vsec 0.0.1

Detect secrets and in Rust codebases
Documentation
// src/config/loader.rs

use std::path::Path;

use crate::config::defaults::default_config_toml;
use crate::config::schema::Config;
use crate::error::{Result, SecretraceError};

/// Load configuration from a file
pub fn load_from_file(path: &Path) -> Result<Config> {
    let content = std::fs::read_to_string(path).map_err(|e| SecretraceError::FileRead {
        path: path.to_path_buf(),
        source: e,
    })?;

    toml::from_str(&content).map_err(|e| SecretraceError::Config {
        message: format!("Failed to parse config file: {}", path.display()),
        source: Some(Box::new(e)),
    })
}

/// Load configuration from default locations or return default config
///
/// Searches in order:
/// 1. `.vsec.toml` in the given directory
/// 2. `vsec.toml` in the given directory
/// 3. `.vsec.toml` in parent directories (up to root)
/// 4. Default configuration
pub fn load_or_default(start_dir: &Path) -> Result<Config> {
    // Try to find config in start_dir and parent directories
    let config_names = [".vsec.toml", "vsec.toml"];

    let mut current = start_dir.to_path_buf();
    if current.is_file() {
        current = current.parent().map(|p| p.to_path_buf()).unwrap_or(current);
    }

    loop {
        for name in &config_names {
            let config_path = current.join(name);
            if config_path.exists() {
                return load_from_file(&config_path);
            }
        }

        // Move to parent
        match current.parent() {
            Some(parent) => current = parent.to_path_buf(),
            None => break,
        }
    }

    // No config found, use defaults
    Ok(Config::default())
}

/// Generate default configuration content
pub fn generate_default_config(minimal: bool) -> String {
    default_config_toml(minimal).to_string()
}

/// Merge two configurations (overlay on base)
pub fn merge_configs(base: Config, overlay: Config) -> Config {
    // For now, overlay completely replaces base fields that are set
    // A more sophisticated merge could be implemented if needed
    Config {
        general: if overlay.general.ignore_paths.is_empty() {
            base.general
        } else {
            overlay.general
        },
        scoring: overlay.scoring,
        name_filter: NameFilterConfig {
            additional_benign_terms: merge_vec(
                base.name_filter.additional_benign_terms,
                overlay.name_filter.additional_benign_terms,
            ),
            additional_suspicious_terms: merge_vec(
                base.name_filter.additional_suspicious_terms,
                overlay.name_filter.additional_suspicious_terms,
            ),
            ignore_names: merge_vec(
                base.name_filter.ignore_names,
                overlay.name_filter.ignore_names,
            ),
            ignore_patterns: merge_vec(
                base.name_filter.ignore_patterns,
                overlay.name_filter.ignore_patterns,
            ),
        },
        scope_filter: overlay.scope_filter,
        consequence: ConsequenceConfig {
            additional_logging_functions: merge_vec(
                base.consequence.additional_logging_functions,
                overlay.consequence.additional_logging_functions,
            ),
            additional_auth_functions: merge_vec(
                base.consequence.additional_auth_functions,
                overlay.consequence.additional_auth_functions,
            ),
            additional_auth_fields: merge_vec(
                base.consequence.additional_auth_fields,
                overlay.consequence.additional_auth_fields,
            ),
        },
        rhs: RhsConfig {
            additional_command_names: merge_vec(
                base.rhs.additional_command_names,
                overlay.rhs.additional_command_names,
            ),
            additional_auth_names: merge_vec(
                base.rhs.additional_auth_names,
                overlay.rhs.additional_auth_names,
            ),
        },
        output: overlay.output,
        rules: merge_vec(base.rules, overlay.rules),
    }
}

use crate::config::schema::{ConsequenceConfig, NameFilterConfig, RhsConfig};

fn merge_vec<T>(mut base: Vec<T>, overlay: Vec<T>) -> Vec<T> {
    base.extend(overlay);
    base
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::TempDir;

    #[test]
    fn test_load_from_file() {
        let dir = TempDir::new().unwrap();
        let config_path = dir.path().join(".vsec.toml");

        let mut file = std::fs::File::create(&config_path).unwrap();
        writeln!(file, r#"
[scoring]
sensitivity = "high"
threshold_override = 60
"#).unwrap();

        let config = load_from_file(&config_path).unwrap();
        assert_eq!(
            config.scoring.sensitivity,
            crate::config::SensitivityPreset::High
        );
        assert_eq!(config.scoring.threshold_override, Some(60));
    }

    #[test]
    fn test_load_or_default_finds_config() {
        let dir = TempDir::new().unwrap();
        let config_path = dir.path().join(".vsec.toml");

        let mut file = std::fs::File::create(&config_path).unwrap();
        writeln!(file, r#"
[scoring]
sensitivity = "paranoid"
"#).unwrap();

        let config = load_or_default(dir.path()).unwrap();
        assert_eq!(
            config.scoring.sensitivity,
            crate::config::SensitivityPreset::Paranoid
        );
    }

    #[test]
    fn test_load_or_default_uses_default() {
        let dir = TempDir::new().unwrap();
        let config = load_or_default(dir.path()).unwrap();
        assert_eq!(
            config.scoring.sensitivity,
            crate::config::SensitivityPreset::Balanced
        );
    }

    #[test]
    fn test_generate_default_config() {
        let minimal = generate_default_config(true);
        assert!(minimal.contains("sensitivity"));
        assert!(!minimal.contains("additional_benign_terms"));

        let full = generate_default_config(false);
        assert!(full.contains("sensitivity"));
        assert!(full.contains("additional_benign_terms"));
    }

    #[test]
    fn test_merge_configs() {
        let base = Config {
            name_filter: NameFilterConfig {
                ignore_names: vec!["BASE_NAME".to_string()],
                ..Default::default()
            },
            ..Default::default()
        };

        let overlay = Config {
            name_filter: NameFilterConfig {
                ignore_names: vec!["OVERLAY_NAME".to_string()],
                ..Default::default()
            },
            ..Default::default()
        };

        let merged = merge_configs(base, overlay);
        assert!(merged.name_filter.ignore_names.contains(&"BASE_NAME".to_string()));
        assert!(merged.name_filter.ignore_names.contains(&"OVERLAY_NAME".to_string()));
    }
}