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#[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    /// Load config from `.affected.toml` in the project root, or return defaults.
28    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    /// Load config from a specific path (for --config flag).
46    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    /// Get per-package configuration by name.
55    pub fn package_config(&self, name: &str) -> Option<&PackageConfig> {
56        self.packages.as_ref()?.get(name)
57    }
58
59    /// Get a custom test command for a given ecosystem and package.
60    /// Replaces `{package}` placeholder with the actual package name.
61    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(), // Yarn uses npm config
69                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    /// Check if a file path matches any ignore patterns.
80    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}