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 let mut emit_source = true;
130 for dep in manifest.dependencies().iter() {
131 let version_req = dep.version_req();
132 let Some(suggested_req) = get_suggested_version_req(&version_req) else {
133 continue;
134 };
135
136 let name_in_toml = dep.name_in_toml().as_str();
137 let key_path =
138 if let Some(cfg) = dep.platform().and_then(|p| target_key_for_platform.get(p)) {
139 &["target", &cfg, dep.kind().kind_table(), name_in_toml][..]
140 } else {
141 &[dep.kind().kind_table(), name_in_toml][..]
142 };
143
144 let Some(report) = report(
145 lint_level,
146 reason,
147 contents,
148 document,
149 key_path,
150 &manifest_path,
151 &suggested_req,
152 emit_source,
153 ) else {
154 continue;
155 };
156
157 if emit_source {
158 emit_source = false;
159 }
160
161 if lint_level.is_error() {
162 *error_count += 1;
163 }
164 gctx.shell().print_report(&report, lint_level.force())?;
165 }
166
167 Ok(())
168}
169
170pub fn lint_workspace(
171 maybe_pkg: &MaybePackage,
172 manifest_path: String,
173 lint_level: LintLevel,
174 reason: LintLevelReason,
175 error_count: &mut usize,
176 gctx: &GlobalContext,
177) -> CargoResult<()> {
178 let document = maybe_pkg.document();
179 let contents = maybe_pkg.contents();
180 let toml = match maybe_pkg {
181 MaybePackage::Package(p) => p.manifest().normalized_toml(),
182 MaybePackage::Virtual(vm) => vm.normalized_toml(),
183 };
184 let dep_iter = toml
185 .workspace
186 .as_ref()
187 .and_then(|ws| ws.dependencies.as_ref())
188 .into_iter()
189 .flat_map(|deps| deps.iter())
190 .map(|(name, dep)| {
191 let name = name.as_str();
192 let ver = match dep {
193 TomlDependency::Simple(ver) => ver,
194 TomlDependency::Detailed(detailed) => {
195 let Some(ver) = detailed.version.as_ref() else {
196 return (name, OptVersionReq::Any);
197 };
198 ver
199 }
200 };
201 let req = semver::VersionReq::parse(ver)
202 .map(Into::into)
203 .unwrap_or(OptVersionReq::Any);
204 (name, req)
205 });
206
207 let mut emit_source = true;
208 for (name_in_toml, version_req) in dep_iter {
209 let Some(suggested_req) = get_suggested_version_req(&version_req) else {
210 continue;
211 };
212
213 let key_path = ["workspace", "dependencies", name_in_toml];
214
215 let Some(report) = report(
216 lint_level,
217 reason,
218 contents,
219 document,
220 &key_path,
221 &manifest_path,
222 &suggested_req,
223 emit_source,
224 ) else {
225 continue;
226 };
227
228 if emit_source {
229 emit_source = false;
230 }
231
232 if lint_level.is_error() {
233 *error_count += 1;
234 }
235 gctx.shell().print_report(&report, lint_level.force())?;
236 }
237
238 Ok(())
239}
240
241pub fn span_of_version_req<'doc>(
242 document: &'doc toml::Spanned<toml::de::DeTable<'static>>,
243 path: &[&str],
244) -> Option<std::ops::Range<usize>> {
245 let (_key, value) = get_key_value(document, path)?;
246
247 match value.as_ref() {
248 DeValue::String(_) => Some(value.span()),
249 DeValue::Table(map) if map.get("workspace").is_some() => {
250 None
252 }
253 DeValue::Table(map) => {
254 let Some(v) = map.get("version") else {
255 panic!("version must be specified or workspace-inherited");
256 };
257 Some(v.span())
258 }
259 _ => unreachable!("dependency must be string or table"),
260 }
261}
262
263fn report<'a>(
264 lint_level: LintLevel,
265 reason: LintLevelReason,
266 contents: Option<&'a str>,
267 document: Option<&toml::Spanned<toml::de::DeTable<'static>>>,
268 key_path: &[&str],
269 manifest_path: &str,
270 suggested_req: &str,
271 emit_source: bool,
272) -> Option<[Group<'a>; 2]> {
273 let level = lint_level.to_diagnostic_level();
274 let emitted_source = LINT.emitted_source(lint_level, reason);
275 let replacement = format!(r#""{suggested_req}""#);
276 let label = "missing full version components";
277 let secondary_title = "consider specifying full `major.minor.patch` version components";
278
279 let mut desc = Group::with_title(level.primary_title(LINT.desc));
280 let mut help = Group::with_title(Level::HELP.secondary_title(secondary_title));
281
282 if let Some(document) = document
283 && let Some(contents) = contents
284 {
285 let Some(span) = span_of_version_req(document, key_path) else {
286 return None;
287 };
288 desc = desc.element(
289 Snippet::source(contents)
290 .path(manifest_path.to_owned())
291 .annotation(AnnotationKind::Primary.span(span.clone()).label(label)),
292 );
293
294 help = help.element(Snippet::source(contents).patch(Patch::new(span.clone(), replacement)));
295 } else {
296 desc = desc.element(Origin::path(manifest_path.to_owned()));
297 }
298
299 if emit_source {
300 desc = desc.element(Level::NOTE.message(emitted_source));
301 }
302
303 Some([desc, help])
304}
305
306fn get_suggested_version_req(req: &OptVersionReq) -> Option<String> {
307 use semver::Op;
308 let OptVersionReq::Req(req) = req else {
309 return None;
310 };
311 let mut has_suggestions = false;
312 let mut comparators = Vec::new();
313
314 for mut cmp in req.comparators.iter().cloned() {
315 match cmp.op {
316 Op::Caret | Op::GreaterEq => {
317 if cmp.minor.is_some() && cmp.patch.is_some() {
319 comparators.push(cmp);
320 continue;
321 } else {
322 has_suggestions = true;
323 cmp.minor.get_or_insert(0);
324 cmp.patch.get_or_insert(0);
325 comparators.push(cmp);
326 }
327 }
328 Op::Exact | Op::Tilde | Op::Wildcard | Op::Greater | Op::Less | Op::LessEq => {
329 comparators.push(cmp);
330 continue;
331 }
332 _ => panic!("unknown comparator in `{cmp}`"),
333 }
334 }
335
336 if !has_suggestions {
337 return None;
338 }
339
340 let mut suggestion = String::new();
345
346 for cmp in &comparators {
347 if !suggestion.is_empty() {
348 suggestion.push_str(", ");
349 }
350 let s = cmp.to_string();
351
352 if cmp.op == Op::Caret {
353 suggestion.push_str(s.strip_prefix('^').unwrap_or(&s));
354 } else {
355 suggestion.push_str(&s);
356 }
357 }
358
359 Some(suggestion)
360}
361
362fn target_key_for_platform(manifest: &Manifest) -> HashMap<Platform, String> {
367 manifest
368 .normalized_toml()
369 .target
370 .as_ref()
371 .map(|map| {
372 map.keys()
373 .map(|k| (k.parse().expect("already parsed"), k.clone()))
374 .collect()
375 })
376 .unwrap_or_default()
377}