Skip to main content

kaish_kernel/
ignore_config.rs

1//! Configurable ignore file policy for file-walking tools.
2//!
3//! Controls which gitignore-format files are loaded and how broadly
4//! ignore rules apply. Per-mode defaults protect MCP agents from
5//! context flooding while leaving REPL users unrestricted.
6
7use std::path::Path;
8
9use crate::walker::{IgnoreFilter, WalkerFs};
10
11/// Controls which tools respect the ignore configuration.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum IgnoreScope {
14    /// Polite tools (glob, tree, grep, ls, expand_glob) respect config.
15    /// `find` remains unrestricted — traditional POSIX behavior.
16    Advisory,
17    /// ALL file-walking tools respect config, including `find`.
18    /// Protects agents from context flooding.
19    Enforced,
20}
21
22/// Centralized ignore file configuration.
23///
24/// Threaded through `KernelConfig` → `ExecContext` → tools.
25/// Runtime-mutable via the `ignore` builtin.
26#[derive(Debug, Clone)]
27pub struct IgnoreConfig {
28    scope: IgnoreScope,
29    ignore_files: Vec<String>,
30    use_defaults: bool,
31    auto_gitignore: bool,
32}
33
34impl IgnoreConfig {
35    /// No filtering — REPL/embedded/test default.
36    pub fn none() -> Self {
37        Self {
38            scope: IgnoreScope::Advisory,
39            ignore_files: Vec::new(),
40            use_defaults: false,
41            auto_gitignore: false,
42        }
43    }
44
45    /// MCP-safe defaults: enforced scope, .gitignore loaded, defaults on.
46    pub fn mcp() -> Self {
47        Self {
48            scope: IgnoreScope::Enforced,
49            ignore_files: vec![".gitignore".to_string()],
50            use_defaults: true,
51            auto_gitignore: true,
52        }
53    }
54
55    /// Whether any filtering is configured.
56    pub fn is_active(&self) -> bool {
57        self.use_defaults || self.auto_gitignore || !self.ignore_files.is_empty()
58    }
59
60    pub fn scope(&self) -> IgnoreScope {
61        self.scope
62    }
63
64    /// Whether the FileWalker should auto-load nested .gitignore files.
65    pub fn auto_gitignore(&self) -> bool {
66        self.auto_gitignore
67    }
68
69    pub fn use_defaults(&self) -> bool {
70        self.use_defaults
71    }
72
73    pub fn files(&self) -> &[String] {
74        &self.ignore_files
75    }
76
77    pub fn set_scope(&mut self, scope: IgnoreScope) {
78        self.scope = scope;
79    }
80
81    pub fn set_defaults(&mut self, on: bool) {
82        self.use_defaults = on;
83    }
84
85    pub fn set_auto_gitignore(&mut self, on: bool) {
86        self.auto_gitignore = on;
87    }
88
89    pub fn add_file(&mut self, name: &str) {
90        if !self.ignore_files.iter().any(|f| f == name) {
91            self.ignore_files.push(name.to_string());
92        }
93    }
94
95    pub fn remove_file(&mut self, name: &str) {
96        self.ignore_files.retain(|f| f != name);
97    }
98
99    pub fn clear(&mut self) {
100        self.ignore_files.clear();
101        self.use_defaults = false;
102        self.auto_gitignore = false;
103    }
104
105    /// Build an `IgnoreFilter` from the configured file list and defaults.
106    ///
107    /// Loads each ignore file relative to `root` via the given `WalkerFs`.
108    /// Returns `None` if no filtering is configured.
109    pub async fn build_filter<F: WalkerFs>(
110        &self,
111        root: &Path,
112        fs: &F,
113    ) -> Option<IgnoreFilter> {
114        if !self.is_active() {
115            return None;
116        }
117
118        let mut filter = if self.use_defaults {
119            IgnoreFilter::with_defaults()
120        } else {
121            IgnoreFilter::new()
122        };
123
124        for filename in &self.ignore_files {
125            let path = root.join(filename);
126            if let Ok(file_filter) = IgnoreFilter::from_gitignore(&path, fs).await {
127                filter.merge(&file_filter);
128            }
129            // Silently skip files that don't exist or can't be read
130        }
131
132        Some(filter)
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_none_is_inactive() {
142        let config = IgnoreConfig::none();
143        assert!(!config.is_active());
144        assert_eq!(config.scope(), IgnoreScope::Advisory);
145        assert!(!config.auto_gitignore());
146    }
147
148    #[test]
149    fn test_mcp_is_active() {
150        let config = IgnoreConfig::mcp();
151        assert!(config.is_active());
152        assert_eq!(config.scope(), IgnoreScope::Enforced);
153        assert!(config.auto_gitignore());
154        assert!(config.use_defaults());
155        assert_eq!(config.files(), &[".gitignore"]);
156    }
157
158    #[test]
159    fn test_add_remove_files() {
160        let mut config = IgnoreConfig::none();
161        assert!(!config.is_active());
162
163        config.add_file(".dockerignore");
164        assert!(config.is_active());
165        assert_eq!(config.files(), &[".dockerignore"]);
166
167        // No duplicates
168        config.add_file(".dockerignore");
169        assert_eq!(config.files().len(), 1);
170
171        config.remove_file(".dockerignore");
172        assert!(config.files().is_empty());
173    }
174
175    #[test]
176    fn test_clear() {
177        let mut config = IgnoreConfig::mcp();
178        config.clear();
179        assert!(!config.is_active());
180        assert!(config.files().is_empty());
181        assert!(!config.use_defaults());
182        assert!(!config.auto_gitignore());
183    }
184
185    #[test]
186    fn test_set_scope() {
187        let mut config = IgnoreConfig::none();
188        config.set_scope(IgnoreScope::Enforced);
189        assert_eq!(config.scope(), IgnoreScope::Enforced);
190    }
191
192    #[test]
193    fn test_defaults_toggle() {
194        let mut config = IgnoreConfig::none();
195        config.set_defaults(true);
196        assert!(config.is_active());
197        config.set_defaults(false);
198        assert!(!config.is_active());
199    }
200
201    #[test]
202    fn test_auto_gitignore_alone_is_active() {
203        let mut config = IgnoreConfig::none();
204        assert!(!config.is_active());
205        config.set_auto_gitignore(true);
206        assert!(config.is_active());
207    }
208
209    mod async_tests {
210        use super::*;
211        use crate::walker::{WalkerDirEntry, WalkerError, WalkerFs};
212        use std::collections::HashMap;
213        use std::path::PathBuf;
214
215        struct MemEntry;
216        impl WalkerDirEntry for MemEntry {
217            fn name(&self) -> &str { "" }
218            fn is_dir(&self) -> bool { false }
219            fn is_file(&self) -> bool { true }
220            fn is_symlink(&self) -> bool { false }
221        }
222
223        struct FakeFs(HashMap<PathBuf, Vec<u8>>);
224
225        #[async_trait::async_trait]
226        impl WalkerFs for FakeFs {
227            type DirEntry = MemEntry;
228            async fn list_dir(&self, _: &Path) -> Result<Vec<MemEntry>, WalkerError> {
229                Ok(vec![])
230            }
231            async fn read_file(&self, path: &Path) -> Result<Vec<u8>, WalkerError> {
232                self.0.get(path)
233                    .cloned()
234                    .ok_or_else(|| WalkerError::NotFound(path.display().to_string()))
235            }
236            async fn is_dir(&self, _: &Path) -> bool { false }
237            async fn exists(&self, path: &Path) -> bool { self.0.contains_key(path) }
238        }
239
240        #[tokio::test]
241        async fn test_build_filter_none_returns_none() {
242            let config = IgnoreConfig::none();
243            let fs = FakeFs(HashMap::new());
244            assert!(config.build_filter(Path::new("/"), &fs).await.is_none());
245        }
246
247        #[tokio::test]
248        async fn test_build_filter_defaults_returns_some() {
249            let mut config = IgnoreConfig::none();
250            config.set_defaults(true);
251            let fs = FakeFs(HashMap::new());
252
253            let filter = config.build_filter(Path::new("/"), &fs).await;
254            assert!(filter.is_some());
255            let filter = filter.unwrap();
256            // Default filter should ignore target/ and node_modules/
257            assert!(filter.is_name_ignored("target", true));
258            assert!(filter.is_name_ignored("node_modules", true));
259            assert!(!filter.is_name_ignored("src", true));
260        }
261
262        #[tokio::test]
263        async fn test_build_filter_loads_gitignore() {
264            let mut config = IgnoreConfig::none();
265            config.add_file(".gitignore");
266
267            let mut files = HashMap::new();
268            files.insert(PathBuf::from("/project/.gitignore"), b"*.log\nbuild/\n".to_vec());
269            let fs = FakeFs(files);
270
271            let filter = config.build_filter(Path::new("/project"), &fs).await;
272            assert!(filter.is_some());
273            let filter = filter.unwrap();
274            assert!(filter.is_name_ignored("debug.log", false));
275            assert!(filter.is_name_ignored("build", true));
276            assert!(!filter.is_name_ignored("src", true));
277        }
278
279        #[tokio::test]
280        async fn test_build_filter_missing_file_skipped() {
281            let mut config = IgnoreConfig::none();
282            config.add_file(".gitignore");
283            config.add_file(".nonexistent");
284
285            let mut files = HashMap::new();
286            files.insert(PathBuf::from("/root/.gitignore"), b"*.tmp\n".to_vec());
287            let fs = FakeFs(files);
288
289            // Should not error — missing .nonexistent is silently skipped
290            let filter = config.build_filter(Path::new("/root"), &fs).await;
291            assert!(filter.is_some());
292            let filter = filter.unwrap();
293            assert!(filter.is_name_ignored("test.tmp", false));
294        }
295
296        #[tokio::test]
297        async fn test_build_filter_defaults_plus_gitignore_merged() {
298            let config = IgnoreConfig::mcp();
299
300            let mut files = HashMap::new();
301            files.insert(PathBuf::from("/project/.gitignore"), b"*.secret\n".to_vec());
302            let fs = FakeFs(files);
303
304            let filter = config.build_filter(Path::new("/project"), &fs).await;
305            assert!(filter.is_some());
306            let filter = filter.unwrap();
307            // Defaults
308            assert!(filter.is_name_ignored("target", true));
309            assert!(filter.is_name_ignored("node_modules", true));
310            // From .gitignore
311            assert!(filter.is_name_ignored("passwords.secret", false));
312            // Normal files pass through
313            assert!(!filter.is_name_ignored("main.rs", false));
314        }
315    }
316}