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 file path should be excluded (case-insensitive).
53    pub fn is_ignored(&self, file_path: &str) -> bool {
54        let lower = file_path.to_lowercase().replace('\\', "/"); // normalize path separators
55        self.ignore_list()
56            .iter()
57            .any(|pattern| glob_match(pattern, &lower))
58    }
59}
60
61/// Enhanced glob matching: supports path patterns, directories, and wildcards.
62fn glob_match(pattern: &str, path: &str) -> bool {
63    let pattern = pattern.to_lowercase().replace('\\', "/"); // normalize separators
64
65    // Handle directory patterns (ending with /)
66    if pattern.ends_with('/') {
67        let dir_name = pattern.trim_end_matches('/');
68
69        // Check if path starts with this directory
70        if path.starts_with(&format!("{}/", dir_name)) {
71            return true;
72        }
73
74        // Check if path contains this directory
75        if path.contains(&format!("/{}/", dir_name)) {
76            return true;
77        }
78
79        return false;
80    }
81
82    // Handle simple path patterns with *
83    if pattern.contains('/') {
84        let pattern_parts: Vec<&str> = pattern.split('/').collect();
85        let path_parts: Vec<&str> = path.split('/').collect();
86
87        if pattern_parts.len() != path_parts.len() {
88            return false;
89        }
90
91        for (pattern_part, path_part) in pattern_parts.iter().zip(path_parts.iter()) {
92            if !simple_glob_match(pattern_part, path_part) {
93                return false;
94            }
95        }
96
97        return true;
98    }
99
100    // Backward compatibility: filename-only patterns
101    let filename = path.split('/').next_back().unwrap_or(path);
102    simple_glob_match(&pattern, filename)
103}
104
105/// Simple glob matching for single component (filename or directory name)
106fn simple_glob_match(pattern: &str, name: &str) -> bool {
107    match pattern.find('*') {
108        None => name == pattern,
109        Some(i) => name.starts_with(&pattern[..i]) && name.ends_with(&pattern[i + 1..]),
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn default_ignore_list() {
119        let cfg = HubConfig::default();
120        assert!(cfg.is_ignored("README.md"));
121        assert!(cfg.is_ignored("readme.md")); // case-insensitive
122        assert!(cfg.is_ignored("CHANGELOG.md"));
123        assert!(!cfg.is_ignored("my-doc.md"));
124    }
125
126    #[test]
127    fn custom_ignore_overrides_defaults() {
128        let cfg = HubConfig {
129            hub: HubSection { id: None },
130            generate: GenerateSection {
131                ignore: Some(vec!["draft-*.md".to_string()]),
132            },
133        };
134        assert!(!cfg.is_ignored("README.md")); // not in custom list
135        assert!(cfg.is_ignored("draft-wip.md"));
136    }
137
138    #[test]
139    fn license_glob() {
140        let cfg = HubConfig::default();
141        // LICENSE* not in default list — only exact matches by default
142        assert!(!cfg.is_ignored("LICENSE-MIT"));
143    }
144}