use crate::{FailOn, ImpactArgs};
use serde::Deserialize;
use std::path::Path;
const CONFIG_FILENAME: &str = "cargo-impact.toml";
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ConfigFile {
#[serde(default)]
pub defaults: Defaults,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Defaults {
pub confidence_min: Option<f64>,
pub fail_on: Option<String>,
pub semver_checks: Option<bool>,
pub rust_analyzer: Option<bool>,
pub features: Option<Vec<String>>,
pub all_features: Option<bool>,
pub no_default_features: Option<bool>,
pub budget: Option<usize>,
}
impl ConfigFile {
pub fn load(root: &Path) -> Self {
let path = root.join(CONFIG_FILENAME);
let Ok(src) = std::fs::read_to_string(&path) else {
return Self::default();
};
match Self::parse(&src) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!(
"cargo-impact: {} is present but could not be parsed: {e}. \
Continuing with CLI-only defaults.",
path.display()
);
Self::default()
}
}
}
pub fn parse(src: &str) -> Result<Self, toml::de::Error> {
toml::from_str(src)
}
}
pub fn apply_config(defaults: &Defaults, args: &mut ImpactArgs) {
if let Some(v) = defaults.confidence_min
&& args.confidence_min == 0.0
{
args.confidence_min = v;
}
if args.fail_on.is_none()
&& let Some(s) = &defaults.fail_on
{
match s.to_lowercase().as_str() {
"high" => args.fail_on = Some(FailOn::High),
"medium" => args.fail_on = Some(FailOn::Medium),
"low" => args.fail_on = Some(FailOn::Low),
other => eprintln!(
"cargo-impact: cargo-impact.toml: invalid `fail_on = \"{other}\"` \
(expected `high`, `medium`, or `low`); leaving unset."
),
}
}
if let Some(v) = defaults.semver_checks
&& !args.semver_checks
{
args.semver_checks = v;
}
if let Some(v) = defaults.rust_analyzer
&& !args.rust_analyzer
{
args.rust_analyzer = v;
}
if let Some(v) = &defaults.features
&& args.features.is_empty()
{
args.features.clone_from(v);
}
if let Some(v) = defaults.all_features
&& !args.all_features
{
args.all_features = v;
}
if let Some(v) = defaults.no_default_features
&& !args.no_default_features
{
args.no_default_features = v;
}
if let Some(v) = defaults.budget
&& args.budget == 0
{
args.budget = v;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh_args() -> ImpactArgs {
ImpactArgs {
test: false,
format: crate::Format::Text,
since: "HEAD".into(),
manifest_dir: None,
confidence_min: 0.0,
fail_on: None,
semver_checks: false,
rust_analyzer: false,
features: Vec::new(),
all_features: false,
no_default_features: false,
budget: 0,
context: false,
feature_powerset: false,
macro_expand: false,
cache: false,
}
}
#[test]
fn parse_empty_config_yields_empty_defaults() {
let cfg = ConfigFile::parse("").unwrap();
assert!(cfg.defaults.confidence_min.is_none());
assert!(cfg.defaults.fail_on.is_none());
}
#[test]
fn parse_full_defaults_block() {
let src = r#"
[defaults]
confidence_min = 0.6
fail_on = "high"
semver_checks = true
rust_analyzer = false
features = ["tokio", "rt"]
budget = 32000
"#;
let cfg = ConfigFile::parse(src).unwrap();
assert_eq!(cfg.defaults.confidence_min, Some(0.6));
assert_eq!(cfg.defaults.fail_on.as_deref(), Some("high"));
assert_eq!(cfg.defaults.semver_checks, Some(true));
assert_eq!(
cfg.defaults.features.as_deref(),
Some(&["tokio".to_string(), "rt".to_string()][..])
);
assert_eq!(cfg.defaults.budget, Some(32000));
}
#[test]
fn apply_sets_flags_when_cli_left_them_default() {
let mut args = fresh_args();
let defaults = Defaults {
confidence_min: Some(0.6),
fail_on: Some("high".into()),
semver_checks: Some(true),
budget: Some(8000),
..Defaults::default()
};
apply_config(&defaults, &mut args);
assert_eq!(args.confidence_min, 0.6);
assert!(matches!(args.fail_on, Some(FailOn::High)));
assert!(args.semver_checks);
assert_eq!(args.budget, 8000);
}
#[test]
fn apply_does_not_override_explicit_cli_values() {
let mut args = fresh_args();
args.confidence_min = 0.9;
args.fail_on = Some(FailOn::Low);
args.budget = 1000;
let defaults = Defaults {
confidence_min: Some(0.6),
fail_on: Some("high".into()),
budget: Some(8000),
..Defaults::default()
};
apply_config(&defaults, &mut args);
assert_eq!(args.confidence_min, 0.9);
assert!(matches!(args.fail_on, Some(FailOn::Low)));
assert_eq!(args.budget, 1000);
}
#[test]
fn invalid_fail_on_string_leaves_args_untouched_and_logs() {
let mut args = fresh_args();
let defaults = Defaults {
fail_on: Some("catastrophic".into()),
..Defaults::default()
};
apply_config(&defaults, &mut args);
assert!(args.fail_on.is_none());
}
#[test]
fn load_missing_file_returns_empty_config() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = ConfigFile::load(dir.path());
assert!(cfg.defaults.confidence_min.is_none());
}
#[test]
fn load_reads_file_from_root() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILENAME),
"[defaults]\nconfidence_min = 0.75\nbudget = 16000\n",
)
.unwrap();
let cfg = ConfigFile::load(dir.path());
assert_eq!(cfg.defaults.confidence_min, Some(0.75));
assert_eq!(cfg.defaults.budget, Some(16000));
}
#[test]
fn load_malformed_file_logs_and_returns_empty() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILENAME),
"this is not valid toml !!! [[[",
)
.unwrap();
let cfg = ConfigFile::load(dir.path());
assert!(cfg.defaults.confidence_min.is_none());
}
}