use deps_core::policy_config::{DiagnosticsConfig, PolicyConfig};
use serde::Deserialize;
use std::path::{Path, PathBuf};
pub const DEFAULT_CONFIG_FILENAME: &str = "deps.toml";
const MAX_CONFIG_FILE_SIZE: u64 = 1_000_000;
#[non_exhaustive]
#[derive(Debug, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct CliConfig {
#[serde(flatten)]
pub policy: PolicyConfig,
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read config file {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("config file {path} exceeds the {MAX_CONFIG_FILE_SIZE}-byte size cap")]
TooLarge {
path: PathBuf,
},
#[error("failed to parse TOML in {path}: {source}")]
Toml {
path: PathBuf,
#[source]
source: toml_span::Error,
},
#[error("invalid configuration in {path}: {source}")]
Deserialize {
path: PathBuf,
#[source]
source: serde_json::Error,
},
}
pub fn load(explicit_path: Option<&Path>, default_dir: &Path) -> Result<CliConfig, ConfigError> {
let (path, required): (PathBuf, bool) = match explicit_path {
Some(path) => (path.to_path_buf(), true),
None => (default_dir.join(DEFAULT_CONFIG_FILENAME), false),
};
let content = match deps_core::fs_probe::read_to_string_capped(&path, MAX_CONFIG_FILE_SIZE) {
Ok(Some(content)) => content,
Ok(None) => return Err(ConfigError::TooLarge { path }),
Err(source) if !required && source.kind() == std::io::ErrorKind::NotFound => {
return Ok(CliConfig::default());
}
Err(source) => return Err(ConfigError::Io { path, source }),
};
let mut config = parse(&content, &path)?;
if !required {
for section in ignored_sections(&config.policy) {
eprintln!(
"deps-cli: warning: {path}'s [{section}] section was auto-discovered, not given via --config, and is ignored — see `deps_cli::config::safe_auto_discovered_policy`'s doc for why",
path = path.display(),
);
}
config.policy = safe_auto_discovered_policy(config.policy);
}
Ok(config)
}
#[must_use]
pub fn safe_auto_discovered_policy(parsed: PolicyConfig) -> PolicyConfig {
PolicyConfig {
diagnostics: DiagnosticsConfig::new()
.with_outdated_severity(parsed.diagnostics.outdated_severity)
.with_unknown_severity(parsed.diagnostics.unknown_severity)
.with_yanked_severity(parsed.diagnostics.yanked_severity)
.with_unsatisfiable_severity(parsed.diagnostics.unsatisfiable_severity)
.with_deprecated_severity(parsed.diagnostics.deprecated_severity)
.with_mutable_ref_pin_severity(parsed.diagnostics.mutable_ref_pin_severity),
..PolicyConfig::default()
}
}
fn ignored_sections(policy: &PolicyConfig) -> Vec<&'static str> {
let default = PolicyConfig::default();
let mut sections = Vec::new();
if policy.diagnostics.mutable_ref_pin_enabled != default.diagnostics.mutable_ref_pin_enabled
|| policy.diagnostics.vulnerabilities_enabled != default.diagnostics.vulnerabilities_enabled
{
sections.push("diagnostics");
}
if policy.cache.enabled != default.cache.enabled
|| policy.cache.fetch_timeout_secs != default.cache.fetch_timeout_secs
|| policy.cache.max_concurrent_fetches != default.cache.max_concurrent_fetches
{
sections.push("cache");
}
if policy.freshness.enabled != default.freshness.enabled
|| policy.freshness.cooldown_secs != default.freshness.cooldown_secs
{
sections.push("freshness");
}
if policy.supply_chain.enabled != default.supply_chain.enabled {
sections.push("supply_chain");
}
if policy.registries.workspace_registries != default.registries.workspace_registries
|| policy.registries.nuget_user_profile_sources
!= default.registries.nuget_user_profile_sources
|| policy.registries.gitlab_instance_host != default.registries.gitlab_instance_host
{
sections.push("registries");
}
if policy.network.offline != default.network.offline {
sections.push("network");
}
if policy.license_policy.allow != default.license_policy.allow
|| policy.license_policy.deny != default.license_policy.deny
{
sections.push("license_policy");
}
sections
}
fn parse(content: &str, path: &Path) -> Result<CliConfig, ConfigError> {
let value = toml_span::parse(content).map_err(|source| ConfigError::Toml {
path: path.to_path_buf(),
source,
})?;
let json = serde_json::to_value(&value).map_err(|source| ConfigError::Deserialize {
path: path.to_path_buf(),
source,
})?;
serde_json::from_value(json).map_err(|source| ConfigError::Deserialize {
path: path.to_path_buf(),
source,
})
}
pub fn apply_overrides(mut config: CliConfig, offline: bool, cooldown: Option<u64>) -> CliConfig {
if offline {
config.policy.network.offline = true;
}
if let Some(cooldown_secs) = cooldown {
config.policy.freshness.cooldown_secs = cooldown_secs;
}
config
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
fn write_temp_toml(content: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::NamedTempFile::new().expect("create temp file");
file.write_all(content.as_bytes()).expect("write temp file");
file
}
#[test]
fn test_load_missing_default_file_returns_defaults() {
let dir = tempfile::tempdir().expect("create temp dir");
let config = load(None, dir.path()).expect("missing default file is not an error");
assert_eq!(
config.policy.network.offline,
PolicyConfig::default().network.offline
);
}
#[test]
fn test_load_missing_explicit_path_is_an_error() {
let missing = PathBuf::from("/nonexistent/path/to/deps.toml");
let result = load(Some(&missing), Path::new("."));
assert!(matches!(result, Err(ConfigError::Io { .. })));
}
#[test]
fn test_load_valid_toml_parses_policy_sections() {
let file = write_temp_toml(
r"
[network]
offline = true
[freshness]
cooldown_secs = 60
",
);
let config = load(Some(file.path()), Path::new(".")).expect("valid TOML must parse");
assert!(config.policy.network.offline);
assert_eq!(config.policy.freshness.cooldown_secs, 60);
}
#[test]
fn test_load_malformed_toml_is_an_error() {
let file = write_temp_toml("this is not [ valid toml");
let result = load(Some(file.path()), Path::new("."));
assert!(matches!(result, Err(ConfigError::Toml { .. })));
}
#[test]
fn test_load_unknown_top_level_key_is_rejected() {
let file = write_temp_toml("totally_unknown_key = true\n");
let result = load(Some(file.path()), Path::new("."));
assert!(matches!(result, Err(ConfigError::Deserialize { .. })));
}
#[test]
fn test_load_unknown_key_nested_in_known_section_is_tolerated() {
let file = write_temp_toml(
r#"
[network]
offline = true
future_field = "ignored"
"#,
);
let config = load(Some(file.path()), Path::new("."))
.expect("nested unknown key must not reject the payload");
assert!(config.policy.network.offline);
}
#[test]
fn test_load_auto_discovery_resolves_against_given_default_dir_not_cwd() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join(DEFAULT_CONFIG_FILENAME),
"[diagnostics]\noutdated_severity = 1\n",
)
.expect("write deps.toml");
let config = load(None, dir.path()).expect("deps.toml in default_dir must be found");
assert_eq!(
config.policy.diagnostics.outdated_severity,
deps_core::diagnostic::Severity::Error
);
}
#[test]
fn test_load_auto_discovered_registries_section_is_ignored() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join(DEFAULT_CONFIG_FILENAME),
r#"
[registries]
gitlab_instance_host = "attacker-host.invalid"
workspace_registries = "all"
"#,
)
.expect("write deps.toml");
let config = load(None, dir.path()).expect("auto-discovered file must still load");
assert_eq!(config.policy.registries.gitlab_instance_host, "");
assert_eq!(
config.policy.registries.workspace_registries,
deps_core::policy_config::WorkspaceRegistriesSetting::PublicOnly
);
}
#[test]
fn test_load_auto_discovered_diagnostics_enabled_flags_are_ignored() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join(DEFAULT_CONFIG_FILENAME),
r"
[diagnostics]
mutable_ref_pin_enabled = false
vulnerabilities_enabled = false
",
)
.expect("write deps.toml");
let config = load(None, dir.path()).expect("auto-discovered file must still load");
assert!(config.policy.diagnostics.mutable_ref_pin_enabled);
assert!(config.policy.diagnostics.vulnerabilities_enabled);
}
#[test]
fn test_load_auto_discovered_network_offline_is_ignored() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join(DEFAULT_CONFIG_FILENAME),
"[network]\noffline = true\n",
)
.expect("write deps.toml");
let config = load(None, dir.path()).expect("auto-discovered file must still load");
assert!(!config.policy.network.offline);
}
#[test]
fn test_load_auto_discovered_remaining_gate_relevant_sections_are_ignored() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join(DEFAULT_CONFIG_FILENAME),
r#"
[freshness]
cooldown_secs = 0
[license_policy]
allow = ["GPL-3.0"]
[cache]
fetch_timeout_secs = 1
[supply_chain]
enabled = false
"#,
)
.expect("write deps.toml");
let config = load(None, dir.path()).expect("auto-discovered file must still load");
let default = PolicyConfig::default();
assert_eq!(
config.policy.freshness.cooldown_secs,
default.freshness.cooldown_secs
);
assert_eq!(
config.policy.license_policy.allow,
default.license_policy.allow
);
assert_eq!(
config.policy.cache.fetch_timeout_secs,
default.cache.fetch_timeout_secs
);
assert_eq!(
config.policy.supply_chain.enabled,
default.supply_chain.enabled
);
}
#[test]
fn test_load_auto_discovered_severity_values_are_kept() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join(DEFAULT_CONFIG_FILENAME),
"[diagnostics]\nyanked_severity = 4\n",
)
.expect("write deps.toml");
let config = load(None, dir.path()).expect("auto-discovered file must still load");
assert_eq!(
config.policy.diagnostics.yanked_severity,
deps_core::diagnostic::Severity::Hint
);
}
#[test]
fn test_load_explicit_config_registries_section_is_trusted() {
let file = write_temp_toml(
r#"
[registries]
gitlab_instance_host = "gitlab.mycorp.dev"
"#,
);
let config = load(Some(file.path()), Path::new("."))
.expect("explicit config with a registries section must load");
assert_eq!(
config.policy.registries.gitlab_instance_host,
"gitlab.mycorp.dev"
);
}
#[test]
fn test_apply_overrides_offline_flag_forces_true() {
let config = apply_overrides(CliConfig::default(), true, None);
assert!(config.policy.network.offline);
}
#[test]
fn test_apply_overrides_offline_absent_keeps_file_value() {
let mut base = CliConfig::default();
base.policy.network.offline = true;
let config = apply_overrides(base, false, None);
assert!(
config.policy.network.offline,
"absent flag must not clear a file-set true"
);
}
#[test]
fn test_apply_overrides_cooldown_replaces_file_value() {
let mut base = CliConfig::default();
base.policy.freshness.cooldown_secs = 999;
let config = apply_overrides(base, false, Some(42));
assert_eq!(config.policy.freshness.cooldown_secs, 42);
}
#[test]
fn test_apply_overrides_no_cooldown_keeps_file_value() {
let mut base = CliConfig::default();
base.policy.freshness.cooldown_secs = 999;
let config = apply_overrides(base, false, None);
assert_eq!(config.policy.freshness.cooldown_secs, 999);
}
}