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 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 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 pub fn is_ignored(&self, file_path: &str) -> bool {
54 let lower = file_path.to_lowercase().replace('\\', "/"); self.ignore_list()
56 .iter()
57 .any(|pattern| glob_match(pattern, &lower))
58 }
59}
60
61fn glob_match(pattern: &str, path: &str) -> bool {
63 let pattern = pattern.to_lowercase().replace('\\', "/"); if pattern.ends_with('/') {
67 let dir_name = pattern.trim_end_matches('/');
68
69 if path.starts_with(&format!("{}/", dir_name)) {
71 return true;
72 }
73
74 if path.contains(&format!("/{}/", dir_name)) {
76 return true;
77 }
78
79 return false;
80 }
81
82 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 let filename = path.split('/').next_back().unwrap_or(path);
102 simple_glob_match(&pattern, filename)
103}
104
105fn 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")); 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")); assert!(cfg.is_ignored("draft-wip.md"));
136 }
137
138 #[test]
139 fn license_glob() {
140 let cfg = HubConfig::default();
141 assert!(!cfg.is_ignored("LICENSE-MIT"));
143 }
144}