1use std::collections::HashMap;
2use std::path::Path;
3
4use annotate_snippets::AnnotationKind;
5use annotate_snippets::Group;
6use annotate_snippets::Level;
7use annotate_snippets::Origin;
8use annotate_snippets::Patch;
9use annotate_snippets::Snippet;
10use cargo_platform::Platform;
11use cargo_util_schemas::manifest::TomlDependency;
12use cargo_util_schemas::manifest::TomlToolLints;
13use toml::de::DeValue;
14
15use crate::CargoResult;
16use crate::GlobalContext;
17use crate::core::Manifest;
18use crate::core::MaybePackage;
19use crate::core::Package;
20use crate::lints::Lint;
21use crate::lints::LintLevel;
22use crate::lints::LintLevelReason;
23use crate::lints::ManifestFor;
24use crate::lints::PEDANTIC;
25use crate::lints::get_key_value;
26use crate::lints::rel_cwd_manifest_path;
27use crate::util::OptVersionReq;
28
29pub const LINT: Lint = Lint {
30 name: "implicit_minimum_version_req",
31 desc: "dependency version requirement without an explicit minimum version",
32 primary_group: &PEDANTIC,
33 edition_lint_opts: None,
34 feature_gate: None,
35 docs: Some(
36 r#"
37### What it does
38
39Checks for dependency version requirements
40that do not explicitly specify a full `major.minor.patch` version requirement,
41such as `serde = "1"` or `serde = "1.0"`.
42
43This lint currently only applies to caret requirements
44(the [default requirements](specifying-dependencies.md#default-requirements)).
45
46### Why it is bad
47
48Version requirements without an explicit full version
49can be misleading about the actual minimum supported version.
50For example,
51`serde = "1"` has an implicit minimum bound of `1.0.0`.
52If your code actually requires features from `1.0.219`,
53the implicit minimum bound of `1.0.0` gives a false impression about compatibility.
54
55Specifying the full version helps with:
56
57- Accurate minimum version documentation
58- Better compatibility with `-Z minimal-versions`
59- Clearer dependency constraints for consumers
60
61### Drawbacks
62
63Even with a fully specified version,
64the minimum bound might still be incorrect if untested.
65This lint helps make the minimum version requirement explicit
66but doesn't guarantee correctness.
67
68### Example
69
70```toml
71[dependencies]
72serde = "1"
73```
74
75Should be written as a full specific version:
76
77```toml
78[dependencies]
79serde = "1.0.219"
80```
81"#,
82 ),
83};
84
85pub fn implicit_minimum_version_req(
86 manifest: ManifestFor<'_>,
87 manifest_path: &Path,
88 cargo_lints: &TomlToolLints,
89 error_count: &mut usize,
90 gctx: &GlobalContext,
91) -> CargoResult<()> {
92 let (lint_level, reason) = manifest.lint_level(cargo_lints, LINT);
93
94 if lint_level == LintLevel::Allow {
95 return Ok(());
96 }
97
98 let manifest_path = rel_cwd_manifest_path(manifest_path, gctx);
99
100 match manifest {
101 ManifestFor::Package(pkg) => {
102 lint_package(pkg, manifest_path, lint_level, reason, error_count, gctx)
103 }
104 ManifestFor::Workspace(maybe_pkg) => lint_workspace(
105 maybe_pkg,
106 manifest_path,
107 lint_level,
108 reason,
109 error_count,
110 gctx,
111 ),
112 }
113}
114
115pub fn lint_package(
116 pkg: &Package,
117 manifest_path: String,
118 lint_level: LintLevel,
119 reason: LintLevelReason,
120 error_count: &mut usize,
121 gctx: &GlobalContext,
122) -> CargoResult<()> {
123 let manifest = pkg.manifest();
124
125 let document = manifest.document();
126 let contents = manifest.contents();
127 let target_key_for_platform = target_key_for_platform(&manifest);
128
129 for dep in manifest.dependencies().iter() {
130 let version_req = dep.version_req();
131 let Some(suggested_req) = get_suggested_version_req(&version_req) else {
132 continue;
133 };
134
135 let name_in_toml = dep.name_in_toml().as_str();
136 let key_path =
137 if let Some(cfg) = dep.platform().and_then(|p| target_key_for_platform.get(p)) {
138 &["target", &cfg, dep.kind().kind_table(), name_in_toml][..]
139 } else {
140 &[dep.kind().kind_table(), name_in_toml][..]
141 };
142
143 let Some(report) = report(
144 lint_level,
145 reason,
146 contents,
147 document,
148 key_path,
149 &manifest_path,
150 &suggested_req,
151 ) else {
152 continue;
153 };
154
155 if lint_level.is_error() {
156 *error_count += 1;
157 }
158 gctx.shell().print_report(&report, lint_level.force())?;
159 }
160
161 Ok(())
162}
163
164pub fn lint_workspace(
165 maybe_pkg: &MaybePackage,
166 manifest_path: String,
167 lint_level: LintLevel,
168 reason: LintLevelReason,
169 error_count: &mut usize,
170 gctx: &GlobalContext,
171) -> CargoResult<()> {
172 let document = maybe_pkg.document();
173 let contents = maybe_pkg.contents();
174 let toml = match maybe_pkg {
175 MaybePackage::Package(p) => p.manifest().normalized_toml(),
176 MaybePackage::Virtual(vm) => vm.normalized_toml(),
177 };
178 let dep_iter = toml
179 .workspace
180 .as_ref()
181 .and_then(|ws| ws.dependencies.as_ref())
182 .into_iter()
183 .flat_map(|deps| deps.iter())
184 .map(|(name, dep)| {
185 let name = name.as_str();
186 let ver = match dep {
187 TomlDependency::Simple(ver) => ver,
188 TomlDependency::Detailed(detailed) => {
189 let Some(ver) = detailed.version.as_ref() else {
190 return (name, OptVersionReq::Any);
191 };
192 ver
193 }
194 };
195 let req = semver::VersionReq::parse(ver)
196 .map(Into::into)
197 .unwrap_or(OptVersionReq::Any);
198 (name, req)
199 });
200
201 for (name_in_toml, version_req) in dep_iter {
202 let Some(suggested_req) = get_suggested_version_req(&version_req) else {
203 continue;
204 };
205
206 let key_path = ["workspace", "dependencies", name_in_toml];
207
208 let Some(report) = report(
209 lint_level,
210 reason,
211 contents,
212 document,
213 &key_path,
214 &manifest_path,
215 &suggested_req,
216 ) else {
217 continue;
218 };
219
220 if lint_level.is_error() {
221 *error_count += 1;
222 }
223 gctx.shell().print_report(&report, lint_level.force())?;
224 }
225
226 Ok(())
227}
228
229pub fn span_of_version_req<'doc>(
230 document: &'doc toml::Spanned<toml::de::DeTable<'static>>,
231 path: &[&str],
232) -> Option<std::ops::Range<usize>> {
233 let (_key, value) = get_key_value(document, path)?;
234
235 match value.as_ref() {
236 DeValue::String(_) => Some(value.span()),
237 DeValue::Table(map) if map.get("workspace").is_some() => {
238 None
240 }
241 DeValue::Table(map) => {
242 let Some(v) = map.get("version") else {
243 panic!("version must be specified or workspace-inherited");
244 };
245 Some(v.span())
246 }
247 _ => unreachable!("dependency must be string or table"),
248 }
249}
250
251fn report<'a>(
252 lint_level: LintLevel,
253 reason: LintLevelReason,
254 contents: Option<&'a str>,
255 document: Option<&toml::Spanned<toml::de::DeTable<'static>>>,
256 key_path: &[&str],
257 manifest_path: &str,
258 suggested_req: &str,
259) -> Option<[Group<'a>; 2]> {
260 let level = lint_level.to_diagnostic_level();
261 let emitted_source = LINT.emitted_source(lint_level, reason);
262 let replacement = format!(r#""{suggested_req}""#);
263 let label = "missing full version components";
264 let secondary_title = "consider specifying full `major.minor.patch` version components";
265
266 let mut desc = Group::with_title(level.primary_title(LINT.desc));
267 let mut help = Group::with_title(Level::HELP.secondary_title(secondary_title));
268
269 if let Some(document) = document
270 && let Some(contents) = contents
271 {
272 let Some(span) = span_of_version_req(document, key_path) else {
273 return None;
274 };
275 desc = desc.element(
276 Snippet::source(contents)
277 .path(manifest_path.to_owned())
278 .annotation(AnnotationKind::Primary.span(span.clone()).label(label)),
279 );
280 help = help
281 .element(Snippet::source(contents).patch(Patch::new(span.clone(), replacement)))
282 .element(Level::NOTE.message(emitted_source));
283 } else {
284 desc = desc.element(Origin::path(manifest_path.to_owned()));
285 help = help.element(Level::NOTE.message(emitted_source));
286 }
287
288 Some([desc, help])
289}
290
291fn get_suggested_version_req(req: &OptVersionReq) -> Option<String> {
292 use semver::Op;
293 let OptVersionReq::Req(req) = req else {
294 return None;
295 };
296 let mut has_suggestions = false;
297 let mut comparators = Vec::new();
298
299 for mut cmp in req.comparators.iter().cloned() {
300 match cmp.op {
301 Op::Caret | Op::GreaterEq => {
302 if cmp.minor.is_some() && cmp.patch.is_some() {
304 comparators.push(cmp);
305 continue;
306 } else {
307 has_suggestions = true;
308 cmp.minor.get_or_insert(0);
309 cmp.patch.get_or_insert(0);
310 comparators.push(cmp);
311 }
312 }
313 Op::Exact | Op::Tilde | Op::Wildcard | Op::Greater | Op::Less | Op::LessEq => {
314 comparators.push(cmp);
315 continue;
316 }
317 _ => panic!("unknown comparator in `{cmp}`"),
318 }
319 }
320
321 if !has_suggestions {
322 return None;
323 }
324
325 let mut suggestion = String::new();
330
331 for cmp in &comparators {
332 if !suggestion.is_empty() {
333 suggestion.push_str(", ");
334 }
335 let s = cmp.to_string();
336
337 if cmp.op == Op::Caret {
338 suggestion.push_str(s.strip_prefix('^').unwrap_or(&s));
339 } else {
340 suggestion.push_str(&s);
341 }
342 }
343
344 Some(suggestion)
345}
346
347fn target_key_for_platform(manifest: &Manifest) -> HashMap<Platform, String> {
352 manifest
353 .normalized_toml()
354 .target
355 .as_ref()
356 .map(|map| {
357 map.keys()
358 .map(|k| (k.parse().expect("already parsed"), k.clone()))
359 .collect()
360 })
361 .unwrap_or_default()
362}