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::core::Workspace;
21use crate::lints::Lint;
22use crate::lints::LintLevel;
23use crate::lints::LintLevelReason;
24use crate::lints::PEDANTIC;
25use crate::lints::get_key_value;
26use crate::lints::rel_cwd_manifest_path;
27use crate::util::OptVersionReq;
28
29pub static LINT: &Lint = &Lint {
30 name: "implicit_minimum_version_req",
31 desc: "dependency version requirement without an explicit minimum version",
32 primary_group: &PEDANTIC,
33 msrv: 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_pkg(
86 pkg: &Package,
87 manifest_path: &Path,
88 cargo_lints: &TomlToolLints,
89 error_count: &mut usize,
90 gctx: &GlobalContext,
91) -> CargoResult<()> {
92 let (lint_level, reason) = LINT.level(
93 cargo_lints,
94 pkg.rust_version(),
95 pkg.manifest().unstable_features(),
96 );
97
98 if lint_level == LintLevel::Allow {
99 return Ok(());
100 }
101
102 let manifest_path = rel_cwd_manifest_path(manifest_path, gctx);
103
104 let manifest = pkg.manifest();
105
106 let document = manifest.document();
107 let contents = manifest.contents();
108 let target_key_for_platform = target_key_for_platform(&manifest);
109
110 let mut emit_source = true;
111 for dep in manifest.dependencies().iter() {
112 let version_req = dep.version_req();
113 let Some(suggested_req) = get_suggested_version_req(&version_req) else {
114 continue;
115 };
116
117 let name_in_toml = dep.name_in_toml().as_str();
118 let key_path =
119 if let Some(cfg) = dep.platform().and_then(|p| target_key_for_platform.get(p)) {
120 &["target", &cfg, dep.kind().kind_table(), name_in_toml][..]
121 } else {
122 &[dep.kind().kind_table(), name_in_toml][..]
123 };
124
125 let Some(report) = report(
126 lint_level,
127 reason,
128 contents,
129 document,
130 key_path,
131 &manifest_path,
132 &suggested_req,
133 emit_source,
134 ) else {
135 continue;
136 };
137
138 if emit_source {
139 emit_source = false;
140 }
141
142 if lint_level.is_error() {
143 *error_count += 1;
144 }
145 gctx.shell().print_report(&report, lint_level.force())?;
146 }
147
148 Ok(())
149}
150
151pub fn implicit_minimum_version_req_ws(
152 ws: &Workspace<'_>,
153 maybe_pkg: &MaybePackage,
154 manifest_path: &Path,
155 cargo_lints: &TomlToolLints,
156 error_count: &mut usize,
157 gctx: &GlobalContext,
158) -> CargoResult<()> {
159 let (lint_level, reason) = LINT.level(
160 cargo_lints,
161 ws.lowest_rust_version(),
162 maybe_pkg.unstable_features(),
163 );
164
165 if lint_level == LintLevel::Allow {
166 return Ok(());
167 }
168
169 let manifest_path = rel_cwd_manifest_path(manifest_path, gctx);
170
171 let document = maybe_pkg.document();
172 let contents = maybe_pkg.contents();
173 let toml = match maybe_pkg {
174 MaybePackage::Package(p) => p.manifest().normalized_toml(),
175 MaybePackage::Virtual(vm) => vm.normalized_toml(),
176 };
177 let dep_iter = toml
178 .workspace
179 .as_ref()
180 .and_then(|ws| ws.dependencies.as_ref())
181 .into_iter()
182 .flat_map(|deps| deps.iter())
183 .map(|(name, dep)| {
184 let name = name.as_str();
185 let ver = match dep {
186 TomlDependency::Simple(ver) => ver,
187 TomlDependency::Detailed(detailed) => {
188 let Some(ver) = detailed.version.as_ref() else {
189 return (name, OptVersionReq::Any);
190 };
191 ver
192 }
193 };
194 let req = semver::VersionReq::parse(ver)
195 .map(Into::into)
196 .unwrap_or(OptVersionReq::Any);
197 (name, req)
198 });
199
200 let mut emit_source = true;
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 emit_source,
217 ) else {
218 continue;
219 };
220
221 if emit_source {
222 emit_source = false;
223 }
224
225 if lint_level.is_error() {
226 *error_count += 1;
227 }
228 gctx.shell().print_report(&report, lint_level.force())?;
229 }
230
231 Ok(())
232}
233
234pub fn span_of_version_req<'doc>(
235 document: &'doc toml::Spanned<toml::de::DeTable<'static>>,
236 path: &[&str],
237) -> Option<std::ops::Range<usize>> {
238 let (_key, value) = get_key_value(document, path)?;
239
240 match value.as_ref() {
241 DeValue::String(_) => Some(value.span()),
242 DeValue::Table(map) if map.get("workspace").is_some() => {
243 None
245 }
246 DeValue::Table(map) => {
247 let Some(v) = map.get("version") else {
248 panic!("version must be specified or workspace-inherited");
249 };
250 Some(v.span())
251 }
252 _ => unreachable!("dependency must be string or table"),
253 }
254}
255
256fn report<'a>(
257 lint_level: LintLevel,
258 reason: LintLevelReason,
259 contents: Option<&'a str>,
260 document: Option<&toml::Spanned<toml::de::DeTable<'static>>>,
261 key_path: &[&str],
262 manifest_path: &str,
263 suggested_req: &str,
264 emit_source: bool,
265) -> Option<[Group<'a>; 2]> {
266 let level = lint_level.to_diagnostic_level();
267 let emitted_source = LINT.emitted_source(lint_level, reason);
268 let replacement = format!(r#""{suggested_req}""#);
269 let label = "missing full version components";
270 let secondary_title = "consider specifying full `major.minor.patch` version components";
271
272 let mut desc = Group::with_title(level.primary_title(LINT.desc));
273 let mut help = Group::with_title(Level::HELP.secondary_title(secondary_title));
274
275 if let Some(document) = document
276 && let Some(contents) = contents
277 {
278 let Some(span) = span_of_version_req(document, key_path) else {
279 return None;
280 };
281 desc = desc.element(
282 Snippet::source(contents)
283 .path(manifest_path.to_owned())
284 .annotation(AnnotationKind::Primary.span(span.clone()).label(label)),
285 );
286
287 help = help.element(Snippet::source(contents).patch(Patch::new(span.clone(), replacement)));
288 } else {
289 desc = desc.element(Origin::path(manifest_path.to_owned()));
290 }
291
292 if emit_source {
293 desc = desc.element(Level::NOTE.message(emitted_source));
294 }
295
296 Some([desc, help])
297}
298
299fn get_suggested_version_req(req: &OptVersionReq) -> Option<String> {
300 use semver::Op;
301 let OptVersionReq::Req(req) = req else {
302 return None;
303 };
304 let mut has_suggestions = false;
305 let mut comparators = Vec::new();
306
307 for mut cmp in req.comparators.iter().cloned() {
308 match cmp.op {
309 Op::Caret | Op::GreaterEq => {
310 if cmp.minor.is_some() && cmp.patch.is_some() {
312 comparators.push(cmp);
313 continue;
314 } else {
315 has_suggestions = true;
316 cmp.minor.get_or_insert(0);
317 cmp.patch.get_or_insert(0);
318 comparators.push(cmp);
319 }
320 }
321 Op::Exact | Op::Tilde | Op::Wildcard | Op::Greater | Op::Less | Op::LessEq => {
322 comparators.push(cmp);
323 continue;
324 }
325 _ => panic!("unknown comparator in `{cmp}`"),
326 }
327 }
328
329 if !has_suggestions {
330 return None;
331 }
332
333 let mut suggestion = String::new();
338
339 for cmp in &comparators {
340 if !suggestion.is_empty() {
341 suggestion.push_str(", ");
342 }
343 let s = cmp.to_string();
344
345 if cmp.op == Op::Caret {
346 suggestion.push_str(s.strip_prefix('^').unwrap_or(&s));
347 } else {
348 suggestion.push_str(&s);
349 }
350 }
351
352 Some(suggestion)
353}
354
355fn target_key_for_platform(manifest: &Manifest) -> HashMap<Platform, String> {
360 manifest
361 .normalized_toml()
362 .target
363 .as_ref()
364 .map(|map| {
365 map.keys()
366 .map(|k| (k.parse().expect("already parsed"), k.clone()))
367 .collect()
368 })
369 .unwrap_or_default()
370}