Skip to main content

depguard_types/
explain.rs

1//! Explain registry for checks and codes.
2//!
3//! Maps check IDs and codes to human-readable explanations with remediation guidance.
4
5use crate::ids;
6
7/// Explanation entry for a check or code.
8#[derive(Debug, Clone)]
9pub struct Explanation {
10    /// Short description of the check/code.
11    pub title: &'static str,
12    /// What the check does and why it exists.
13    pub description: &'static str,
14    /// How to fix violations.
15    pub remediation: &'static str,
16    /// Before/after code examples.
17    pub examples: ExamplePair,
18}
19
20/// Before and after code examples.
21#[derive(Debug, Clone)]
22pub struct ExamplePair {
23    /// Code that would trigger a finding.
24    pub before: &'static str,
25    /// Code that passes the check.
26    pub after: &'static str,
27}
28
29/// Look up an explanation by check_id or code.
30///
31/// Returns `None` if the identifier is not recognized.
32pub fn lookup_explanation(identifier: &str) -> Option<Explanation> {
33    // Try check_id first, then code
34    match identifier {
35        // Check IDs
36        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        // Codes
49        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
66/// List all known check IDs.
67pub 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
83/// List all known codes.
84pub 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
101// --- Check-level explanations ---
102
103fn 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
281// --- Code-level explanations ---
282
283fn explain_wildcard_version() -> Explanation {
284    // Same as the check, but framed as the specific code
285    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
382// --- New check explanations ---
383
384fn 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}