Skip to main content

rustbrain_core/
ignore.rs

1//! Workspace ignore patterns for indexing (`.rustbrainignore` + optional `.gitignore`).
2//!
3//! Patterns use a simple gitignore-inspired dialect sufficient for v0.2:
4//! - `#` comments and blank lines ignored
5//! - trailing `/` matches directories only (prefix of path segments)
6//! - leading `/` anchors to workspace root
7//! - `*` matches within a single path segment (no `/`)
8//! - `**` matches across segments
9//! - bare names match any path segment equal to the name
10//!
11//! Built-in defaults always apply: `target/`, `.git/`, `.brain/`, `node_modules/`, etc.
12
13use crate::error::Result;
14use std::path::{Path, PathBuf};
15
16/// Compiled ignore set for a workspace root.
17#[derive(Debug, Clone, Default)]
18pub struct IgnoreSet {
19    patterns: Vec<Pattern>,
20    /// Paths of ignore files that were loaded (for doctor / bootstrap reports).
21    pub sources: Vec<PathBuf>,
22}
23
24#[derive(Debug, Clone)]
25struct Pattern {
26    /// Normalized pattern without leading `/` or trailing `/`.
27    body: String,
28    dir_only: bool,
29    anchored: bool,
30}
31
32impl IgnoreSet {
33    /// Load ignore rules for `workspace`.
34    ///
35    /// Always includes built-in defaults. Then loads `.rustbrainignore` if present.
36    /// If `include_gitignore` is true and `.gitignore` exists, its patterns are appended.
37    pub fn load(workspace: &Path, include_gitignore: bool) -> Result<Self> {
38        let mut set = Self::default();
39        for line in BUILTIN_PATTERNS {
40            set.push_line(line);
41        }
42
43        let rb = workspace.join(".rustbrainignore");
44        if rb.is_file() {
45            set.load_file(&rb)?;
46            set.sources.push(rb);
47        }
48
49        if include_gitignore {
50            let gi = workspace.join(".gitignore");
51            if gi.is_file() {
52                set.load_file(&gi)?;
53                set.sources.push(gi);
54            }
55        }
56
57        Ok(set)
58    }
59
60    /// Whether a **relative** path (posix `/` separators preferred) should be skipped.
61    ///
62    /// `is_dir` should be true when `rel` names a directory.
63    pub fn is_ignored(&self, rel: &str, is_dir: bool) -> bool {
64        let rel = rel.trim_matches('/');
65        if rel.is_empty() {
66            return false;
67        }
68        let rel_slash = rel.replace('\\', "/");
69        for p in &self.patterns {
70            if p.matches(&rel_slash, is_dir) {
71                return true;
72            }
73        }
74        false
75    }
76
77    /// Whether a directory name (single segment) should be skipped during walk.
78    pub fn skip_dir_name(&self, name: &str) -> bool {
79        if name == "." || name == ".." {
80            return true;
81        }
82        // Fast path built-ins
83        if matches!(
84            name,
85            "target"
86                | "node_modules"
87                | "vendor"
88                | ".git"
89                | ".brain"
90                | "dist"
91                | "build"
92                | ".svn"
93                | ".hg"
94                | ".direnv"
95                | "result"
96        ) || name.starts_with('.')
97        {
98            // Still allow going into dirs that are only ignored via custom rules differently —
99            // for hidden dirs we skip by default (same as pre-0.2 indexer).
100            return true;
101        }
102        // Check patterns against "name" and "name/" as directory
103        self.is_ignored(name, true)
104    }
105
106    fn load_file(&mut self, path: &Path) -> Result<()> {
107        let text = std::fs::read_to_string(path)?;
108        for line in text.lines() {
109            self.push_line(line);
110        }
111        Ok(())
112    }
113
114    fn push_line(&mut self, line: &str) {
115        let line = line.trim();
116        if line.is_empty() || line.starts_with('#') {
117            return;
118        }
119        // Negation not supported in v0.2 — skip.
120        if line.starts_with('!') {
121            return;
122        }
123        let anchored = line.starts_with('/');
124        let dir_only = line.ends_with('/');
125        let mut body = line.trim_start_matches('/').trim_end_matches('/').to_string();
126        body = body.replace('\\', "/");
127        if body.is_empty() {
128            return;
129        }
130        self.patterns.push(Pattern {
131            body,
132            dir_only,
133            anchored,
134        });
135    }
136}
137
138impl Pattern {
139    fn matches(&self, rel: &str, is_dir: bool) -> bool {
140        if self.dir_only && !is_dir {
141            // Still match if any path prefix is this directory.
142            // e.g. pattern `target/` matches file `target/foo.rlib` via prefix.
143        }
144
145        if self.body.contains("**") {
146            return glob_double_star(&self.body, rel, self.anchored);
147        }
148
149        if self.body.contains('*') {
150            if self.anchored {
151                return glob_single_segment_path(&self.body, rel);
152            }
153            // Unanchored: match any path suffix or segment
154            if glob_single_segment_path(&self.body, rel) {
155                return true;
156            }
157            for part in rel.split('/') {
158                if glob_segment(&self.body, part) {
159                    return true;
160                }
161            }
162            // also match full relative with pattern as suffix path
163            if let Some(idx) = rel.rfind('/') {
164                return glob_single_segment_path(&self.body, &rel[idx + 1..]);
165            }
166            return false;
167        }
168
169        // Literal
170        if self.anchored {
171            if self.dir_only {
172                return rel == self.body || rel.starts_with(&format!("{}/", self.body));
173            }
174            return rel == self.body;
175        }
176
177        // Unanchored literal: any segment equals body, or path ends with /body
178        if rel == self.body || rel.ends_with(&format!("/{}", self.body)) {
179            return true;
180        }
181        // directory prefix anywhere
182        if rel.starts_with(&format!("{}/", self.body)) {
183            return true;
184        }
185        if rel.contains(&format!("/{}/", self.body)) {
186            return true;
187        }
188        rel.split('/').any(|s| s == self.body)
189    }
190}
191
192fn glob_segment(pat: &str, seg: &str) -> bool {
193    // only * wildcards, no ?
194    let parts: Vec<&str> = pat.split('*').collect();
195    if parts.len() == 1 {
196        return pat == seg;
197    }
198    if !seg.starts_with(parts[0]) {
199        return false;
200    }
201    if !seg.ends_with(parts[parts.len() - 1]) {
202        return false;
203    }
204    let mut rest = &seg[parts[0].len()..seg.len() - parts[parts.len() - 1].len()];
205    for mid in &parts[1..parts.len() - 1] {
206        if mid.is_empty() {
207            continue;
208        }
209        if let Some(pos) = rest.find(mid) {
210            rest = &rest[pos + mid.len()..];
211        } else {
212            return false;
213        }
214    }
215    true
216}
217
218fn glob_single_segment_path(pat: &str, rel: &str) -> bool {
219    // Pattern may include `/` for multi-segment without `**`
220    let pat_parts: Vec<&str> = pat.split('/').collect();
221    let rel_parts: Vec<&str> = rel.split('/').collect();
222    if pat_parts.len() != rel_parts.len() {
223        // allow pattern match against suffix of equal length
224        if rel_parts.len() < pat_parts.len() {
225            return false;
226        }
227        let start = rel_parts.len() - pat_parts.len();
228        return pat_parts
229            .iter()
230            .zip(rel_parts[start..].iter())
231            .all(|(p, r)| glob_segment(p, r));
232    }
233    pat_parts
234        .iter()
235        .zip(rel_parts.iter())
236        .all(|(p, r)| glob_segment(p, r))
237}
238
239fn glob_double_star(pat: &str, rel: &str, anchored: bool) -> bool {
240    // Very small ** implementation: split on ** and check sequential find.
241    let parts: Vec<&str> = pat.split("**").map(|s| s.trim_matches('/')).collect();
242    if parts.is_empty() {
243        return true;
244    }
245    let mut cursor = 0usize;
246    let rel_bytes = rel.as_bytes();
247    for (i, part) in parts.iter().enumerate() {
248        if part.is_empty() {
249            continue;
250        }
251        // For first part with anchored, must be prefix
252        if i == 0 && anchored && !part.contains('*') {
253            if !rel.starts_with(part)
254                && !rel.starts_with(&format!("{part}/"))
255            {
256                return false;
257            }
258            cursor = part.len().min(rel.len());
259            if cursor < rel.len() && rel.as_bytes()[cursor] == b'/' {
260                cursor += 1;
261            }
262            continue;
263        }
264        // Find part in remaining (segment-aware for pure literals)
265        let remaining = &rel[cursor..];
266        if part.contains('*') {
267            // fallback: substring after simplifying
268            if let Some(pos) = remaining.find(part.trim_matches('*')) {
269                cursor += pos + part.trim_matches('*').len();
270            } else {
271                return false;
272            }
273        } else if let Some(pos) = remaining.find(part) {
274            cursor += pos + part.len();
275        } else {
276            return false;
277        }
278        let _ = rel_bytes;
279    }
280    true
281}
282
283const BUILTIN_PATTERNS: &[&str] = &[
284    "target/",
285    "node_modules/",
286    "vendor/",
287    ".git/",
288    ".brain/",
289    "dist/",
290    "build/",
291    ".svn/",
292    ".hg/",
293    ".direnv/",
294    "result/",
295    "*.sqlite",
296    "*.sqlite-journal",
297    "*.sqlite-wal",
298    "*.sqlite-shm",
299];
300
301/// Default recommended extra patterns for a new `.rustbrainignore`.
302pub fn recommended_ignore_extras() -> &'static [&'static str] {
303    &[
304        "# Build / IDE",
305        "target/",
306        ".idea/",
307        ".vscode/",
308        "*.swp",
309        ".DS_Store",
310        "",
311        "# Derived data / large blobs",
312        "*.parquet",
313        "*.arrow",
314        "data/",
315        "datasets/",
316        "",
317        "# Logs and local env",
318        "*.log",
319        ".env",
320        ".env.*",
321    ]
322}
323
324/// Write a `.rustbrainignore` file.
325pub fn write_rustbrainignore(workspace: &Path, import_gitignore: bool, extras: &[&str]) -> Result<PathBuf> {
326    let path = workspace.join(".rustbrainignore");
327    let mut out = String::from(
328        "# rustbrain index ignore (gitignore-inspired)\n\
329         # Built-in defaults always apply; this file adds more skips.\n\n",
330    );
331
332    if import_gitignore {
333        let gi = workspace.join(".gitignore");
334        if gi.is_file() {
335            out.push_str("# --- imported from .gitignore ---\n");
336            out.push_str(&std::fs::read_to_string(&gi)?);
337            if !out.ends_with('\n') {
338                out.push('\n');
339            }
340            out.push_str("# --- end import ---\n\n");
341        }
342    }
343
344    for line in extras {
345        out.push_str(line);
346        out.push('\n');
347    }
348
349    // Don't overwrite silently if exists — caller should decide.
350    std::fs::write(&path, out)?;
351    Ok(path)
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use tempfile::tempdir;
358
359    #[test]
360    fn builtin_skips_target() {
361        let dir = tempdir().unwrap();
362        let set = IgnoreSet::load(dir.path(), false).unwrap();
363        assert!(set.is_ignored("target", true));
364        assert!(set.is_ignored("target/debug/foo", false));
365        assert!(set.skip_dir_name("target"));
366        assert!(!set.is_ignored("src/main.rs", false));
367    }
368
369    #[test]
370    fn custom_pattern_file() {
371        let dir = tempdir().unwrap();
372        std::fs::write(
373            dir.path().join(".rustbrainignore"),
374            "data/\n*.parquet\n",
375        )
376        .unwrap();
377        let set = IgnoreSet::load(dir.path(), false).unwrap();
378        assert!(set.is_ignored("data/x", false));
379        assert!(set.is_ignored("foo.parquet", false));
380        assert!(!set.is_ignored("src/lib.rs", false));
381    }
382}