cc_audit/config/
loading.rs1use std::fs;
4use std::path::{Path, PathBuf};
5use tracing::debug;
6
7use super::error::ConfigError;
8use super::types::Config;
9
10#[derive(Debug)]
12pub struct ConfigLoadResult {
13 pub config: Config,
15 pub path: Option<PathBuf>,
17}
18
19fn 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 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 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 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 if let Some(root) = project_root {
91 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 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 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 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 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 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 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 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 assert_eq!(normalized(&config), normalized(&Config::default()));
295 }
296
297 #[test]
298 fn test_from_file_comment_only_yaml_returns_default() {
299 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 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}