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