cc-audit 3.10.1

Security auditor for Claude Code skills, hooks, and MCP servers
Documentation
//! Configuration loading functions.

use std::fs;
use std::path::{Path, PathBuf};
use tracing::debug;

use super::error::ConfigError;
use super::types::Config;

/// Result of trying to find a configuration file.
#[derive(Debug)]
pub struct ConfigLoadResult {
    /// The loaded configuration.
    pub config: Config,
    /// The path to the configuration file, if found.
    pub path: Option<PathBuf>,
}

/// Returns `true` if a YAML document carries no content, i.e. every line is
/// blank or a full-line comment. Such documents are semantically empty and
/// should map to the default configuration.
fn is_effectively_empty(content: &str) -> bool {
    content.lines().all(|line| {
        let trimmed = line.trim();
        trimmed.is_empty() || trimmed.starts_with('#')
    })
}

impl Config {
    /// Load configuration from a file.
    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
        let content = fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
            path: path.display().to_string(),
            source: e,
        })?;

        let ext = path
            .extension()
            .and_then(|e| e.to_str())
            .unwrap_or("")
            .to_lowercase();

        match ext.as_str() {
            "yaml" | "yml" => {
                // An empty or comment-only YAML document means "use defaults".
                // Handle it explicitly so config loading does not depend on the
                // YAML backend's null coercion, which varies between crates and
                // versions (some backends reject an empty document with
                // "expected mapping, found other" instead of yielding null).
                if is_effectively_empty(&content) {
                    return Ok(Self::default());
                }
                serde_norway::from_str(&content).map_err(|e| ConfigError::ParseYaml {
                    path: path.display().to_string(),
                    source: e,
                })
            }
            "json" => serde_json::from_str(&content).map_err(|e| ConfigError::ParseJson {
                path: path.display().to_string(),
                source: e,
            }),
            "toml" => toml::from_str(&content).map_err(|e| ConfigError::ParseToml {
                path: path.display().to_string(),
                source: e,
            }),
            _ => Err(ConfigError::UnsupportedFormat(
                path.display().to_string(),
                ext,
            )),
        }
    }

    /// Try to find a configuration file in the project directory or parent directories.
    /// Returns `None` if no configuration file is found.
    ///
    /// Search order:
    /// 1. Walk up from project root to find `.cc-audit.yaml`, `.yml`, `.json`, or `.toml`
    /// 2. `~/.config/cc-audit/config.yaml`
    pub fn find_config_file(project_root: Option<&Path>) -> Option<PathBuf> {
        const CONFIG_FILENAMES: &[&str] = &[
            ".cc-audit.yaml",
            ".cc-audit.yml",
            ".cc-audit.json",
            ".cc-audit.toml",
        ];

        debug!(project_root = ?project_root, "Searching for configuration file");

        // Walk up directory tree to find config file (like git finds .git)
        if let Some(root) = project_root {
            // Canonicalize to resolve relative paths (e.g., "subdir" -> "/abs/path/subdir")
            // so that parent() traversal works correctly.
            let canonical = fs::canonicalize(root).unwrap_or_else(|e| {
                debug!(error = %e, path = %root.display(), "Failed to canonicalize project root, using as-is");
                root.to_path_buf()
            });

            let mut current = canonical.as_path();
            loop {
                debug!(directory = %current.display(), "Checking directory for config file");
                for filename in CONFIG_FILENAMES {
                    let path = current.join(filename);
                    if path.exists() {
                        debug!(path = %path.display(), "Found configuration file");
                        return Some(path);
                    }
                }

                match current.parent() {
                    Some(parent) if !parent.as_os_str().is_empty() => current = parent,
                    _ => {
                        debug!("Reached filesystem root, stopping search");
                        break;
                    }
                }
            }
        }

        // Fall back to global config (~/.config/cc-audit/config.yaml)
        if let Some(config_dir) = dirs::config_dir() {
            let global_config = config_dir.join("cc-audit").join("config.yaml");
            if global_config.exists() {
                debug!(path = %global_config.display(), "Found global configuration file");
                return Some(global_config);
            }
        }

        debug!("No configuration file found");
        None
    }

    /// Try to load configuration from the project directory or global config.
    /// Returns both the configuration and the path where it was found.
    pub fn try_load(project_root: Option<&Path>) -> ConfigLoadResult {
        if let Some(path) = Self::find_config_file(project_root)
            && let Ok(config) = Self::from_file(&path)
        {
            return ConfigLoadResult {
                config,
                path: Some(path),
            };
        }

        ConfigLoadResult {
            config: Self::default(),
            path: None,
        }
    }

    /// Load configuration from the project directory or global config.
    /// Returns default configuration if no file is found.
    ///
    /// Search order:
    /// 1. `.cc-audit.yaml` in project root
    /// 2. `.cc-audit.json` in project root
    /// 3. `.cc-audit.toml` in project root
    /// 4. `~/.config/cc-audit/config.yaml`
    /// 5. Default configuration
    pub fn load(project_root: Option<&Path>) -> Self {
        Self::try_load(project_root).config
    }
}

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

    /// Serialize a `Config` to a JSON value with the set-backed `text_files`
    /// collections sorted, so two logically-equal configs compare equal despite
    /// `HashSet` iteration order being nondeterministic.
    fn normalized(config: &Config) -> serde_json::Value {
        let mut value = serde_json::to_value(config).unwrap();
        if let Some(text_files) = value.get_mut("text_files").and_then(|t| t.as_object_mut()) {
            for key in ["extensions", "special_names"] {
                if let Some(array) = text_files.get_mut(key).and_then(|a| a.as_array_mut()) {
                    array.sort_by_key(|value| value.to_string());
                }
            }
        }
        value
    }

    /// Helper to canonicalize and compare two paths for equality.
    fn assert_paths_eq(actual: &Path, expected: &Path) {
        assert_eq!(
            actual.canonicalize().unwrap(),
            expected.canonicalize().unwrap()
        );
    }

    #[test]
    fn test_find_config_file_walks_up_to_parent() {
        let temp_dir = TempDir::new().unwrap();
        let subdir = temp_dir.path().join("subdir");
        fs::create_dir(&subdir).unwrap();

        let config_path = temp_dir.path().join(".cc-audit.yaml");
        fs::write(&config_path, "# Test config\n").unwrap();

        let found = Config::find_config_file(Some(&subdir))
            .expect("should find config in parent directory");

        assert_paths_eq(&found, &config_path);
    }

    #[test]
    fn test_find_config_file_with_relative_path() {
        let temp_dir = TempDir::new().unwrap();
        let subdir = temp_dir.path().join("subdir");
        fs::create_dir(&subdir).unwrap();

        let config_path = temp_dir.path().join(".cc-audit.yaml");
        fs::write(&config_path, "# Test config\n").unwrap();

        // Temporarily change cwd so that a relative path resolves correctly.
        // NOTE: set_current_dir is process-global and not thread-safe.
        let original_dir = std::env::current_dir().unwrap();
        std::env::set_current_dir(temp_dir.path()).unwrap();
        let found = Config::find_config_file(Some(Path::new("subdir")));
        std::env::set_current_dir(original_dir).unwrap();

        assert!(found.is_some(), "should find config via relative path");
    }

    #[test]
    fn test_find_config_file_returns_none_when_missing() {
        let temp_dir = TempDir::new().unwrap();
        let subdir = temp_dir.path().join("subdir");
        fs::create_dir(&subdir).unwrap();

        let found = Config::find_config_file(Some(&subdir));

        assert!(found.is_none());
    }

    #[test]
    fn test_find_config_file_in_same_directory() {
        let temp_dir = TempDir::new().unwrap();

        let config_path = temp_dir.path().join(".cc-audit.yaml");
        fs::write(&config_path, "# Test config\n").unwrap();

        let found = Config::find_config_file(Some(temp_dir.path()))
            .expect("should find config in same directory");

        assert_paths_eq(&found, &config_path);
    }

    #[test]
    fn test_find_config_file_traverses_multiple_levels() {
        let temp_dir = TempDir::new().unwrap();
        let level3 = temp_dir.path().join("level1").join("level2").join("level3");
        fs::create_dir_all(&level3).unwrap();

        let config_path = temp_dir.path().join(".cc-audit.yaml");
        fs::write(&config_path, "# Test config\n").unwrap();

        let found =
            Config::find_config_file(Some(&level3)).expect("should find config 3 levels up");

        assert_paths_eq(&found, &config_path);
    }

    #[test]
    fn test_find_config_file_prefers_closest_config() {
        let temp_dir = TempDir::new().unwrap();
        let subdir = temp_dir.path().join("subdir");
        fs::create_dir(&subdir).unwrap();

        let parent_config = temp_dir.path().join(".cc-audit.yaml");
        let subdir_config = subdir.join(".cc-audit.yaml");
        fs::write(&parent_config, "# Parent config\n").unwrap();
        fs::write(&subdir_config, "# Subdir config\n").unwrap();

        let found = Config::find_config_file(Some(&subdir)).expect("should find closest config");

        assert_paths_eq(&found, &subdir_config);
    }

    #[test]
    fn test_from_file_empty_yaml_returns_default() {
        // An empty `.cc-audit.yaml` must be treated as "use defaults", not as a
        // parse error. This must not depend on the YAML backend's null handling,
        // which differs across YAML crates (some reject an empty/comment-only
        // document instead of yielding null).
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join(".cc-audit.yaml");
        fs::write(&config_path, "").unwrap();

        let config = Config::from_file(&config_path).expect("empty YAML should load as default");
        // Config does not implement PartialEq; compare via normalized serialized form.
        assert_eq!(normalized(&config), normalized(&Config::default()));
    }

    #[test]
    fn test_from_file_comment_only_yaml_returns_default() {
        // A config file containing only comments (and blank lines) is
        // semantically empty and must load as the default configuration.
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join(".cc-audit.yaml");
        fs::write(
            &config_path,
            "# Minimal test config\n\n  # indented comment\n",
        )
        .unwrap();

        let config =
            Config::from_file(&config_path).expect("comment-only YAML should load as default");
        assert_eq!(normalized(&config), normalized(&Config::default()));
    }

    #[test]
    fn test_from_file_invalid_yaml_still_errors() {
        // The empty-document handling must NOT mask genuine parse errors: a file
        // with real (non-comment) but malformed content must still fail.
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join(".cc-audit.yaml");
        fs::write(&config_path, "severity: {default: error\n").unwrap();

        let result = Config::from_file(&config_path);
        assert!(result.is_err(), "malformed YAML must still return an error");
    }
}