Skip to main content

affected_core/
config.rs

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    /// Load config from `.affected.toml` in the project root, or return defaults.
36    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    /// Load config from a specific path (for --config flag).
54    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    /// Get per-package configuration by name.
63    pub fn package_config(&self, name: &str) -> Option<&PackageConfig> {
64        self.packages.as_ref()?.get(name)
65    }
66
67    /// Get a custom test command for a given ecosystem and package.
68    /// Replaces `{package}` placeholder with the actual package name.
69    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(), // Yarn uses npm config
77                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    /// Check if a file path matches any ignore patterns.
94    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}