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
19/// Returns `true` if a YAML document carries no content, i.e. every line is
20/// blank or a full-line comment. Such documents are semantically empty and
21/// should map to the default configuration.
22fn is_effectively_empty(content: &str) -> bool {
23    content.lines().all(|line| {
24        let trimmed = line.trim();
25        trimmed.is_empty() || trimmed.starts_with('#')
26    })
27}
28
29impl Config {
30    /// Load configuration from a file.
31    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
32        let content = fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
33            path: path.display().to_string(),
34            source: e,
35        })?;
36
37        let ext = path
38            .extension()
39            .and_then(|e| e.to_str())
40            .unwrap_or("")
41            .to_lowercase();
42
43        match ext.as_str() {
44            "yaml" | "yml" => {
45                // An empty or comment-only YAML document means "use defaults".
46                // Handle it explicitly so config loading does not depend on the
47                // YAML backend's null coercion, which varies between crates and
48                // versions (some backends reject an empty document with
49                // "expected mapping, found other" instead of yielding null).
50                if is_effectively_empty(&content) {
51                    return Ok(Self::default());
52                }
53                serde_norway::from_str(&content).map_err(|e| ConfigError::ParseYaml {
54                    path: path.display().to_string(),
55                    source: e,
56                })
57            }
58            "json" => serde_json::from_str(&content).map_err(|e| ConfigError::ParseJson {
59                path: path.display().to_string(),
60                source: e,
61            }),
62            "toml" => toml::from_str(&content).map_err(|e| ConfigError::ParseToml {
63                path: path.display().to_string(),
64                source: e,
65            }),
66            _ => Err(ConfigError::UnsupportedFormat(
67                path.display().to_string(),
68                ext,
69            )),
70        }
71    }
72
73    /// Try to find a configuration file in the project directory or parent directories.
74    /// Returns `None` if no configuration file is found.
75    ///
76    /// Search order:
77    /// 1. Walk up from project root to find `.cc-audit.yaml`, `.yml`, `.json`, or `.toml`
78    /// 2. `~/.config/cc-audit/config.yaml`
79    pub fn find_config_file(project_root: Option<&Path>) -> Option<PathBuf> {
80        const CONFIG_FILENAMES: &[&str] = &[
81            ".cc-audit.yaml",
82            ".cc-audit.yml",
83            ".cc-audit.json",
84            ".cc-audit.toml",
85        ];
86
87        debug!(project_root = ?project_root, "Searching for configuration file");
88
89        // Walk up directory tree to find config file (like git finds .git)
90        if let Some(root) = project_root {
91            // Canonicalize to resolve relative paths (e.g., "subdir" -> "/abs/path/subdir")
92            // so that parent() traversal works correctly.
93            let canonical = fs::canonicalize(root).unwrap_or_else(|e| {
94                debug!(error = %e, path = %root.display(), "Failed to canonicalize project root, using as-is");
95                root.to_path_buf()
96            });
97
98            let mut current = canonical.as_path();
99            loop {
100                debug!(directory = %current.display(), "Checking directory for config file");
101                for filename in CONFIG_FILENAMES {
102                    let path = current.join(filename);
103                    if path.exists() {
104                        debug!(path = %path.display(), "Found configuration file");
105                        return Some(path);
106                    }
107                }
108
109                match current.parent() {
110                    Some(parent) if !parent.as_os_str().is_empty() => current = parent,
111                    _ => {
112                        debug!("Reached filesystem root, stopping search");
113                        break;
114                    }
115                }
116            }
117        }
118
119        // Fall back to global config (~/.config/cc-audit/config.yaml)
120        if let Some(config_dir) = dirs::config_dir() {
121            let global_config = config_dir.join("cc-audit").join("config.yaml");
122            if global_config.exists() {
123                debug!(path = %global_config.display(), "Found global configuration file");
124                return Some(global_config);
125            }
126        }
127
128        debug!("No configuration file found");
129        None
130    }
131
132    /// Try to load configuration from the project directory or global config.
133    /// Returns both the configuration and the path where it was found.
134    pub fn try_load(project_root: Option<&Path>) -> ConfigLoadResult {
135        if let Some(path) = Self::find_config_file(project_root)
136            && let Ok(config) = Self::from_file(&path)
137        {
138            return ConfigLoadResult {
139                config,
140                path: Some(path),
141            };
142        }
143
144        ConfigLoadResult {
145            config: Self::default(),
146            path: None,
147        }
148    }
149
150    /// Load configuration from the project directory or global config.
151    /// Returns default configuration if no file is found.
152    ///
153    /// Search order:
154    /// 1. `.cc-audit.yaml` in project root
155    /// 2. `.cc-audit.json` in project root
156    /// 3. `.cc-audit.toml` in project root
157    /// 4. `~/.config/cc-audit/config.yaml`
158    /// 5. Default configuration
159    pub fn load(project_root: Option<&Path>) -> Self {
160        Self::try_load(project_root).config
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use std::fs;
168    use tempfile::TempDir;
169
170    /// Serialize a `Config` to a JSON value with the set-backed `text_files`
171    /// collections sorted, so two logically-equal configs compare equal despite
172    /// `HashSet` iteration order being nondeterministic.
173    fn normalized(config: &Config) -> serde_json::Value {
174        let mut value = serde_json::to_value(config).unwrap();
175        if let Some(text_files) = value.get_mut("text_files").and_then(|t| t.as_object_mut()) {
176            for key in ["extensions", "special_names"] {
177                if let Some(array) = text_files.get_mut(key).and_then(|a| a.as_array_mut()) {
178                    array.sort_by_key(|value| value.to_string());
179                }
180            }
181        }
182        value
183    }
184
185    /// Helper to canonicalize and compare two paths for equality.
186    fn assert_paths_eq(actual: &Path, expected: &Path) {
187        assert_eq!(
188            actual.canonicalize().unwrap(),
189            expected.canonicalize().unwrap()
190        );
191    }
192
193    #[test]
194    fn test_find_config_file_walks_up_to_parent() {
195        let temp_dir = TempDir::new().unwrap();
196        let subdir = temp_dir.path().join("subdir");
197        fs::create_dir(&subdir).unwrap();
198
199        let config_path = temp_dir.path().join(".cc-audit.yaml");
200        fs::write(&config_path, "# Test config\n").unwrap();
201
202        let found = Config::find_config_file(Some(&subdir))
203            .expect("should find config in parent directory");
204
205        assert_paths_eq(&found, &config_path);
206    }
207
208    #[test]
209    fn test_find_config_file_with_relative_path() {
210        let temp_dir = TempDir::new().unwrap();
211        let subdir = temp_dir.path().join("subdir");
212        fs::create_dir(&subdir).unwrap();
213
214        let config_path = temp_dir.path().join(".cc-audit.yaml");
215        fs::write(&config_path, "# Test config\n").unwrap();
216
217        // Temporarily change cwd so that a relative path resolves correctly.
218        // NOTE: set_current_dir is process-global and not thread-safe.
219        let original_dir = std::env::current_dir().unwrap();
220        std::env::set_current_dir(temp_dir.path()).unwrap();
221        let found = Config::find_config_file(Some(Path::new("subdir")));
222        std::env::set_current_dir(original_dir).unwrap();
223
224        assert!(found.is_some(), "should find config via relative path");
225    }
226
227    #[test]
228    fn test_find_config_file_returns_none_when_missing() {
229        let temp_dir = TempDir::new().unwrap();
230        let subdir = temp_dir.path().join("subdir");
231        fs::create_dir(&subdir).unwrap();
232
233        let found = Config::find_config_file(Some(&subdir));
234
235        assert!(found.is_none());
236    }
237
238    #[test]
239    fn test_find_config_file_in_same_directory() {
240        let temp_dir = TempDir::new().unwrap();
241
242        let config_path = temp_dir.path().join(".cc-audit.yaml");
243        fs::write(&config_path, "# Test config\n").unwrap();
244
245        let found = Config::find_config_file(Some(temp_dir.path()))
246            .expect("should find config in same directory");
247
248        assert_paths_eq(&found, &config_path);
249    }
250
251    #[test]
252    fn test_find_config_file_traverses_multiple_levels() {
253        let temp_dir = TempDir::new().unwrap();
254        let level3 = temp_dir.path().join("level1").join("level2").join("level3");
255        fs::create_dir_all(&level3).unwrap();
256
257        let config_path = temp_dir.path().join(".cc-audit.yaml");
258        fs::write(&config_path, "# Test config\n").unwrap();
259
260        let found =
261            Config::find_config_file(Some(&level3)).expect("should find config 3 levels up");
262
263        assert_paths_eq(&found, &config_path);
264    }
265
266    #[test]
267    fn test_find_config_file_prefers_closest_config() {
268        let temp_dir = TempDir::new().unwrap();
269        let subdir = temp_dir.path().join("subdir");
270        fs::create_dir(&subdir).unwrap();
271
272        let parent_config = temp_dir.path().join(".cc-audit.yaml");
273        let subdir_config = subdir.join(".cc-audit.yaml");
274        fs::write(&parent_config, "# Parent config\n").unwrap();
275        fs::write(&subdir_config, "# Subdir config\n").unwrap();
276
277        let found = Config::find_config_file(Some(&subdir)).expect("should find closest config");
278
279        assert_paths_eq(&found, &subdir_config);
280    }
281
282    #[test]
283    fn test_from_file_empty_yaml_returns_default() {
284        // An empty `.cc-audit.yaml` must be treated as "use defaults", not as a
285        // parse error. This must not depend on the YAML backend's null handling,
286        // which differs across YAML crates (some reject an empty/comment-only
287        // document instead of yielding null).
288        let temp_dir = TempDir::new().unwrap();
289        let config_path = temp_dir.path().join(".cc-audit.yaml");
290        fs::write(&config_path, "").unwrap();
291
292        let config = Config::from_file(&config_path).expect("empty YAML should load as default");
293        // Config does not implement PartialEq; compare via normalized serialized form.
294        assert_eq!(normalized(&config), normalized(&Config::default()));
295    }
296
297    #[test]
298    fn test_from_file_comment_only_yaml_returns_default() {
299        // A config file containing only comments (and blank lines) is
300        // semantically empty and must load as the default configuration.
301        let temp_dir = TempDir::new().unwrap();
302        let config_path = temp_dir.path().join(".cc-audit.yaml");
303        fs::write(
304            &config_path,
305            "# Minimal test config\n\n  # indented comment\n",
306        )
307        .unwrap();
308
309        let config =
310            Config::from_file(&config_path).expect("comment-only YAML should load as default");
311        assert_eq!(normalized(&config), normalized(&Config::default()));
312    }
313
314    #[test]
315    fn test_from_file_invalid_yaml_still_errors() {
316        // The empty-document handling must NOT mask genuine parse errors: a file
317        // with real (non-comment) but malformed content must still fail.
318        let temp_dir = TempDir::new().unwrap();
319        let config_path = temp_dir.path().join(".cc-audit.yaml");
320        fs::write(&config_path, "severity: {default: error\n").unwrap();
321
322        let result = Config::from_file(&config_path);
323        assert!(result.is_err(), "malformed YAML must still return an error");
324    }
325}