cargo/lints/rules/
implicit_minimum_version_req.rs

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            // We only lint non-workspace-inherited dependencies
239            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                // Only focus on comparator that has only `major` or `major.minor`
303                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    // This is a lossy suggestion that
326    //
327    // * extra spaces are removed
328    // * caret operator `^` is stripped
329    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
347/// A map from parsed `Platform` to their original TOML key strings.
348/// This is needed for constructing TOML key paths in diagnostics.
349///
350/// This is only relevant for package dependencies.
351fn 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}