1use anyhow::Result;
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::path::Path;
5use tracing::debug;
6
7use crate::types::{Ecosystem, PackageConfig};
8
9#[derive(Debug, Deserialize, Default)]
10pub struct Config {
11 pub test: Option<TestConfig>,
12 pub ignore: Option<Vec<String>>,
13 pub packages: Option<HashMap<String, PackageConfig>>,
14}
15
16#[derive(Debug, Deserialize, Default)]
17pub struct TestConfig {
18 pub cargo: Option<String>,
19 pub npm: Option<String>,
20 pub go: Option<String>,
21 pub python: Option<String>,
22 pub maven: Option<String>,
23 pub gradle: Option<String>,
24}
25
26impl Config {
27 pub fn load(root: &Path) -> Result<Self> {
29 let config_path = root.join(".affected.toml");
30 if config_path.exists() {
31 debug!("Loading config from {}", config_path.display());
32 let content = std::fs::read_to_string(&config_path)?;
33 let config: Self = toml::from_str(&content)?;
34 debug!("Config loaded successfully");
35 Ok(config)
36 } else {
37 debug!(
38 "No .affected.toml found at {}, using defaults",
39 root.display()
40 );
41 Ok(Self::default())
42 }
43 }
44
45 pub fn load_from(path: &Path) -> Result<Self> {
47 debug!("Loading config from explicit path: {}", path.display());
48 let content = std::fs::read_to_string(path)?;
49 let config: Self = toml::from_str(&content)?;
50 debug!("Config loaded successfully from {}", path.display());
51 Ok(config)
52 }
53
54 pub fn package_config(&self, name: &str) -> Option<&PackageConfig> {
56 self.packages.as_ref()?.get(name)
57 }
58
59 pub fn test_command_for(&self, ecosystem: Ecosystem, package: &str) -> Option<Vec<String>> {
62 let template = match &self.test {
63 Some(tc) => match ecosystem {
64 Ecosystem::Cargo => tc.cargo.as_deref(),
65 Ecosystem::Npm => tc.npm.as_deref(),
66 Ecosystem::Go => tc.go.as_deref(),
67 Ecosystem::Python => tc.python.as_deref(),
68 Ecosystem::Yarn => tc.npm.as_deref(), Ecosystem::Maven => tc.maven.as_deref(),
70 Ecosystem::Gradle => tc.gradle.as_deref(),
71 },
72 None => None,
73 }?;
74
75 let expanded = template.replace("{package}", package);
76 Some(expanded.split_whitespace().map(String::from).collect())
77 }
78
79 pub fn is_ignored(&self, path: &str) -> bool {
81 match &self.ignore {
82 Some(patterns) => patterns.iter().any(|pat| {
83 glob::Pattern::new(pat)
84 .map(|p| p.matches(path))
85 .unwrap_or(false)
86 }),
87 None => false,
88 }
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn test_load_missing_config_returns_defaults() {
98 let dir = tempfile::tempdir().unwrap();
99 let config = Config::load(dir.path()).unwrap();
100 assert!(config.test.is_none());
101 assert!(config.ignore.is_none());
102 }
103
104 #[test]
105 fn test_load_valid_config() {
106 let dir = tempfile::tempdir().unwrap();
107 std::fs::write(
108 dir.path().join(".affected.toml"),
109 r#"
110ignore = ["*.md", "docs/**"]
111
112[test]
113cargo = "cargo nextest run -p {package}"
114npm = "pnpm test --filter {package}"
115"#,
116 )
117 .unwrap();
118
119 let config = Config::load(dir.path()).unwrap();
120 let tc = config.test.unwrap();
121 assert!(tc.cargo.is_some());
122 assert!(tc.npm.is_some());
123 assert!(tc.go.is_none());
124 assert!(tc.python.is_none());
125 assert_eq!(config.ignore.unwrap().len(), 2);
126 }
127
128 #[test]
129 fn test_load_invalid_toml_errors() {
130 let dir = tempfile::tempdir().unwrap();
131 std::fs::write(dir.path().join(".affected.toml"), "this is [not valid toml").unwrap();
132 assert!(Config::load(dir.path()).is_err());
133 }
134
135 #[test]
136 fn test_command_for_with_placeholder() {
137 let config = Config {
138 test: Some(TestConfig {
139 cargo: Some("cargo nextest run -p {package}".into()),
140 npm: None,
141 go: None,
142 python: None,
143 maven: None,
144 gradle: None,
145 }),
146 ignore: None,
147 packages: None,
148 };
149
150 let cmd = config
151 .test_command_for(Ecosystem::Cargo, "my-crate")
152 .unwrap();
153 assert_eq!(cmd, vec!["cargo", "nextest", "run", "-p", "my-crate"]);
154 }
155
156 #[test]
157 fn test_command_for_missing_ecosystem() {
158 let config = Config {
159 test: Some(TestConfig {
160 cargo: Some("cargo test -p {package}".into()),
161 npm: None,
162 go: None,
163 python: None,
164 maven: None,
165 gradle: None,
166 }),
167 ignore: None,
168 packages: None,
169 };
170
171 assert!(config.test_command_for(Ecosystem::Npm, "pkg").is_none());
172 assert!(config.test_command_for(Ecosystem::Go, "pkg").is_none());
173 assert!(config.test_command_for(Ecosystem::Python, "pkg").is_none());
174 }
175
176 #[test]
177 fn test_command_for_no_test_config() {
178 let config = Config::default();
179 assert!(config.test_command_for(Ecosystem::Cargo, "pkg").is_none());
180 }
181
182 #[test]
183 fn test_is_ignored_md_files() {
184 let config = Config {
185 test: None,
186 ignore: Some(vec!["*.md".into()]),
187 packages: None,
188 };
189
190 assert!(config.is_ignored("README.md"));
191 assert!(config.is_ignored("CHANGELOG.md"));
192 assert!(!config.is_ignored("src/main.rs"));
193 }
194
195 #[test]
196 fn test_is_ignored_glob_patterns() {
197 let config = Config {
198 test: None,
199 ignore: Some(vec!["docs/**".into(), "*.txt".into()]),
200 packages: None,
201 };
202
203 assert!(config.is_ignored("docs/guide.html"));
204 assert!(config.is_ignored("docs/api/ref.md"));
205 assert!(config.is_ignored("notes.txt"));
206 assert!(!config.is_ignored("src/lib.rs"));
207 }
208
209 #[test]
210 fn test_is_ignored_no_patterns() {
211 let config = Config::default();
212 assert!(!config.is_ignored("anything.rs"));
213 }
214
215 #[test]
216 fn test_package_config_lookup() {
217 let mut packages = HashMap::new();
218 packages.insert(
219 "my-pkg".to_string(),
220 PackageConfig {
221 test: Some("cargo nextest run -p my-pkg".to_string()),
222 timeout: Some(120),
223 skip: Some(false),
224 },
225 );
226 let config = Config {
227 test: None,
228 ignore: None,
229 packages: Some(packages),
230 };
231
232 let pc = config.package_config("my-pkg").unwrap();
233 assert_eq!(pc.timeout, Some(120));
234 assert!(config.package_config("nonexistent").is_none());
235 }
236
237 #[test]
238 fn test_load_from_explicit_path() {
239 let dir = tempfile::tempdir().unwrap();
240 let custom_path = dir.path().join("custom-config.toml");
241 std::fs::write(
242 &custom_path,
243 r#"
244ignore = ["*.lock"]
245"#,
246 )
247 .unwrap();
248
249 let config = Config::load_from(&custom_path).unwrap();
250 assert_eq!(config.ignore.unwrap().len(), 1);
251 }
252
253 #[test]
254 fn test_load_config_with_packages() {
255 let dir = tempfile::tempdir().unwrap();
256 std::fs::write(
257 dir.path().join(".affected.toml"),
258 r#"
259[packages.my-crate]
260test = "cargo nextest run -p my-crate"
261timeout = 60
262skip = false
263"#,
264 )
265 .unwrap();
266
267 let config = Config::load(dir.path()).unwrap();
268 let pc = config.package_config("my-crate").unwrap();
269 assert_eq!(pc.test.as_deref(), Some("cargo nextest run -p my-crate"));
270 assert_eq!(pc.timeout, Some(60));
271 assert_eq!(pc.skip, Some(false));
272 }
273}