Skip to main content

cc_audit/config/
loading.rs

1//! Configuration loading functions.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use tracing::debug;
6
7use super::error::ConfigError;
8use super::types::Config;
9
10/// Result of trying to find a configuration file.
11#[derive(Debug)]
12pub struct ConfigLoadResult {
13    /// The loaded configuration.
14    pub config: Config,
15    /// The path to the configuration file, if found.
16    pub path: Option<PathBuf>,
17}
18
19impl Config {
20    /// Load configuration from a file.
21    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
22        let content = fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
23            path: path.display().to_string(),
24            source: e,
25        })?;
26
27        let ext = path
28            .extension()
29            .and_then(|e| e.to_str())
30            .unwrap_or("")
31            .to_lowercase();
32
33        match ext.as_str() {
34            "yaml" | "yml" => serde_yaml::from_str(&content).map_err(|e| ConfigError::ParseYaml {
35                path: path.display().to_string(),
36                source: e,
37            }),
38            "json" => serde_json::from_str(&content).map_err(|e| ConfigError::ParseJson {
39                path: path.display().to_string(),
40                source: e,
41            }),
42            "toml" => toml::from_str(&content).map_err(|e| ConfigError::ParseToml {
43                path: path.display().to_string(),
44                source: e,
45            }),
46            _ => Err(ConfigError::UnsupportedFormat(
47                path.display().to_string(),
48                ext,
49            )),
50        }
51    }
52
53    /// Try to find a configuration file in the project directory or parent directories.
54    /// Returns `None` if no configuration file is found.
55    ///
56    /// Search order:
57    /// 1. Walk up from project root to find `.cc-audit.yaml`, `.yml`, `.json`, or `.toml`
58    /// 2. `~/.config/cc-audit/config.yaml`
59    pub fn find_config_file(project_root: Option<&Path>) -> Option<PathBuf> {
60        const CONFIG_FILENAMES: &[&str] = &[
61            ".cc-audit.yaml",
62            ".cc-audit.yml",
63            ".cc-audit.json",
64            ".cc-audit.toml",
65        ];
66
67        debug!(project_root = ?project_root, "Searching for configuration file");
68
69        // Walk up directory tree to find config file (like git finds .git)
70        if let Some(root) = project_root {
71            // Canonicalize to resolve relative paths (e.g., "subdir" -> "/abs/path/subdir")
72            // so that parent() traversal works correctly.
73            let canonical = fs::canonicalize(root).unwrap_or_else(|e| {
74                debug!(error = %e, path = %root.display(), "Failed to canonicalize project root, using as-is");
75                root.to_path_buf()
76            });
77
78            let mut current = canonical.as_path();
79            loop {
80                debug!(directory = %current.display(), "Checking directory for config file");
81                for filename in CONFIG_FILENAMES {
82                    let path = current.join(filename);
83                    if path.exists() {
84                        debug!(path = %path.display(), "Found configuration file");
85                        return Some(path);
86                    }
87                }
88
89                match current.parent() {
90                    Some(parent) if !parent.as_os_str().is_empty() => current = parent,
91                    _ => {
92                        debug!("Reached filesystem root, stopping search");
93                        break;
94                    }
95                }
96            }
97        }
98
99        // Fall back to global config (~/.config/cc-audit/config.yaml)
100        if let Some(config_dir) = dirs::config_dir() {
101            let global_config = config_dir.join("cc-audit").join("config.yaml");
102            if global_config.exists() {
103                debug!(path = %global_config.display(), "Found global configuration file");
104                return Some(global_config);
105            }
106        }
107
108        debug!("No configuration file found");
109        None
110    }
111
112    /// Try to load configuration from the project directory or global config.
113    /// Returns both the configuration and the path where it was found.
114    pub fn try_load(project_root: Option<&Path>) -> ConfigLoadResult {
115        if let Some(path) = Self::find_config_file(project_root)
116            && let Ok(config) = Self::from_file(&path)
117        {
118            return ConfigLoadResult {
119                config,
120                path: Some(path),
121            };
122        }
123
124        ConfigLoadResult {
125            config: Self::default(),
126            path: None,
127        }
128    }
129
130    /// Load configuration from the project directory or global config.
131    /// Returns default configuration if no file is found.
132    ///
133    /// Search order:
134    /// 1. `.cc-audit.yaml` in project root
135    /// 2. `.cc-audit.json` in project root
136    /// 3. `.cc-audit.toml` in project root
137    /// 4. `~/.config/cc-audit/config.yaml`
138    /// 5. Default configuration
139    pub fn load(project_root: Option<&Path>) -> Self {
140        Self::try_load(project_root).config
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use std::fs;
148    use tempfile::TempDir;
149
150    /// Helper to canonicalize and compare two paths for equality.
151    fn assert_paths_eq(actual: &Path, expected: &Path) {
152        assert_eq!(
153            actual.canonicalize().unwrap(),
154            expected.canonicalize().unwrap()
155        );
156    }
157
158    #[test]
159    fn test_find_config_file_walks_up_to_parent() {
160        let temp_dir = TempDir::new().unwrap();
161        let subdir = temp_dir.path().join("subdir");
162        fs::create_dir(&subdir).unwrap();
163
164        let config_path = temp_dir.path().join(".cc-audit.yaml");
165        fs::write(&config_path, "# Test config\n").unwrap();
166
167        let found = Config::find_config_file(Some(&subdir))
168            .expect("should find config in parent directory");
169
170        assert_paths_eq(&found, &config_path);
171    }
172
173    #[test]
174    fn test_find_config_file_with_relative_path() {
175        let temp_dir = TempDir::new().unwrap();
176        let subdir = temp_dir.path().join("subdir");
177        fs::create_dir(&subdir).unwrap();
178
179        let config_path = temp_dir.path().join(".cc-audit.yaml");
180        fs::write(&config_path, "# Test config\n").unwrap();
181
182        // Temporarily change cwd so that a relative path resolves correctly.
183        // NOTE: set_current_dir is process-global and not thread-safe.
184        let original_dir = std::env::current_dir().unwrap();
185        std::env::set_current_dir(temp_dir.path()).unwrap();
186        let found = Config::find_config_file(Some(Path::new("subdir")));
187        std::env::set_current_dir(original_dir).unwrap();
188
189        assert!(found.is_some(), "should find config via relative path");
190    }
191
192    #[test]
193    fn test_find_config_file_returns_none_when_missing() {
194        let temp_dir = TempDir::new().unwrap();
195        let subdir = temp_dir.path().join("subdir");
196        fs::create_dir(&subdir).unwrap();
197
198        let found = Config::find_config_file(Some(&subdir));
199
200        assert!(found.is_none());
201    }
202
203    #[test]
204    fn test_find_config_file_in_same_directory() {
205        let temp_dir = TempDir::new().unwrap();
206
207        let config_path = temp_dir.path().join(".cc-audit.yaml");
208        fs::write(&config_path, "# Test config\n").unwrap();
209
210        let found = Config::find_config_file(Some(temp_dir.path()))
211            .expect("should find config in same directory");
212
213        assert_paths_eq(&found, &config_path);
214    }
215
216    #[test]
217    fn test_find_config_file_traverses_multiple_levels() {
218        let temp_dir = TempDir::new().unwrap();
219        let level3 = temp_dir.path().join("level1").join("level2").join("level3");
220        fs::create_dir_all(&level3).unwrap();
221
222        let config_path = temp_dir.path().join(".cc-audit.yaml");
223        fs::write(&config_path, "# Test config\n").unwrap();
224
225        let found =
226            Config::find_config_file(Some(&level3)).expect("should find config 3 levels up");
227
228        assert_paths_eq(&found, &config_path);
229    }
230
231    #[test]
232    fn test_find_config_file_prefers_closest_config() {
233        let temp_dir = TempDir::new().unwrap();
234        let subdir = temp_dir.path().join("subdir");
235        fs::create_dir(&subdir).unwrap();
236
237        let parent_config = temp_dir.path().join(".cc-audit.yaml");
238        let subdir_config = subdir.join(".cc-audit.yaml");
239        fs::write(&parent_config, "# Parent config\n").unwrap();
240        fs::write(&subdir_config, "# Subdir config\n").unwrap();
241
242        let found = Config::find_config_file(Some(&subdir)).expect("should find closest config");
243
244        assert_paths_eq(&found, &subdir_config);
245    }
246}