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