use crate::ids;
#[derive(Debug, Clone)]
pub struct Explanation {
pub title: &'static str,
pub description: &'static str,
pub remediation: &'static str,
pub examples: ExamplePair,
}
#[derive(Debug, Clone)]
pub struct ExamplePair {
pub before: &'static str,
pub after: &'static str,
}
pub fn lookup_explanation(identifier: &str) -> Option<Explanation> {
match identifier {
ids::CHECK_DEPS_NO_WILDCARDS => Some(explain_no_wildcards()),
ids::CHECK_DEPS_PATH_REQUIRES_VERSION => Some(explain_path_requires_version()),
ids::CHECK_DEPS_PATH_SAFETY => Some(explain_path_safety()),
ids::CHECK_DEPS_WORKSPACE_INHERITANCE => Some(explain_workspace_inheritance()),
ids::CHECK_DEPS_GIT_REQUIRES_VERSION => Some(explain_git_requires_version()),
ids::CHECK_DEPS_DEV_ONLY_IN_NORMAL => Some(explain_dev_only_in_normal()),
ids::CHECK_DEPS_DEFAULT_FEATURES_EXPLICIT => Some(explain_default_features_explicit()),
ids::CHECK_DEPS_NO_MULTIPLE_VERSIONS => Some(explain_no_multiple_versions()),
ids::CHECK_DEPS_OPTIONAL_UNUSED => Some(explain_optional_unused()),
ids::CHECK_DEPS_YANKED_VERSIONS => Some(explain_yanked_versions()),
ids::CHECK_TOOL_RUNTIME => Some(explain_tool_runtime()),
ids::CODE_WILDCARD_VERSION => Some(explain_wildcard_version()),
ids::CODE_PATH_WITHOUT_VERSION => Some(explain_path_without_version()),
ids::CODE_ABSOLUTE_PATH => Some(explain_absolute_path()),
ids::CODE_PARENT_ESCAPE => Some(explain_parent_escape()),
ids::CODE_MISSING_WORKSPACE_TRUE => Some(explain_missing_workspace_true()),
ids::CODE_GIT_WITHOUT_VERSION => Some(explain_git_without_version()),
ids::CODE_DEV_DEP_IN_NORMAL => Some(explain_dev_dep_in_normal()),
ids::CODE_DEFAULT_FEATURES_IMPLICIT => Some(explain_default_features_implicit()),
ids::CODE_DUPLICATE_DIFFERENT_VERSIONS => Some(explain_duplicate_different_versions()),
ids::CODE_OPTIONAL_NOT_IN_FEATURES => Some(explain_optional_not_in_features()),
ids::CODE_VERSION_YANKED => Some(explain_version_yanked()),
ids::CODE_RUNTIME_ERROR => Some(explain_runtime_error()),
_ => None,
}
}
pub fn all_check_ids() -> &'static [&'static str] {
&[
ids::CHECK_DEPS_NO_WILDCARDS,
ids::CHECK_DEPS_PATH_REQUIRES_VERSION,
ids::CHECK_DEPS_PATH_SAFETY,
ids::CHECK_DEPS_WORKSPACE_INHERITANCE,
ids::CHECK_DEPS_GIT_REQUIRES_VERSION,
ids::CHECK_DEPS_DEV_ONLY_IN_NORMAL,
ids::CHECK_DEPS_DEFAULT_FEATURES_EXPLICIT,
ids::CHECK_DEPS_NO_MULTIPLE_VERSIONS,
ids::CHECK_DEPS_OPTIONAL_UNUSED,
ids::CHECK_DEPS_YANKED_VERSIONS,
ids::CHECK_TOOL_RUNTIME,
]
}
pub fn all_codes() -> &'static [&'static str] {
&[
ids::CODE_WILDCARD_VERSION,
ids::CODE_PATH_WITHOUT_VERSION,
ids::CODE_ABSOLUTE_PATH,
ids::CODE_PARENT_ESCAPE,
ids::CODE_MISSING_WORKSPACE_TRUE,
ids::CODE_GIT_WITHOUT_VERSION,
ids::CODE_DEV_DEP_IN_NORMAL,
ids::CODE_DEFAULT_FEATURES_IMPLICIT,
ids::CODE_DUPLICATE_DIFFERENT_VERSIONS,
ids::CODE_OPTIONAL_NOT_IN_FEATURES,
ids::CODE_VERSION_YANKED,
ids::CODE_RUNTIME_ERROR,
]
}
fn explain_no_wildcards() -> Explanation {
Explanation {
title: "No Wildcard Versions",
description: "\
Detects dependencies declared with wildcard version requirements like `*` or `1.*`.
Wildcard versions are problematic because:
- They allow any version to be selected, including breaking changes
- Builds are not reproducible across different points in time
- Security vulnerabilities in newer versions may be pulled in unknowingly
- cargo publish rejects crates with wildcard dependencies",
remediation: "\
Replace wildcard versions with explicit semver requirements:
- Use `^1.2.3` (caret, default) for compatible updates within the same major version
- Use `~1.2.3` (tilde) for patch-level updates only
- Use `=1.2.3` for an exact version pin
- Use `>=1.2.0, <2.0.0` for explicit version ranges",
examples: ExamplePair {
before: r#"[dependencies]
serde = "*"
tokio = "1.*""#,
after: r#"[dependencies]
serde = "1.0"
tokio = "1.35""#,
},
}
}
fn explain_path_requires_version() -> Explanation {
Explanation {
title: "Path Dependencies Require Version",
description: "\
Detects path dependencies in publishable crates that lack an explicit version.
When publishing a crate to crates.io, Cargo ignores the `path` key and uses only
the version from the registry. If no version is specified:
- The crate cannot be published (cargo publish will fail)
- Users who depend on your crate won't be able to build it
This check only applies to crates that can be published (publish != false).",
remediation: "\
Add an explicit version alongside the path:
my-crate = { path = \"../my-crate\", version = \"0.1.0\" }
Alternatively, use workspace inheritance:
my-crate.workspace = true
Or mark the crate as unpublishable in its Cargo.toml:
[package]
publish = false",
examples: ExamplePair {
before: r#"[dependencies]
my-lib = { path = "../my-lib" }"#,
after: r#"[dependencies]
my-lib = { path = "../my-lib", version = "0.1.0" }
# Or use workspace inheritance:
my-lib.workspace = true"#,
},
}
}
fn explain_path_safety() -> Explanation {
Explanation {
title: "Path Dependency Safety",
description: "\
Detects path dependencies that use absolute paths or escape the repository root.
This check flags two issues:
1. Absolute paths (e.g., `/home/user/code/lib` or `C:\\Code\\lib`)
2. Parent references (`..`) that escape outside the repository root
Both patterns cause problems:
- Absolute paths are machine-specific and not portable
- Escaping the repo root means the dependency is not version-controlled with the project
- CI/CD builds will fail when paths don't exist on the build machine
- Other contributors cannot build the project without identical directory layouts",
remediation: "\
Use repo-relative paths that stay within the repository:
my-crate = { path = \"../sibling-crate\" } # OK if still in repo
my-crate = { path = \"crates/my-crate\" } # Always OK
If you need an external dependency:
- Publish it to crates.io or a private registry
- Use a git dependency with a URL
- Move the dependency into the workspace",
examples: ExamplePair {
before: r#"[dependencies]
# Absolute path - not portable
my-lib = { path = "/home/user/code/my-lib" }
# Escapes repo root
other-lib = { path = "../../../outside-repo/lib" }"#,
after: r#"[dependencies]
# Repo-relative path
my-lib = { path = "../my-lib" }
# Or use a git/registry dependency for external code
other-lib = { git = "https://github.com/org/other-lib" }"#,
},
}
}
fn explain_workspace_inheritance() -> Explanation {
Explanation {
title: "Workspace Dependency Inheritance",
description: "\
Detects dependencies that exist in [workspace.dependencies] but are not using
`workspace = true` inheritance.
When a workspace defines shared dependencies in [workspace.dependencies], member
crates should inherit them to ensure:
- Consistent versions across all workspace crates
- Single source of truth for dependency versions
- Easier bulk updates when upgrading dependencies
- Reduced duplication in Cargo.toml files",
remediation: "\
Change the dependency declaration to use workspace inheritance:
# In member crate's Cargo.toml
[dependencies]
serde.workspace = true
You can still add local features while inheriting the version:
serde = { workspace = true, features = [\"derive\"] }
If you intentionally need a different version, add the dependency to the
check's allow list in depguard.toml.",
examples: ExamplePair {
before: r#"# In Cargo.toml (workspace root)
[workspace.dependencies]
serde = "1.0"
# In crates/my-crate/Cargo.toml
[dependencies]
serde = "1.0" # Duplicates workspace definition"#,
after: r#"# In Cargo.toml (workspace root)
[workspace.dependencies]
serde = "1.0"
# In crates/my-crate/Cargo.toml
[dependencies]
serde.workspace = true
# Or with additional features:
serde = { workspace = true, features = ["derive"] }"#,
},
}
}
fn explain_tool_runtime() -> Explanation {
Explanation {
title: "Tool Runtime Error",
description: "\
Depguard encountered an internal error or invalid environment while running.
This indicates the tool could not complete analysis due to a runtime failure
such as an invalid config file, missing repository root, or a git error.",
remediation: "\
Fix the underlying error and re-run depguard:
- Check the error message in stderr
- Fix invalid depguard.toml syntax or values
- Ensure the repo root exists and is accessible
- Provide required git history for diff scope",
examples: ExamplePair {
before: r#"# Fails with a tool runtime error
depguard check --repo-root /missing/path"#,
after: r#"# Succeeds after fixing the input
depguard check --repo-root ."#,
},
}
}
fn explain_wildcard_version() -> Explanation {
let mut exp = explain_no_wildcards();
exp.title = "Wildcard Version";
exp
}
fn explain_path_without_version() -> Explanation {
let mut exp = explain_path_requires_version();
exp.title = "Path Without Version";
exp
}
fn explain_absolute_path() -> Explanation {
Explanation {
title: "Absolute Path Dependency",
description: "\
A dependency is declared with an absolute filesystem path.
Absolute paths like `/home/user/code/lib` or `C:\\Code\\lib` are:
- Machine-specific and not portable across systems
- Not reproducible in CI/CD environments
- Not shareable with other contributors
- A potential security concern (may leak host directory structure)",
remediation: "\
Convert to a repo-relative path:
my-crate = { path = \"../my-crate\" }
Or use a published/git dependency:
my-crate = \"1.0\"
my-crate = { git = \"https://github.com/org/my-crate\" }",
examples: ExamplePair {
before: r#"[dependencies]
my-lib = { path = "/home/user/projects/my-lib" }
win-lib = { path = "C:\\Code\\win-lib" }"#,
after: r#"[dependencies]
my-lib = { path = "../my-lib" }
win-lib = { path = "../win-lib" }"#,
},
}
}
fn explain_parent_escape() -> Explanation {
Explanation {
title: "Path Escapes Repository Root",
description: "\
A path dependency uses `..` segments that navigate outside the repository root.
This typically happens when:
- A dependency lives in a sibling directory outside the repo
- The path was copied from another project with different structure
- A monorepo was split but paths weren't updated
Dependencies outside the repository:
- Are not version-controlled with the project
- Won't exist on CI/CD machines
- Cannot be cloned by other contributors
- Break the principle of self-contained repositories",
remediation: "\
Move the dependency into the workspace, or use an external reference:
1. Move into workspace:
mv ../external-lib crates/external-lib
# Update path to: { path = \"crates/external-lib\" }
2. Use git dependency:
external-lib = { git = \"https://github.com/org/external-lib\" }
3. Publish to a registry:
external-lib = \"1.0\"",
examples: ExamplePair {
before: r#"# From crates/my-app/Cargo.toml
[dependencies]
# Escapes repo: crates/my-app -> crates -> repo-root -> ??? (outside!)
shared = { path = "../../../shared-libs/common" }"#,
after: r#"# Move shared into the workspace, then:
[dependencies]
shared = { path = "../shared" }
# Or use a git/registry dependency:
shared = { git = "https://github.com/org/shared-libs", subdirectory = "common" }"#,
},
}
}
fn explain_missing_workspace_true() -> Explanation {
let mut exp = explain_workspace_inheritance();
exp.title = "Missing workspace = true";
exp
}
fn explain_runtime_error() -> Explanation {
let mut exp = explain_tool_runtime();
exp.title = "Runtime Error";
exp
}
fn explain_git_requires_version() -> Explanation {
Explanation {
title: "Git Dependencies Require Version",
description: "\
Detects git dependencies in publishable crates that lack an explicit version.
When publishing a crate to crates.io, Cargo ignores the `git` key and uses only
the version from the registry. If no version is specified:
- The crate cannot be published (cargo publish will fail)
- Users who depend on your crate won't be able to build it
This check only applies to crates that can be published (publish != false).",
remediation: "\
Add an explicit version alongside the git URL:
my-crate = { git = \"https://github.com/org/repo\", version = \"0.1.0\" }
Alternatively, use workspace inheritance:
my-crate.workspace = true
Or mark the crate as unpublishable in its Cargo.toml:
[package]
publish = false",
examples: ExamplePair {
before: r#"[dependencies]
my-lib = { git = "https://github.com/org/my-lib" }"#,
after: r#"[dependencies]
my-lib = { git = "https://github.com/org/my-lib", version = "0.1.0" }
# Or use workspace inheritance:
my-lib.workspace = true"#,
},
}
}
fn explain_git_without_version() -> Explanation {
let mut exp = explain_git_requires_version();
exp.title = "Git Without Version";
exp
}
fn explain_dev_only_in_normal() -> Explanation {
Explanation {
title: "Dev-Only Crate in Normal Dependencies",
description: "\
Detects crates that are typically dev-only appearing in [dependencies].
Some crates are designed exclusively for testing and development:
- Test frameworks: proptest, quickcheck, rstest, test-case
- Mocking: mockall, mockito, wiremock
- Benchmarking: criterion, divan
- Test utilities: tempfile, assert_cmd, insta
Including these in [dependencies] instead of [dev-dependencies]:
- Increases binary size for consumers
- May add unnecessary compile time
- Suggests a potential configuration error",
remediation: "\
Move the dependency to [dev-dependencies]:
[dev-dependencies]
mockall = \"0.11\"
proptest = \"1.0\"
If you genuinely need it in [dependencies] for production code,
add it to the check's allow list in depguard.toml.",
examples: ExamplePair {
before: r#"[dependencies]
mockall = "0.11"
proptest = "1.0""#,
after: r#"[dependencies]
# Production dependencies only
[dev-dependencies]
mockall = "0.11"
proptest = "1.0""#,
},
}
}
fn explain_dev_dep_in_normal() -> Explanation {
let mut exp = explain_dev_only_in_normal();
exp.title = "Dev Dependency in Normal";
exp
}
fn explain_default_features_explicit() -> Explanation {
Explanation {
title: "Explicit default-features",
description: "\
Detects dependencies with inline options that don't explicitly set default-features.
When a dependency has inline options (features, optional, path, git) but doesn't
explicitly declare `default-features = true/false`, it can lead to:
- Unclear intent about whether default features are wanted
- Accidental inclusion of unwanted features
- Inconsistent behavior when features change upstream",
remediation: "\
Add an explicit `default-features` declaration:
# If you want default features:
serde = { version = \"1.0\", features = [\"derive\"], default-features = true }
# If you don't want default features:
tokio = { version = \"1.0\", features = [\"rt\"], default-features = false }
For simple version-only dependencies, this check doesn't apply.",
examples: ExamplePair {
before: r#"[dependencies]
serde = { version = "1.0", features = ["derive"] }"#,
after: r#"[dependencies]
serde = { version = "1.0", features = ["derive"], default-features = true }"#,
},
}
}
fn explain_default_features_implicit() -> Explanation {
let mut exp = explain_default_features_explicit();
exp.title = "Default Features Implicit";
exp
}
fn explain_no_multiple_versions() -> Explanation {
Explanation {
title: "No Multiple Versions",
description: "\
Detects the same crate with different versions across workspace members.
Having multiple versions of the same dependency in a workspace:
- Increases binary size (both versions are compiled)
- Can cause subtle compatibility issues
- Makes dependency updates more complex
- May indicate accidental version drift",
remediation: "\
Align all workspace members to use the same version:
1. Define the dependency in [workspace.dependencies]:
[workspace.dependencies]
serde = \"1.0.200\"
2. Use workspace inheritance in all members:
[dependencies]
serde.workspace = true
If intentional version differences are required, add the crate
to the check's allow list.",
examples: ExamplePair {
before: r#"# crates/a/Cargo.toml
[dependencies]
serde = "1.0.195"
# crates/b/Cargo.toml
[dependencies]
serde = "1.0.200""#,
after: r#"# Cargo.toml (workspace root)
[workspace.dependencies]
serde = "1.0.200"
# crates/a/Cargo.toml
[dependencies]
serde.workspace = true
# crates/b/Cargo.toml
[dependencies]
serde.workspace = true"#,
},
}
}
fn explain_duplicate_different_versions() -> Explanation {
let mut exp = explain_no_multiple_versions();
exp.title = "Duplicate Different Versions";
exp
}
fn explain_optional_unused() -> Explanation {
Explanation {
title: "Unused Optional Dependency",
description: "\
Detects optional dependencies that aren't referenced in any feature.
When a dependency is marked `optional = true`, it should be activated by
at least one feature in the [features] table. An optional dependency that
isn't referenced in any feature:
- Cannot be enabled by users
- Suggests incomplete feature configuration
- May indicate dead code or misconfiguration",
remediation: "\
Either reference the optional dependency in a feature:
[features]
my-feature = [\"dep:optional-crate\"]
Or remove the `optional = true` if it should always be included:
[dependencies]
my-crate = \"1.0\" # Remove optional = true",
examples: ExamplePair {
before: r#"[dependencies]
serde = { version = "1.0", optional = true }
[features]
# No feature uses serde"#,
after: r#"[dependencies]
serde = { version = "1.0", optional = true }
[features]
serialization = ["dep:serde"]"#,
},
}
}
fn explain_optional_not_in_features() -> Explanation {
let mut exp = explain_optional_unused();
exp.title = "Optional Not in Features";
exp
}
fn explain_yanked_versions() -> Explanation {
Explanation {
title: "No Yanked Versions",
description: "\
Detects dependencies pinned to versions listed as yanked in an offline index.
Yanked versions are removed from normal resolution because they often indicate:
- serious bugs discovered after publish
- accidental bad releases
- security or reliability concerns
This check only flags exact pins (`=x.y.z`) when the version appears in the supplied yanked index.",
remediation: "\
Upgrade to a non-yanked version and keep the dependency explicitly pinned:
serde = \"=1.0.200\"
If the yanked version is intentional for a temporary reason, document it and add
the dependency to the check allowlist.",
examples: ExamplePair {
before: r#"[dependencies]
serde = "=1.0.189""#,
after: r#"[dependencies]
serde = "=1.0.200""#,
},
}
}
fn explain_version_yanked() -> Explanation {
let mut exp = explain_yanked_versions();
exp.title = "Pinned Version Is Yanked";
exp
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lookup_by_check_id() {
assert!(lookup_explanation(ids::CHECK_DEPS_NO_WILDCARDS).is_some());
assert!(lookup_explanation(ids::CHECK_DEPS_PATH_REQUIRES_VERSION).is_some());
assert!(lookup_explanation(ids::CHECK_DEPS_PATH_SAFETY).is_some());
assert!(lookup_explanation(ids::CHECK_DEPS_WORKSPACE_INHERITANCE).is_some());
assert!(lookup_explanation(ids::CHECK_TOOL_RUNTIME).is_some());
}
#[test]
fn lookup_by_code() {
assert!(lookup_explanation(ids::CODE_WILDCARD_VERSION).is_some());
assert!(lookup_explanation(ids::CODE_PATH_WITHOUT_VERSION).is_some());
assert!(lookup_explanation(ids::CODE_ABSOLUTE_PATH).is_some());
assert!(lookup_explanation(ids::CODE_PARENT_ESCAPE).is_some());
assert!(lookup_explanation(ids::CODE_MISSING_WORKSPACE_TRUE).is_some());
assert!(lookup_explanation(ids::CODE_RUNTIME_ERROR).is_some());
}
#[test]
fn lookup_unknown_returns_none() {
assert!(lookup_explanation("unknown.check").is_none());
assert!(lookup_explanation("unknown_code").is_none());
}
#[test]
fn all_check_ids_are_valid() {
for id in all_check_ids() {
assert!(
lookup_explanation(id).is_some(),
"check_id {} should be in registry",
id
);
}
}
#[test]
fn all_codes_are_valid() {
for code in all_codes() {
assert!(
lookup_explanation(code).is_some(),
"code {} should be in registry",
code
);
}
}
}