use super::*;
macro_rules! apply_bool_flag {
($cli:expr, $src:expr, $field:ident) => {
if !$cli.$field {
if let Some(v) = $src.$field {
$cli.$field = v;
}
}
};
}
macro_rules! apply_opt_field {
($cli:expr, $src:expr, $field:ident) => {
if $cli.$field.is_none() {
$cli.$field = $src.$field;
}
};
}
macro_rules! apply_concrete_field {
($cli:expr, $src:expr, $field:ident, $default:expr) => {
if $cli.$field == $default {
if let Some(v) = $src.$field {
$cli.$field = v;
}
}
};
}
fn merge_list(target: &mut Vec<String>, source: Vec<String>) {
for v in source {
if !target.contains(&v) {
target.push(v);
}
}
}
fn apply_settings_layer(cli: &mut Cli, s: SanitizeConfig) {
merge_list(&mut cli.app, s.app);
merge_list(&mut cli.allow, s.allow);
merge_list(&mut cli.exclude_path, s.exclude_path);
merge_list(&mut cli.include_path, s.include_path);
merge_list(&mut cli.context_keywords, s.context_keywords);
apply_bool_flag!(cli, s, fail_on_match);
apply_bool_flag!(cli, s, strict);
apply_bool_flag!(cli, s, no_structured_handoff);
apply_bool_flag!(cli, s, no_field_signal);
apply_bool_flag!(cli, s, force_text);
apply_bool_flag!(cli, s, include_binary);
apply_bool_flag!(cli, s, hidden);
apply_bool_flag!(cli, s, context_keywords_replace);
apply_bool_flag!(cli, s, context_case_sensitive);
apply_bool_flag!(cli, s, extract_context);
apply_bool_flag!(cli, s, no_progress);
apply_bool_flag!(cli, s, quiet);
apply_opt_field!(cli, s, threads);
apply_opt_field!(cli, s, entropy_threshold);
apply_opt_field!(cli, s, log_format);
apply_opt_field!(cli, s, log_level);
apply_concrete_field!(cli, s, chunk_size, 1_048_576_usize);
apply_concrete_field!(cli, s, max_mappings, 10_000_000_usize);
apply_concrete_field!(
cli,
s,
max_structured_size,
DEFAULT_MAX_STRUCTURED_FILE_SIZE
);
apply_concrete_field!(cli, s, max_archive_depth, DEFAULT_ARCHIVE_DEPTH);
apply_concrete_field!(cli, s, context_lines, DEFAULT_CONTEXT_LINES);
apply_concrete_field!(cli, s, max_context_matches, DEFAULT_MAX_MATCHES);
apply_concrete_field!(cli, s, max_match_locations, 500_usize);
apply_concrete_field!(cli, s, progress_interval_ms, DEFAULT_PROGRESS_INTERVAL_MS);
}
fn apply_project_config_layer(cli: &mut Cli, pc: SanitizeConfig, config_dir: &std::path::Path) {
merge_list(&mut cli.app, pc.app);
merge_list(&mut cli.allow, pc.allow);
merge_list(&mut cli.exclude_path, pc.exclude_path);
merge_list(&mut cli.include_path, pc.include_path);
merge_list(&mut cli.context_keywords, pc.context_keywords);
if cli.secrets_file.is_none() {
if let Some(rel) = pc.secrets_file {
cli.secrets_file = Some(config_dir.join(rel));
}
}
apply_bool_flag!(cli, pc, encrypted_secrets);
if cli.profile.is_none() {
if let Some(rel) = pc.profile {
cli.profile = Some(config_dir.join(rel));
}
}
apply_bool_flag!(cli, pc, fail_on_match);
apply_bool_flag!(cli, pc, strict);
apply_bool_flag!(cli, pc, no_structured_handoff);
apply_bool_flag!(cli, pc, no_field_signal);
apply_bool_flag!(cli, pc, force_text);
apply_bool_flag!(cli, pc, include_binary);
apply_bool_flag!(cli, pc, hidden);
apply_bool_flag!(cli, pc, context_keywords_replace);
apply_bool_flag!(cli, pc, context_case_sensitive);
apply_bool_flag!(cli, pc, extract_context);
apply_bool_flag!(cli, pc, no_progress);
apply_bool_flag!(cli, pc, quiet);
apply_opt_field!(cli, pc, threads);
apply_opt_field!(cli, pc, entropy_threshold);
apply_opt_field!(cli, pc, log_format);
apply_opt_field!(cli, pc, log_level);
apply_concrete_field!(cli, pc, chunk_size, 1_048_576_usize);
apply_concrete_field!(cli, pc, max_mappings, 10_000_000_usize);
apply_concrete_field!(
cli,
pc,
max_structured_size,
DEFAULT_MAX_STRUCTURED_FILE_SIZE
);
apply_concrete_field!(cli, pc, max_archive_depth, DEFAULT_ARCHIVE_DEPTH);
apply_concrete_field!(cli, pc, context_lines, DEFAULT_CONTEXT_LINES);
apply_concrete_field!(cli, pc, max_context_matches, DEFAULT_MAX_MATCHES);
apply_concrete_field!(cli, pc, max_match_locations, 500_usize);
apply_concrete_field!(cli, pc, progress_interval_ms, DEFAULT_PROGRESS_INTERVAL_MS);
}
pub(super) fn merge_settings(mut cli: Cli) -> Cli {
apply_settings_layer(&mut cli, load_settings());
if let Some(project_config_path) = find_project_config() {
let (pc, config_dir) = load_project_config(&project_config_path);
apply_project_config_layer(&mut cli, pc, &config_dir);
}
cli
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use std::path::Path;
fn default_cli() -> Cli {
Cli::try_parse_from(["scour-secrets", "file.txt"]).unwrap()
}
#[test]
fn settings_layer_applies_when_cli_is_default() {
let mut cli = default_cli();
let s = SanitizeConfig {
threads: Some(8),
fail_on_match: Some(true),
log_level: Some("debug".into()),
..Default::default()
};
apply_settings_layer(&mut cli, s);
assert_eq!(cli.threads, Some(8));
assert!(cli.fail_on_match);
assert_eq!(cli.log_level.as_deref(), Some("debug"));
}
#[test]
fn settings_layer_does_not_override_cli_flags() {
let mut cli =
Cli::try_parse_from(["scour-secrets", "file.txt", "--fail-on-match"]).unwrap();
assert!(cli.fail_on_match);
apply_settings_layer(
&mut cli,
SanitizeConfig {
fail_on_match: Some(false),
..Default::default()
},
);
assert!(cli.fail_on_match);
}
#[test]
fn settings_layer_does_not_override_explicit_threads() {
let mut cli = Cli::try_parse_from(["scour-secrets", "file.txt", "--threads", "2"]).unwrap();
apply_settings_layer(
&mut cli,
SanitizeConfig {
threads: Some(16),
..Default::default()
},
);
assert_eq!(cli.threads, Some(2));
}
#[test]
fn settings_layer_fills_app_when_cli_empty() {
let mut cli = default_cli();
apply_settings_layer(
&mut cli,
SanitizeConfig {
app: vec!["gitlab".into()],
..Default::default()
},
);
assert!(cli.app.contains(&"gitlab".to_string()));
}
#[test]
fn settings_layer_merges_app_additively_with_cli() {
let mut cli =
Cli::try_parse_from(["scour-secrets", "file.txt", "--app", "kubernetes"]).unwrap();
apply_settings_layer(
&mut cli,
SanitizeConfig {
app: vec!["gitlab".into()],
..Default::default()
},
);
assert!(cli.app.contains(&"kubernetes".to_string()));
assert!(cli.app.contains(&"gitlab".to_string()));
}
#[test]
fn settings_layer_applies_new_bool_flags() {
let mut cli = default_cli();
apply_settings_layer(
&mut cli,
SanitizeConfig {
force_text: Some(true),
include_binary: Some(true),
hidden: Some(true),
quiet: Some(true),
..Default::default()
},
);
assert!(cli.force_text);
assert!(cli.include_binary);
assert!(cli.hidden);
assert!(cli.quiet);
}
#[test]
fn settings_layer_applies_concrete_fields() {
let mut cli = default_cli();
apply_settings_layer(
&mut cli,
SanitizeConfig {
chunk_size: Some(2_097_152),
max_archive_depth: Some(3),
context_lines: Some(5),
..Default::default()
},
);
assert_eq!(cli.chunk_size, 2_097_152);
assert_eq!(cli.max_archive_depth, 3);
assert_eq!(cli.context_lines, 5);
}
#[test]
fn settings_layer_does_not_override_explicit_concrete_field() {
let mut cli =
Cli::try_parse_from(["scour-secrets", "file.txt", "--max-archive-depth", "2"]).unwrap();
apply_settings_layer(
&mut cli,
SanitizeConfig {
max_archive_depth: Some(10),
..Default::default()
},
);
assert_eq!(cli.max_archive_depth, 2);
}
#[test]
fn project_layer_adds_app_bundles_additively() {
let mut cli =
Cli::try_parse_from(["scour-secrets", "file.txt", "--app", "kubernetes"]).unwrap();
let pc = SanitizeConfig {
app: vec!["gitlab".into()],
..Default::default()
};
apply_project_config_layer(&mut cli, pc, Path::new("."));
assert!(cli.app.contains(&"kubernetes".to_string()));
assert!(cli.app.contains(&"gitlab".to_string()));
}
#[test]
fn project_layer_deduplicates_app_bundles() {
let mut cli =
Cli::try_parse_from(["scour-secrets", "file.txt", "--app", "gitlab"]).unwrap();
let pc = SanitizeConfig {
app: vec!["gitlab".into()],
..Default::default()
};
apply_project_config_layer(&mut cli, pc, Path::new("."));
assert_eq!(cli.app.iter().filter(|a| *a == "gitlab").count(), 1);
}
#[test]
fn project_layer_resolves_secrets_file_relative_to_config_dir() {
let mut cli = default_cli();
let pc = SanitizeConfig {
secrets_file: Some(PathBuf::from("secrets.yaml")),
..Default::default()
};
apply_project_config_layer(&mut cli, pc, Path::new("/repo"));
assert_eq!(cli.secrets_file, Some(PathBuf::from("/repo/secrets.yaml")));
}
#[test]
fn project_layer_does_not_override_cli_secrets_file() {
let mut cli =
Cli::try_parse_from(["scour-secrets", "file.txt", "-s", "/explicit/secrets.yaml"])
.unwrap();
let pc = SanitizeConfig {
secrets_file: Some(PathBuf::from("project_secrets.yaml")),
..Default::default()
};
apply_project_config_layer(&mut cli, pc, Path::new("/repo"));
assert_eq!(
cli.secrets_file,
Some(PathBuf::from("/explicit/secrets.yaml"))
);
}
#[test]
fn project_layer_resolves_profile_relative_to_config_dir() {
let mut cli = default_cli();
let pc = SanitizeConfig {
profile: Some(PathBuf::from("my.profile.yaml")),
..Default::default()
};
apply_project_config_layer(&mut cli, pc, Path::new("/repo"));
assert_eq!(cli.profile, Some(PathBuf::from("/repo/my.profile.yaml")));
}
#[test]
fn project_layer_adds_allow_additively() {
let mut cli =
Cli::try_parse_from(["scour-secrets", "file.txt", "--allow", "localhost"]).unwrap();
let pc = SanitizeConfig {
allow: vec!["*.internal".into()],
..Default::default()
};
apply_project_config_layer(&mut cli, pc, Path::new("."));
assert!(cli.allow.contains(&"localhost".to_string()));
assert!(cli.allow.contains(&"*.internal".to_string()));
}
#[test]
fn project_layer_adds_exclude_path_additively() {
let mut cli =
Cli::try_parse_from(["scour", "file.txt", "--exclude-path", "tests/fixtures/**"])
.unwrap();
let pc = SanitizeConfig {
exclude_path: vec!["*.generated.yaml".into()],
..Default::default()
};
apply_project_config_layer(&mut cli, pc, Path::new("."));
assert!(cli.exclude_path.contains(&"tests/fixtures/**".to_string()));
assert!(cli.exclude_path.contains(&"*.generated.yaml".to_string()));
}
}