1use crate::error::Result;
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone, Default)]
18pub struct IgnoreSet {
19 patterns: Vec<Pattern>,
20 pub sources: Vec<PathBuf>,
22}
23
24#[derive(Debug, Clone)]
25struct Pattern {
26 body: String,
28 dir_only: bool,
29 anchored: bool,
30}
31
32impl IgnoreSet {
33 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 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 pub fn skip_dir_name(&self, name: &str) -> bool {
79 if name == "." || name == ".." {
80 return true;
81 }
82 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 return true;
101 }
102 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 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 }
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 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 if let Some(idx) = rel.rfind('/') {
164 return glob_single_segment_path(&self.body, &rel[idx + 1..]);
165 }
166 return false;
167 }
168
169 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 if rel == self.body || rel.ends_with(&format!("/{}", self.body)) {
179 return true;
180 }
181 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 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 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 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 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 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 let remaining = &rel[cursor..];
266 if part.contains('*') {
267 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
301pub 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
324pub 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 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}