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