Skip to main content

agentctl/hub/
config.rs

1use std::path::Path;
2
3use serde::Deserialize;
4
5const DEFAULT_IGNORE: &[&str] = &[
6    "README.md",
7    "CHANGELOG.md",
8    "CONTRIBUTING.md",
9    "ARCHIVED.md",
10];
11
12#[derive(Debug, Default, Deserialize)]
13pub struct HubConfig {
14    #[serde(default)]
15    pub hub: HubSection,
16    #[serde(default)]
17    pub generate: GenerateSection,
18}
19
20#[derive(Debug, Default, Deserialize)]
21pub struct HubSection {
22    pub id: Option<String>,
23}
24
25#[derive(Debug, Default, Deserialize)]
26pub struct GenerateSection {
27    pub ignore: Option<Vec<String>>,
28}
29
30impl HubConfig {
31    /// Load from `agentctl.toml` at hub root, or return defaults if absent.
32    pub fn load(hub_path: &Path) -> Self {
33        let toml_path = hub_path.join("agentctl.toml");
34        if !toml_path.exists() {
35            return Self::default();
36        }
37        let content = match std::fs::read_to_string(&toml_path) {
38            Ok(c) => c,
39            Err(_) => return Self::default(),
40        };
41        toml::from_str(&content).unwrap_or_default()
42    }
43
44    /// Effective ignore list: from config if set, otherwise defaults.
45    pub fn ignore_list(&self) -> Vec<String> {
46        match &self.generate.ignore {
47            Some(list) => list.clone(),
48            None => DEFAULT_IGNORE.iter().map(|s| s.to_string()).collect(),
49        }
50    }
51
52    /// Returns true if the filename should be excluded (case-insensitive).
53    pub fn is_ignored(&self, filename: &str) -> bool {
54        let lower = filename.to_lowercase();
55        self.ignore_list()
56            .iter()
57            .any(|pattern| glob_match(pattern, &lower))
58    }
59}
60
61/// Minimal glob: supports `*` wildcard on filename only.
62fn glob_match(pattern: &str, name: &str) -> bool {
63    let pattern = pattern.to_lowercase();
64    match pattern.find('*') {
65        None => name == pattern.as_str(),
66        Some(i) => name.starts_with(&pattern[..i]) && name.ends_with(&pattern[i + 1..]),
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn default_ignore_list() {
76        let cfg = HubConfig::default();
77        assert!(cfg.is_ignored("README.md"));
78        assert!(cfg.is_ignored("readme.md")); // case-insensitive
79        assert!(cfg.is_ignored("CHANGELOG.md"));
80        assert!(!cfg.is_ignored("my-doc.md"));
81    }
82
83    #[test]
84    fn custom_ignore_overrides_defaults() {
85        let cfg = HubConfig {
86            hub: HubSection { id: None },
87            generate: GenerateSection {
88                ignore: Some(vec!["draft-*.md".to_string()]),
89            },
90        };
91        assert!(!cfg.is_ignored("README.md")); // not in custom list
92        assert!(cfg.is_ignored("draft-wip.md"));
93    }
94
95    #[test]
96    fn license_glob() {
97        let cfg = HubConfig::default();
98        // LICENSE* not in default list — only exact matches by default
99        assert!(!cfg.is_ignored("LICENSE-MIT"));
100    }
101}