1use crate::ids;
6
7#[derive(Debug, Clone)]
9pub struct Explanation {
10 pub title: &'static str,
12 pub description: &'static str,
14 pub remediation: &'static str,
16 pub examples: ExamplePair,
18}
19
20#[derive(Debug, Clone)]
22pub struct ExamplePair {
23 pub before: &'static str,
25 pub after: &'static str,
27}
28
29pub fn lookup_explanation(identifier: &str) -> Option<Explanation> {
33 match identifier {
35 ids::CHECK_DEPS_NO_WILDCARDS => Some(explain_no_wildcards()),
37 ids::CHECK_DEPS_PATH_REQUIRES_VERSION => Some(explain_path_requires_version()),
38 ids::CHECK_DEPS_PATH_SAFETY => Some(explain_path_safety()),
39 ids::CHECK_DEPS_WORKSPACE_INHERITANCE => Some(explain_workspace_inheritance()),
40 ids::CHECK_DEPS_GIT_REQUIRES_VERSION => Some(explain_git_requires_version()),
41 ids::CHECK_DEPS_DEV_ONLY_IN_NORMAL => Some(explain_dev_only_in_normal()),
42 ids::CHECK_DEPS_DEFAULT_FEATURES_EXPLICIT => Some(explain_default_features_explicit()),
43 ids::CHECK_DEPS_NO_MULTIPLE_VERSIONS => Some(explain_no_multiple_versions()),
44 ids::CHECK_DEPS_OPTIONAL_UNUSED => Some(explain_optional_unused()),
45 ids::CHECK_DEPS_YANKED_VERSIONS => Some(explain_yanked_versions()),
46 ids::CHECK_TOOL_RUNTIME => Some(explain_tool_runtime()),
47
48 ids::CODE_WILDCARD_VERSION => Some(explain_wildcard_version()),
50 ids::CODE_PATH_WITHOUT_VERSION => Some(explain_path_without_version()),
51 ids::CODE_ABSOLUTE_PATH => Some(explain_absolute_path()),
52 ids::CODE_PARENT_ESCAPE => Some(explain_parent_escape()),
53 ids::CODE_MISSING_WORKSPACE_TRUE => Some(explain_missing_workspace_true()),
54 ids::CODE_GIT_WITHOUT_VERSION => Some(explain_git_without_version()),
55 ids::CODE_DEV_DEP_IN_NORMAL => Some(explain_dev_dep_in_normal()),
56 ids::CODE_DEFAULT_FEATURES_IMPLICIT => Some(explain_default_features_implicit()),
57 ids::CODE_DUPLICATE_DIFFERENT_VERSIONS => Some(explain_duplicate_different_versions()),
58 ids::CODE_OPTIONAL_NOT_IN_FEATURES => Some(explain_optional_not_in_features()),
59 ids::CODE_VERSION_YANKED => Some(explain_version_yanked()),
60 ids::CODE_RUNTIME_ERROR => Some(explain_runtime_error()),
61
62 _ => None,
63 }
64}
65
66pub fn all_check_ids() -> &'static [&'static str] {
68 &[
69 ids::CHECK_DEPS_NO_WILDCARDS,
70 ids::CHECK_DEPS_PATH_REQUIRES_VERSION,
71 ids::CHECK_DEPS_PATH_SAFETY,
72 ids::CHECK_DEPS_WORKSPACE_INHERITANCE,
73 ids::CHECK_DEPS_GIT_REQUIRES_VERSION,
74 ids::CHECK_DEPS_DEV_ONLY_IN_NORMAL,
75 ids::CHECK_DEPS_DEFAULT_FEATURES_EXPLICIT,
76 ids::CHECK_DEPS_NO_MULTIPLE_VERSIONS,
77 ids::CHECK_DEPS_OPTIONAL_UNUSED,
78 ids::CHECK_DEPS_YANKED_VERSIONS,
79 ids::CHECK_TOOL_RUNTIME,
80 ]
81}
82
83pub fn all_codes() -> &'static [&'static str] {
85 &[
86 ids::CODE_WILDCARD_VERSION,
87 ids::CODE_PATH_WITHOUT_VERSION,
88 ids::CODE_ABSOLUTE_PATH,
89 ids::CODE_PARENT_ESCAPE,
90 ids::CODE_MISSING_WORKSPACE_TRUE,
91 ids::CODE_GIT_WITHOUT_VERSION,
92 ids::CODE_DEV_DEP_IN_NORMAL,
93 ids::CODE_DEFAULT_FEATURES_IMPLICIT,
94 ids::CODE_DUPLICATE_DIFFERENT_VERSIONS,
95 ids::CODE_OPTIONAL_NOT_IN_FEATURES,
96 ids::CODE_VERSION_YANKED,
97 ids::CODE_RUNTIME_ERROR,
98 ]
99}
100
101fn explain_no_wildcards() -> Explanation {
104 Explanation {
105 title: "No Wildcard Versions",
106 description: "\
107Detects dependencies declared with wildcard version requirements like `*` or `1.*`.
108
109Wildcard versions are problematic because:
110- They allow any version to be selected, including breaking changes
111- Builds are not reproducible across different points in time
112- Security vulnerabilities in newer versions may be pulled in unknowingly
113- cargo publish rejects crates with wildcard dependencies",
114 remediation: "\
115Replace wildcard versions with explicit semver requirements:
116- Use `^1.2.3` (caret, default) for compatible updates within the same major version
117- Use `~1.2.3` (tilde) for patch-level updates only
118- Use `=1.2.3` for an exact version pin
119- Use `>=1.2.0, <2.0.0` for explicit version ranges",
120 examples: ExamplePair {
121 before: r#"[dependencies]
122serde = "*"
123tokio = "1.*""#,
124 after: r#"[dependencies]
125serde = "1.0"
126tokio = "1.35""#,
127 },
128 }
129}
130
131fn explain_path_requires_version() -> Explanation {
132 Explanation {
133 title: "Path Dependencies Require Version",
134 description: "\
135Detects path dependencies in publishable crates that lack an explicit version.
136
137When publishing a crate to crates.io, Cargo ignores the `path` key and uses only
138the version from the registry. If no version is specified:
139- The crate cannot be published (cargo publish will fail)
140- Users who depend on your crate won't be able to build it
141
142This check only applies to crates that can be published (publish != false).",
143 remediation: "\
144Add an explicit version alongside the path:
145
146 my-crate = { path = \"../my-crate\", version = \"0.1.0\" }
147
148Alternatively, use workspace inheritance:
149
150 my-crate.workspace = true
151
152Or mark the crate as unpublishable in its Cargo.toml:
153
154 [package]
155 publish = false",
156 examples: ExamplePair {
157 before: r#"[dependencies]
158my-lib = { path = "../my-lib" }"#,
159 after: r#"[dependencies]
160my-lib = { path = "../my-lib", version = "0.1.0" }
161
162# Or use workspace inheritance:
163my-lib.workspace = true"#,
164 },
165 }
166}
167
168fn explain_path_safety() -> Explanation {
169 Explanation {
170 title: "Path Dependency Safety",
171 description: "\
172Detects path dependencies that use absolute paths or escape the repository root.
173
174This check flags two issues:
1751. Absolute paths (e.g., `/home/user/code/lib` or `C:\\Code\\lib`)
1762. Parent references (`..`) that escape outside the repository root
177
178Both patterns cause problems:
179- Absolute paths are machine-specific and not portable
180- Escaping the repo root means the dependency is not version-controlled with the project
181- CI/CD builds will fail when paths don't exist on the build machine
182- Other contributors cannot build the project without identical directory layouts",
183 remediation: "\
184Use repo-relative paths that stay within the repository:
185
186 my-crate = { path = \"../sibling-crate\" } # OK if still in repo
187 my-crate = { path = \"crates/my-crate\" } # Always OK
188
189If you need an external dependency:
190- Publish it to crates.io or a private registry
191- Use a git dependency with a URL
192- Move the dependency into the workspace",
193 examples: ExamplePair {
194 before: r#"[dependencies]
195# Absolute path - not portable
196my-lib = { path = "/home/user/code/my-lib" }
197
198# Escapes repo root
199other-lib = { path = "../../../outside-repo/lib" }"#,
200 after: r#"[dependencies]
201# Repo-relative path
202my-lib = { path = "../my-lib" }
203
204# Or use a git/registry dependency for external code
205other-lib = { git = "https://github.com/org/other-lib" }"#,
206 },
207 }
208}
209
210fn explain_workspace_inheritance() -> Explanation {
211 Explanation {
212 title: "Workspace Dependency Inheritance",
213 description: "\
214Detects dependencies that exist in [workspace.dependencies] but are not using
215`workspace = true` inheritance.
216
217When a workspace defines shared dependencies in [workspace.dependencies], member
218crates should inherit them to ensure:
219- Consistent versions across all workspace crates
220- Single source of truth for dependency versions
221- Easier bulk updates when upgrading dependencies
222- Reduced duplication in Cargo.toml files",
223 remediation: "\
224Change the dependency declaration to use workspace inheritance:
225
226 # In member crate's Cargo.toml
227 [dependencies]
228 serde.workspace = true
229
230You can still add local features while inheriting the version:
231
232 serde = { workspace = true, features = [\"derive\"] }
233
234If you intentionally need a different version, add the dependency to the
235check's allow list in depguard.toml.",
236 examples: ExamplePair {
237 before: r#"# In Cargo.toml (workspace root)
238[workspace.dependencies]
239serde = "1.0"
240
241# In crates/my-crate/Cargo.toml
242[dependencies]
243serde = "1.0" # Duplicates workspace definition"#,
244 after: r#"# In Cargo.toml (workspace root)
245[workspace.dependencies]
246serde = "1.0"
247
248# In crates/my-crate/Cargo.toml
249[dependencies]
250serde.workspace = true
251
252# Or with additional features:
253serde = { workspace = true, features = ["derive"] }"#,
254 },
255 }
256}
257
258fn explain_tool_runtime() -> Explanation {
259 Explanation {
260 title: "Tool Runtime Error",
261 description: "\
262Depguard encountered an internal error or invalid environment while running.
263
264This indicates the tool could not complete analysis due to a runtime failure
265such as an invalid config file, missing repository root, or a git error.",
266 remediation: "\
267Fix the underlying error and re-run depguard:
268- Check the error message in stderr
269- Fix invalid depguard.toml syntax or values
270- Ensure the repo root exists and is accessible
271- Provide required git history for diff scope",
272 examples: ExamplePair {
273 before: r#"# Fails with a tool runtime error
274depguard check --repo-root /missing/path"#,
275 after: r#"# Succeeds after fixing the input
276depguard check --repo-root ."#,
277 },
278 }
279}
280
281fn explain_wildcard_version() -> Explanation {
284 let mut exp = explain_no_wildcards();
286 exp.title = "Wildcard Version";
287 exp
288}
289
290fn explain_path_without_version() -> Explanation {
291 let mut exp = explain_path_requires_version();
292 exp.title = "Path Without Version";
293 exp
294}
295
296fn explain_absolute_path() -> Explanation {
297 Explanation {
298 title: "Absolute Path Dependency",
299 description: "\
300A dependency is declared with an absolute filesystem path.
301
302Absolute paths like `/home/user/code/lib` or `C:\\Code\\lib` are:
303- Machine-specific and not portable across systems
304- Not reproducible in CI/CD environments
305- Not shareable with other contributors
306- A potential security concern (may leak host directory structure)",
307 remediation: "\
308Convert to a repo-relative path:
309
310 my-crate = { path = \"../my-crate\" }
311
312Or use a published/git dependency:
313
314 my-crate = \"1.0\"
315 my-crate = { git = \"https://github.com/org/my-crate\" }",
316 examples: ExamplePair {
317 before: r#"[dependencies]
318my-lib = { path = "/home/user/projects/my-lib" }
319win-lib = { path = "C:\\Code\\win-lib" }"#,
320 after: r#"[dependencies]
321my-lib = { path = "../my-lib" }
322win-lib = { path = "../win-lib" }"#,
323 },
324 }
325}
326
327fn explain_parent_escape() -> Explanation {
328 Explanation {
329 title: "Path Escapes Repository Root",
330 description: "\
331A path dependency uses `..` segments that navigate outside the repository root.
332
333This typically happens when:
334- A dependency lives in a sibling directory outside the repo
335- The path was copied from another project with different structure
336- A monorepo was split but paths weren't updated
337
338Dependencies outside the repository:
339- Are not version-controlled with the project
340- Won't exist on CI/CD machines
341- Cannot be cloned by other contributors
342- Break the principle of self-contained repositories",
343 remediation: "\
344Move the dependency into the workspace, or use an external reference:
345
3461. Move into workspace:
347 mv ../external-lib crates/external-lib
348 # Update path to: { path = \"crates/external-lib\" }
349
3502. Use git dependency:
351 external-lib = { git = \"https://github.com/org/external-lib\" }
352
3533. Publish to a registry:
354 external-lib = \"1.0\"",
355 examples: ExamplePair {
356 before: r#"# From crates/my-app/Cargo.toml
357[dependencies]
358# Escapes repo: crates/my-app -> crates -> repo-root -> ??? (outside!)
359shared = { path = "../../../shared-libs/common" }"#,
360 after: r#"# Move shared into the workspace, then:
361[dependencies]
362shared = { path = "../shared" }
363
364# Or use a git/registry dependency:
365shared = { git = "https://github.com/org/shared-libs", subdirectory = "common" }"#,
366 },
367 }
368}
369
370fn explain_missing_workspace_true() -> Explanation {
371 let mut exp = explain_workspace_inheritance();
372 exp.title = "Missing workspace = true";
373 exp
374}
375
376fn explain_runtime_error() -> Explanation {
377 let mut exp = explain_tool_runtime();
378 exp.title = "Runtime Error";
379 exp
380}
381
382fn explain_git_requires_version() -> Explanation {
385 Explanation {
386 title: "Git Dependencies Require Version",
387 description: "\
388Detects git dependencies in publishable crates that lack an explicit version.
389
390When publishing a crate to crates.io, Cargo ignores the `git` key and uses only
391the version from the registry. If no version is specified:
392- The crate cannot be published (cargo publish will fail)
393- Users who depend on your crate won't be able to build it
394
395This check only applies to crates that can be published (publish != false).",
396 remediation: "\
397Add an explicit version alongside the git URL:
398
399 my-crate = { git = \"https://github.com/org/repo\", version = \"0.1.0\" }
400
401Alternatively, use workspace inheritance:
402
403 my-crate.workspace = true
404
405Or mark the crate as unpublishable in its Cargo.toml:
406
407 [package]
408 publish = false",
409 examples: ExamplePair {
410 before: r#"[dependencies]
411my-lib = { git = "https://github.com/org/my-lib" }"#,
412 after: r#"[dependencies]
413my-lib = { git = "https://github.com/org/my-lib", version = "0.1.0" }
414
415# Or use workspace inheritance:
416my-lib.workspace = true"#,
417 },
418 }
419}
420
421fn explain_git_without_version() -> Explanation {
422 let mut exp = explain_git_requires_version();
423 exp.title = "Git Without Version";
424 exp
425}
426
427fn explain_dev_only_in_normal() -> Explanation {
428 Explanation {
429 title: "Dev-Only Crate in Normal Dependencies",
430 description: "\
431Detects crates that are typically dev-only appearing in [dependencies].
432
433Some crates are designed exclusively for testing and development:
434- Test frameworks: proptest, quickcheck, rstest, test-case
435- Mocking: mockall, mockito, wiremock
436- Benchmarking: criterion, divan
437- Test utilities: tempfile, assert_cmd, insta
438
439Including these in [dependencies] instead of [dev-dependencies]:
440- Increases binary size for consumers
441- May add unnecessary compile time
442- Suggests a potential configuration error",
443 remediation: "\
444Move the dependency to [dev-dependencies]:
445
446 [dev-dependencies]
447 mockall = \"0.11\"
448 proptest = \"1.0\"
449
450If you genuinely need it in [dependencies] for production code,
451add it to the check's allow list in depguard.toml.",
452 examples: ExamplePair {
453 before: r#"[dependencies]
454mockall = "0.11"
455proptest = "1.0""#,
456 after: r#"[dependencies]
457# Production dependencies only
458
459[dev-dependencies]
460mockall = "0.11"
461proptest = "1.0""#,
462 },
463 }
464}
465
466fn explain_dev_dep_in_normal() -> Explanation {
467 let mut exp = explain_dev_only_in_normal();
468 exp.title = "Dev Dependency in Normal";
469 exp
470}
471
472fn explain_default_features_explicit() -> Explanation {
473 Explanation {
474 title: "Explicit default-features",
475 description: "\
476Detects dependencies with inline options that don't explicitly set default-features.
477
478When a dependency has inline options (features, optional, path, git) but doesn't
479explicitly declare `default-features = true/false`, it can lead to:
480- Unclear intent about whether default features are wanted
481- Accidental inclusion of unwanted features
482- Inconsistent behavior when features change upstream",
483 remediation: "\
484Add an explicit `default-features` declaration:
485
486 # If you want default features:
487 serde = { version = \"1.0\", features = [\"derive\"], default-features = true }
488
489 # If you don't want default features:
490 tokio = { version = \"1.0\", features = [\"rt\"], default-features = false }
491
492For simple version-only dependencies, this check doesn't apply.",
493 examples: ExamplePair {
494 before: r#"[dependencies]
495serde = { version = "1.0", features = ["derive"] }"#,
496 after: r#"[dependencies]
497serde = { version = "1.0", features = ["derive"], default-features = true }"#,
498 },
499 }
500}
501
502fn explain_default_features_implicit() -> Explanation {
503 let mut exp = explain_default_features_explicit();
504 exp.title = "Default Features Implicit";
505 exp
506}
507
508fn explain_no_multiple_versions() -> Explanation {
509 Explanation {
510 title: "No Multiple Versions",
511 description: "\
512Detects the same crate with different versions across workspace members.
513
514Having multiple versions of the same dependency in a workspace:
515- Increases binary size (both versions are compiled)
516- Can cause subtle compatibility issues
517- Makes dependency updates more complex
518- May indicate accidental version drift",
519 remediation: "\
520Align all workspace members to use the same version:
521
5221. Define the dependency in [workspace.dependencies]:
523 [workspace.dependencies]
524 serde = \"1.0.200\"
525
5262. Use workspace inheritance in all members:
527 [dependencies]
528 serde.workspace = true
529
530If intentional version differences are required, add the crate
531to the check's allow list.",
532 examples: ExamplePair {
533 before: r#"# crates/a/Cargo.toml
534[dependencies]
535serde = "1.0.195"
536
537# crates/b/Cargo.toml
538[dependencies]
539serde = "1.0.200""#,
540 after: r#"# Cargo.toml (workspace root)
541[workspace.dependencies]
542serde = "1.0.200"
543
544# crates/a/Cargo.toml
545[dependencies]
546serde.workspace = true
547
548# crates/b/Cargo.toml
549[dependencies]
550serde.workspace = true"#,
551 },
552 }
553}
554
555fn explain_duplicate_different_versions() -> Explanation {
556 let mut exp = explain_no_multiple_versions();
557 exp.title = "Duplicate Different Versions";
558 exp
559}
560
561fn explain_optional_unused() -> Explanation {
562 Explanation {
563 title: "Unused Optional Dependency",
564 description: "\
565Detects optional dependencies that aren't referenced in any feature.
566
567When a dependency is marked `optional = true`, it should be activated by
568at least one feature in the [features] table. An optional dependency that
569isn't referenced in any feature:
570- Cannot be enabled by users
571- Suggests incomplete feature configuration
572- May indicate dead code or misconfiguration",
573 remediation: "\
574Either reference the optional dependency in a feature:
575
576 [features]
577 my-feature = [\"dep:optional-crate\"]
578
579Or remove the `optional = true` if it should always be included:
580
581 [dependencies]
582 my-crate = \"1.0\" # Remove optional = true",
583 examples: ExamplePair {
584 before: r#"[dependencies]
585serde = { version = "1.0", optional = true }
586
587[features]
588# No feature uses serde"#,
589 after: r#"[dependencies]
590serde = { version = "1.0", optional = true }
591
592[features]
593serialization = ["dep:serde"]"#,
594 },
595 }
596}
597
598fn explain_optional_not_in_features() -> Explanation {
599 let mut exp = explain_optional_unused();
600 exp.title = "Optional Not in Features";
601 exp
602}
603
604fn explain_yanked_versions() -> Explanation {
605 Explanation {
606 title: "No Yanked Versions",
607 description: "\
608Detects dependencies pinned to versions listed as yanked in an offline index.
609
610Yanked versions are removed from normal resolution because they often indicate:
611- serious bugs discovered after publish
612- accidental bad releases
613- security or reliability concerns
614
615This check only flags exact pins (`=x.y.z`) when the version appears in the supplied yanked index.",
616 remediation: "\
617Upgrade to a non-yanked version and keep the dependency explicitly pinned:
618
619 serde = \"=1.0.200\"
620
621If the yanked version is intentional for a temporary reason, document it and add
622the dependency to the check allowlist.",
623 examples: ExamplePair {
624 before: r#"[dependencies]
625serde = "=1.0.189""#,
626 after: r#"[dependencies]
627serde = "=1.0.200""#,
628 },
629 }
630}
631
632fn explain_version_yanked() -> Explanation {
633 let mut exp = explain_yanked_versions();
634 exp.title = "Pinned Version Is Yanked";
635 exp
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 #[test]
643 fn lookup_by_check_id() {
644 assert!(lookup_explanation(ids::CHECK_DEPS_NO_WILDCARDS).is_some());
645 assert!(lookup_explanation(ids::CHECK_DEPS_PATH_REQUIRES_VERSION).is_some());
646 assert!(lookup_explanation(ids::CHECK_DEPS_PATH_SAFETY).is_some());
647 assert!(lookup_explanation(ids::CHECK_DEPS_WORKSPACE_INHERITANCE).is_some());
648 assert!(lookup_explanation(ids::CHECK_TOOL_RUNTIME).is_some());
649 }
650
651 #[test]
652 fn lookup_by_code() {
653 assert!(lookup_explanation(ids::CODE_WILDCARD_VERSION).is_some());
654 assert!(lookup_explanation(ids::CODE_PATH_WITHOUT_VERSION).is_some());
655 assert!(lookup_explanation(ids::CODE_ABSOLUTE_PATH).is_some());
656 assert!(lookup_explanation(ids::CODE_PARENT_ESCAPE).is_some());
657 assert!(lookup_explanation(ids::CODE_MISSING_WORKSPACE_TRUE).is_some());
658 assert!(lookup_explanation(ids::CODE_RUNTIME_ERROR).is_some());
659 }
660
661 #[test]
662 fn lookup_unknown_returns_none() {
663 assert!(lookup_explanation("unknown.check").is_none());
664 assert!(lookup_explanation("unknown_code").is_none());
665 }
666
667 #[test]
668 fn all_check_ids_are_valid() {
669 for id in all_check_ids() {
670 assert!(
671 lookup_explanation(id).is_some(),
672 "check_id {} should be in registry",
673 id
674 );
675 }
676 }
677
678 #[test]
679 fn all_codes_are_valid() {
680 for code in all_codes() {
681 assert!(
682 lookup_explanation(code).is_some(),
683 "code {} should be in registry",
684 code
685 );
686 }
687 }
688}