Skip to main content

codesynapse_core/
detect.rs

1use crate::error::Result;
2use crate::types::FileType;
3use ignore::gitignore::{Gitignore, GitignoreBuilder};
4use regex::Regex;
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::sync::LazyLock;
8use walkdir::WalkDir;
9
10// ---------------------------------------------------------------------------
11// Extension whitelists (mirrors codesynapse/detect.py)
12// ---------------------------------------------------------------------------
13
14const CODE_EXTENSIONS: &[&str] = &[
15    ".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".ejs", ".ets", ".go", ".rs", ".java", ".groovy",
16    ".gradle", ".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", ".rb", ".swift", ".kt", ".kts", ".cs",
17    ".scala", ".php", ".lua", ".luau", ".toc", ".zig", ".ps1", ".ex", ".exs", ".m", ".mm", ".jl",
18    ".vue", ".svelte", ".astro", ".dart", ".v", ".sv", ".svh", ".sql", ".r", ".f", ".F", ".f90",
19    ".F90", ".f95", ".F95", ".f03", ".F03", ".f08", ".F08", ".pas", ".pp", ".dpr", ".dpk", ".lpr",
20    ".inc", ".dfm", ".lfm", ".lpk", ".sh", ".bash", ".json", ".dm", ".dme", ".dmi", ".dmm", ".dmf",
21    ".sln", ".csproj", ".fsproj", ".vbproj", ".razor", ".cshtml", ".yml", ".yaml",
22];
23
24const DOC_EXTENSIONS: &[&str] = &[".md", ".mdx", ".qmd", ".txt", ".rst"];
25
26const IMAGE_EXTENSIONS: &[&str] = &[
27    ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico", ".bmp",
28];
29
30const VIDEO_EXTENSIONS: &[&str] = &[
31    ".mp4", ".mp3", ".mov", ".wav", ".webm", ".m4a", ".avi", ".mkv", ".flac", ".ogg", ".opus",
32];
33
34const OFFICE_EXTENSIONS: &[&str] = &[".docx", ".xlsx"];
35
36const WORKSPACE_EXTENSIONS: &[&str] = &[".gdoc", ".gsheet", ".gslides", ".gform"];
37
38// ---------------------------------------------------------------------------
39// Hard-coded skip lists (mirrors _SKIP_DIRS / _SKIP_FILES, minus "build")
40// ---------------------------------------------------------------------------
41
42const SKIP_DIRS: &[&str] = &[
43    "venv",
44    ".venv",
45    "env",
46    ".env",
47    "node_modules",
48    "__pycache__",
49    ".git",
50    "dist",
51    "target",
52    "out",
53    "site-packages",
54    "lib64",
55    ".pytest_cache",
56    ".mypy_cache",
57    ".ruff_cache",
58    ".tox",
59    ".eggs",
60    "codesynapse-out",
61    "coverage",
62    "lcov-report",
63    "visual-tests",
64    "visual-test",
65    "__snapshots__",
66    "snapshots",
67    "storybook-static",
68    "dist-protected",
69    ".next",
70    ".nuxt",
71    ".turbo",
72    ".angular",
73    ".idea",
74    ".cache",
75    ".parcel-cache",
76    ".svelte-kit",
77    ".terraform",
78    ".serverless",
79    ".codesynapse",
80    ".worktrees",
81    "worktrees",
82];
83
84const SKIP_FILES: &[&str] = &[
85    "package-lock.json",
86    "yarn.lock",
87    "pnpm-lock.yaml",
88    "Cargo.lock",
89    "poetry.lock",
90    "Gemfile.lock",
91    "composer.lock",
92    "go.sum",
93    "go.work.sum",
94];
95
96// ---------------------------------------------------------------------------
97// Paper signal heuristics (mirrors _PAPER_SIGNALS)
98// ---------------------------------------------------------------------------
99
100static PAPER_COMPILED: LazyLock<Vec<Regex>> = LazyLock::new(|| {
101    vec![
102        Regex::new(r"(?i)\barxiv\b").unwrap(),
103        Regex::new(r"(?i)\bdoi\s*:").unwrap(),
104        Regex::new(r"(?i)\babstract\b").unwrap(),
105        Regex::new(r"(?i)\bproceedings\b").unwrap(),
106        Regex::new(r"(?i)\bjournal\b").unwrap(),
107        Regex::new(r"(?i)\bpreprint\b").unwrap(),
108        Regex::new(r"\\cite\{").unwrap(),
109        Regex::new(r"\[\d+\]").unwrap(),
110        Regex::new(r"(?i)eq\.\s*\d+|equation\s+\d+").unwrap(),
111        Regex::new(r"\d{4}\.\d{4,5}").unwrap(),
112        Regex::new(r"(?i)\bwe propose\b").unwrap(),
113        Regex::new(r"(?i)\bliterature\b").unwrap(),
114    ]
115});
116
117const PAPER_SIGNAL_THRESHOLD: usize = 3;
118
119static ASSET_DIR_MARKERS: LazyLock<Vec<&str>> = LazyLock::new(|| {
120    vec![
121        ".imageset",
122        ".xcassets",
123        ".appiconset",
124        ".colorset",
125        ".launchimage",
126    ]
127});
128
129// ---------------------------------------------------------------------------
130// Sensitive file patterns (mirrors _SENSITIVE_PATTERNS + _SENSITIVE_DIRS)
131// ---------------------------------------------------------------------------
132
133const SENSITIVE_DIRS: &[&str] = &[
134    ".ssh",
135    ".gnupg",
136    ".aws",
137    ".gcloud",
138    "secrets",
139    ".secrets",
140    "credentials",
141];
142
143static SENSITIVE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
144    vec![
145        Regex::new(r"^\.(env|envrc)(\.|$)").unwrap(),
146        Regex::new(r"\.(pem|key|p12|pfx|cert|crt|der|p8)$").unwrap(),
147        Regex::new(r"(?i)(^|[._-])(credential|secret|passwd|password|private_key)s?([._-]|$)")
148            .unwrap(),
149        Regex::new(r"(?i)(^|[._-])tokens?([._-]|$)").unwrap(),
150        Regex::new(r"(id_rsa|id_dsa|id_ecdsa|id_ed25519)(\.pub)?$").unwrap(),
151        Regex::new(r"(\.netrc|\.pgpass|\.htpasswd)$").unwrap(),
152        Regex::new(r"(?i)(aws_credentials|gcloud_credentials|service\.account)").unwrap(),
153    ]
154});
155
156// ---------------------------------------------------------------------------
157// Shebang code interpreters (mirrors _SHEBANG_CODE_INTERPRETERS)
158// ---------------------------------------------------------------------------
159
160const SHEBANG_CODE_INTERPRETERS: &[&str] = &[
161    "python", "python3", "python2", "ruby", "perl", "node", "nodejs", "bash", "sh", "dash", "zsh",
162    "fish", "ksh", "tcsh", "lua", "php", "julia", "Rscript",
163];
164
165// ---------------------------------------------------------------------------
166// VCS root markers
167// ---------------------------------------------------------------------------
168
169const VCS_MARKERS: &[&str] = &[".git", ".hg", ".svn", "_darcs", ".fossil"];
170
171// needs_graph threshold: corpus must have this many files to be graph-worthy
172const NEEDS_GRAPH_MIN: usize = 5;
173
174// ---------------------------------------------------------------------------
175// DiscoveredFile
176// ---------------------------------------------------------------------------
177
178#[derive(Debug, Clone)]
179pub struct DiscoveredFile {
180    pub path: PathBuf,
181    pub file_type: FileType,
182    pub relative_path: String,
183}
184
185// ---------------------------------------------------------------------------
186// DetectResult
187// ---------------------------------------------------------------------------
188
189#[derive(Debug, Clone)]
190pub struct DetectResult {
191    pub files: HashMap<String, Vec<String>>,
192    pub total_files: usize,
193    pub total_words: usize,
194    pub needs_graph: bool,
195    pub warning: Option<String>,
196    pub codesynapseignore_patterns: usize,
197    pub skipped_sensitive: Vec<String>,
198}
199
200// ---------------------------------------------------------------------------
201// Detector
202// ---------------------------------------------------------------------------
203
204pub struct Detector {
205    pub follow_symlinks: bool,
206    ignore: Gitignore,
207    pub codesynapseignore_patterns: usize,
208}
209
210impl Detector {
211    pub fn new(root: &Path) -> Self {
212        Self::new_with_excludes(root, &[])
213    }
214
215    pub fn new_with_excludes(root: &Path, extra_excludes: &[&str]) -> Self {
216        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
217        let (ignore, codesynapseignore_patterns) = load_ignore_patterns(&root, extra_excludes);
218        Detector {
219            follow_symlinks: false,
220            ignore,
221            codesynapseignore_patterns,
222        }
223    }
224
225    pub fn discover(&self, root: &Path) -> Result<Vec<DiscoveredFile>> {
226        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
227        let mut files = Vec::new();
228        let mut seen = std::collections::HashSet::new();
229
230        let walk = WalkDir::new(&root)
231            .follow_links(self.follow_symlinks)
232            .sort_by(|a, b| a.file_name().cmp(b.file_name()));
233
234        for entry in walk
235            .into_iter()
236            .filter_entry(|e| self.filter_entry(e, &root))
237        {
238            let entry = match entry {
239                Ok(e) => e,
240                Err(_) => continue,
241            };
242            if !entry.file_type().is_file() {
243                continue;
244            }
245            let path = entry.path().to_path_buf();
246
247            if !seen.insert(path.clone()) {
248                continue;
249            }
250
251            let name = path
252                .file_name()
253                .unwrap_or_default()
254                .to_string_lossy()
255                .to_lowercase();
256            if SKIP_FILES.contains(&name.as_str()) {
257                continue;
258            }
259
260            if is_sensitive(&path) {
261                continue;
262            }
263
264            if self.ignore.matched(&path, false).is_ignore() {
265                continue;
266            }
267
268            let Some(file_type) = classify_file(&path) else {
269                continue;
270            };
271
272            let relative = path
273                .strip_prefix(&root)
274                .unwrap_or(&path)
275                .to_string_lossy()
276                .to_string();
277            files.push(DiscoveredFile {
278                path,
279                file_type,
280                relative_path: relative,
281            });
282        }
283
284        Ok(files)
285    }
286
287    fn filter_entry(&self, entry: &walkdir::DirEntry, _root: &Path) -> bool {
288        let name = entry.file_name().to_string_lossy();
289        if entry.file_type().is_dir() {
290            if is_noise_dir(&name, Some(entry.path())) {
291                return false;
292            }
293            if entry.depth() > 0 && entry.path().join(".git").exists() {
294                return false;
295            }
296            if self.ignore.matched(entry.path(), true).is_ignore() {
297                return false;
298            }
299            true
300        } else {
301            true
302        }
303    }
304
305    pub fn detect_incremental(
306        &self,
307        root: &Path,
308        previous_manifest: &[String],
309    ) -> Result<Vec<DiscoveredFile>> {
310        let all = self.discover(root)?;
311        if previous_manifest.is_empty() {
312            return Ok(all);
313        }
314        let previous: std::collections::HashSet<&str> =
315            previous_manifest.iter().map(|s| s.as_str()).collect();
316        Ok(all
317            .into_iter()
318            .filter(|f| !previous.contains(f.relative_path.as_str()))
319            .collect())
320    }
321}
322
323impl Default for Detector {
324    fn default() -> Self {
325        let root = Path::new(".");
326        let (ignore, codesynapseignore_patterns) = load_ignore_patterns(root, &[]);
327        Detector {
328            follow_symlinks: false,
329            ignore,
330            codesynapseignore_patterns,
331        }
332    }
333}
334
335// ---------------------------------------------------------------------------
336// detect() — high-level function returning DetectResult
337// ---------------------------------------------------------------------------
338
339pub fn detect(root: &Path, follow_symlinks: Option<bool>, extra_excludes: &[&str]) -> DetectResult {
340    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
341
342    let fs = match follow_symlinks {
343        Some(v) => v,
344        None => auto_detect_follow_symlinks(&root),
345    };
346
347    let mut detector = Detector::new_with_excludes(&root, extra_excludes);
348    detector.follow_symlinks = fs;
349
350    let discovered = detector.discover(&root).unwrap_or_default();
351    let codesynapseignore_patterns = detector.codesynapseignore_patterns;
352
353    let mut files: HashMap<String, Vec<String>> = HashMap::new();
354    for key in &["code", "document", "paper", "image", "video"] {
355        files.insert(key.to_string(), Vec::new());
356    }
357
358    let mut total_words = 0usize;
359    let mut skipped_sensitive = Vec::new();
360
361    for f in discovered {
362        let path_str = f.path.to_string_lossy().to_string();
363
364        if is_workspace_shortcut(&f.path) {
365            skipped_sensitive.push(format!("Google Workspace shortcut skipped: {}", path_str));
366            continue;
367        }
368
369        let key = match f.file_type {
370            FileType::Code => "code",
371            FileType::Document => "document",
372            FileType::Paper => "paper",
373            FileType::Image => "image",
374            FileType::Video => "video",
375            _ => continue,
376        };
377
378        if matches!(
379            f.file_type,
380            FileType::Code | FileType::Document | FileType::Paper
381        ) {
382            total_words += count_words(&f.path);
383        }
384
385        files.get_mut(key).unwrap().push(path_str);
386    }
387
388    let total_files: usize = files.values().map(|v| v.len()).sum();
389    let needs_graph = total_files >= NEEDS_GRAPH_MIN;
390    let warning = if !needs_graph {
391        Some(format!(
392            "Corpus has only {} file(s) — a graph may not be meaningful. Add more source files.",
393            total_files
394        ))
395    } else {
396        None
397    };
398
399    DetectResult {
400        files,
401        total_files,
402        total_words,
403        needs_graph,
404        warning,
405        codesynapseignore_patterns,
406        skipped_sensitive,
407    }
408}
409
410fn auto_detect_follow_symlinks(root: &Path) -> bool {
411    if let Ok(entries) = std::fs::read_dir(root) {
412        for entry in entries.flatten() {
413            if let Ok(meta) = entry.path().symlink_metadata() {
414                if meta.file_type().is_symlink() {
415                    if let Ok(target) = std::fs::metadata(entry.path()) {
416                        if target.is_dir() {
417                            return true;
418                        }
419                    }
420                }
421            }
422        }
423    }
424    false
425}
426
427// ---------------------------------------------------------------------------
428// count_words
429// ---------------------------------------------------------------------------
430
431pub fn count_words(path: &Path) -> usize {
432    std::fs::read_to_string(path)
433        .map(|s| s.split_whitespace().count())
434        .unwrap_or(0)
435}
436
437// ---------------------------------------------------------------------------
438// Ignore-pattern loading
439// ---------------------------------------------------------------------------
440
441fn find_vcs_root(start: &Path) -> Option<PathBuf> {
442    let start = start.canonicalize().ok()?;
443    let home = dirs_home();
444    let mut current = Some(start.as_path());
445    while let Some(dir) = current {
446        for marker in VCS_MARKERS {
447            if dir.join(marker).exists() {
448                return Some(dir.to_path_buf());
449            }
450        }
451        let parent = dir.parent()?;
452        if parent == dir || Some(parent) == home.as_deref() {
453            return None;
454        }
455        current = Some(parent);
456    }
457    None
458}
459
460fn dirs_home() -> Option<PathBuf> {
461    std::env::var_os("HOME").map(PathBuf::from)
462}
463
464fn path_ancestors(from: &Path, to: &Path) -> Vec<PathBuf> {
465    let mut dirs: Vec<PathBuf> = Vec::new();
466    let mut current = Some(to.to_path_buf());
467    let ceiling = from.to_path_buf();
468    while let Some(d) = current {
469        dirs.push(d.clone());
470        if d == ceiling {
471            break;
472        }
473        current = d.parent().map(|p| p.to_path_buf());
474    }
475    dirs.reverse();
476    dirs
477}
478
479fn load_ignore_patterns(root: &Path, extra_excludes: &[&str]) -> (Gitignore, usize) {
480    let ceiling = find_vcs_root(root).unwrap_or_else(|| root.to_path_buf());
481    let mut builder = GitignoreBuilder::new(&ceiling);
482    let mut codesynapseignore_count = 0usize;
483    let mut found_codesynapseignore = false;
484
485    for ancestor in path_ancestors(&ceiling, root) {
486        let ignore_file = ancestor.join(".codesynapseignore");
487        if ignore_file.exists() {
488            found_codesynapseignore = true;
489            if let Ok(content) = std::fs::read_to_string(&ignore_file) {
490                codesynapseignore_count += content
491                    .lines()
492                    .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
493                    .count();
494            }
495            let _ = builder.add(&ignore_file);
496        } else if !found_codesynapseignore {
497            let gitignore_file = ancestor.join(".gitignore");
498            if gitignore_file.exists() {
499                let _ = builder.add(&gitignore_file);
500            }
501        }
502    }
503
504    for pattern in extra_excludes {
505        let _ = builder.add_line(None, pattern);
506    }
507
508    let ignore = builder
509        .build()
510        .unwrap_or_else(|_| GitignoreBuilder::new(root).build().unwrap());
511    (ignore, codesynapseignore_count)
512}
513
514// ---------------------------------------------------------------------------
515// Noise / sensitive helpers
516// ---------------------------------------------------------------------------
517
518fn is_noise_dir(name: &str, path: Option<&std::path::Path>) -> bool {
519    let lower = name.to_lowercase();
520    if lower.ends_with("_venv") || lower.ends_with("_env") || lower.ends_with(".egg-info") {
521        return true;
522    }
523    // "target" and "out" are legitimate Java package name segments under
524    // src/{main,test}/java — don't skip them in that context.
525    if lower == "target" || lower == "out" {
526        if let Some(p) = path {
527            let s = p.to_string_lossy();
528            if s.contains("src/main/java") || s.contains("src/test/java") {
529                return false;
530            }
531        }
532    }
533    SKIP_DIRS.contains(&lower.as_str())
534}
535
536pub fn is_sensitive(path: &Path) -> bool {
537    // Stage 1: any PARENT directory (not the file itself) is a known sensitive dir
538    if let Some(parents) = path.parent() {
539        for part in parents.components() {
540            if let std::path::Component::Normal(name) = part {
541                let name = name.to_string_lossy();
542                if SENSITIVE_DIRS.contains(&name.as_ref()) {
543                    return true;
544                }
545            }
546        }
547    }
548    // Stage 2: filename pattern match
549    let name = path.file_name().unwrap_or_default().to_string_lossy();
550    SENSITIVE_PATTERNS.iter().any(|re| re.is_match(&name))
551}
552
553pub fn is_workspace_shortcut(path: &Path) -> bool {
554    let ext = path
555        .extension()
556        .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
557        .unwrap_or_default();
558    WORKSPACE_EXTENSIONS.contains(&ext.as_str())
559}
560
561// ---------------------------------------------------------------------------
562// File classification
563// ---------------------------------------------------------------------------
564
565pub fn classify_file(path: &Path) -> Option<FileType> {
566    let name = path
567        .file_name()
568        .unwrap_or_default()
569        .to_string_lossy()
570        .to_lowercase();
571
572    if name.ends_with(".blade.php") {
573        return Some(FileType::Code);
574    }
575
576    let ext = path
577        .extension()
578        .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
579        .unwrap_or_default();
580
581    if ext.is_empty() {
582        return shebang_file_type(path);
583    }
584
585    if CODE_EXTENSIONS.contains(&ext.as_str()) {
586        return Some(FileType::Code);
587    }
588
589    if IMAGE_EXTENSIONS.contains(&ext.as_str()) {
590        return Some(FileType::Image);
591    }
592
593    if VIDEO_EXTENSIONS.contains(&ext.as_str()) {
594        return Some(FileType::Video);
595    }
596
597    if WORKSPACE_EXTENSIONS.contains(&ext.as_str()) {
598        return Some(FileType::Document);
599    }
600
601    if ext == ".pdf" {
602        if let Some(parents) = path.parent() {
603            for part in parents.components() {
604                if let std::path::Component::Normal(name) = part {
605                    let name = name.to_string_lossy();
606                    if ASSET_DIR_MARKERS.iter().any(|m| name.ends_with(m)) {
607                        return None;
608                    }
609                }
610            }
611        }
612        return Some(FileType::Paper);
613    }
614
615    if DOC_EXTENSIONS.contains(&ext.as_str()) {
616        if looks_like_paper(path) {
617            return Some(FileType::Paper);
618        }
619        return Some(FileType::Document);
620    }
621
622    if OFFICE_EXTENSIONS.contains(&ext.as_str()) {
623        return Some(FileType::Document);
624    }
625
626    None
627}
628
629fn looks_like_paper(path: &Path) -> bool {
630    let content = match std::fs::read_to_string(path) {
631        Ok(s) => s,
632        Err(_) => return false,
633    };
634    let mut byte_end = content.len().min(3000);
635    while byte_end > 0 && !content.is_char_boundary(byte_end) {
636        byte_end -= 1;
637    }
638    let head = &content[..byte_end];
639    let hits = PAPER_COMPILED.iter().filter(|re| re.is_match(head)).count();
640    hits >= PAPER_SIGNAL_THRESHOLD
641}
642
643pub fn shebang_interpreter(path: &Path) -> Option<String> {
644    let mut f = match std::fs::File::open(path) {
645        Ok(f) => f,
646        Err(_) => return None,
647    };
648    use std::io::Read;
649    let mut buf = [0u8; 256];
650    let n = f.read(&mut buf).ok()?;
651    let first = &buf[..n];
652    if !first.starts_with(b"#!") {
653        return None;
654    }
655    let line = match first.splitn(2, |b| *b == b'\n').next() {
656        Some(l) => String::from_utf8_lossy(l).to_string(),
657        None => return None,
658    };
659    let line = line[2..].trim().to_string();
660    let parts = shlex_split(&line);
661    let interp = Path::new(parts.first()?)
662        .file_name()?
663        .to_string_lossy()
664        .to_string();
665    if interp == "env" {
666        let argv = env_command_args(&parts[1..]);
667        Some(
668            Path::new(argv.first()?)
669                .file_name()?
670                .to_string_lossy()
671                .to_string(),
672        )
673    } else {
674        Some(interp)
675    }
676}
677
678fn shebang_file_type(path: &Path) -> Option<FileType> {
679    let interp = shebang_interpreter(path)?;
680    if SHEBANG_CODE_INTERPRETERS.contains(&interp.as_str()) {
681        Some(FileType::Code)
682    } else {
683        None
684    }
685}
686
687fn shlex_split(line: &str) -> Vec<String> {
688    let mut args = Vec::new();
689    let mut current = String::new();
690    let mut in_quote = false;
691    let mut quote_char = ' ';
692    let mut escaped = false;
693    let chars: Vec<char> = line.chars().collect();
694    let mut i = 0;
695    while i < chars.len() {
696        let c = chars[i];
697        if escaped {
698            current.push(c);
699            escaped = false;
700            i += 1;
701            continue;
702        }
703        if c == '\\' {
704            escaped = true;
705            i += 1;
706            continue;
707        }
708        if in_quote {
709            if c == quote_char {
710                in_quote = false;
711            } else {
712                current.push(c);
713            }
714            i += 1;
715            continue;
716        }
717        if c == '"' || c == '\'' {
718            in_quote = true;
719            quote_char = c;
720            i += 1;
721            continue;
722        }
723        if c.is_whitespace() {
724            if !current.is_empty() {
725                args.push(std::mem::take(&mut current));
726            }
727            i += 1;
728            continue;
729        }
730        current.push(c);
731        i += 1;
732    }
733    if !current.is_empty() {
734        args.push(current);
735    }
736    args
737}
738
739fn env_command_args(args: &[String]) -> Vec<String> {
740    env_command_args_inner(args, true)
741}
742
743fn env_command_args_inner(args: &[String], allow_split: bool) -> Vec<String> {
744    let mut i = 0;
745    'outer: while i < args.len() {
746        let arg = args[i].as_str();
747
748        // End-of-options marker
749        if arg == "--" {
750            return args[i + 1..].to_vec();
751        }
752
753        // Long options
754        if arg.starts_with("--") {
755            // --split-string with = inline operand
756            if let Some(payload) = arg.strip_prefix("--split-string=") {
757                if !allow_split {
758                    return vec![];
759                }
760                let sub = shlex_split(payload);
761                return env_command_args_inner(&sub, false);
762            }
763            // --split-string separate operand
764            if arg == "--split-string" {
765                if !allow_split {
766                    return vec![];
767                }
768                if i + 1 < args.len() {
769                    let packed = args[i + 1..].join(" ");
770                    let sub = shlex_split(&packed);
771                    return env_command_args_inner(&sub, false);
772                }
773                return vec![];
774            }
775
776            // No-operand long flags
777            if matches!(
778                arg,
779                "--ignore-environment" | "--null" | "--debug" | "--list-signal-handling"
780            ) {
781                i += 1;
782                continue;
783            }
784
785            // Signal-handling flags (always include = or standalone)
786            if arg.starts_with("--default-signal")
787                || arg.starts_with("--ignore-signal")
788                || arg.starts_with("--block-signal")
789            {
790                i += 1;
791                continue;
792            }
793
794            // Long flags with operand (separate or inline = form)
795            for &flag in &["--unset", "--chdir", "--argv0"] {
796                if arg == flag {
797                    i += 2;
798                    continue 'outer;
799                }
800                let flag_eq = flag.len();
801                if arg.len() > flag_eq && arg.starts_with(flag) && arg.as_bytes()[flag_eq] == b'=' {
802                    i += 1;
803                    continue 'outer;
804                }
805            }
806
807            // Unknown long flag
808            return vec![];
809        }
810
811        // Short option cluster (e.g. "-i", "-vS", "-uPATH")
812        if arg.starts_with('-') && arg.len() > 1 {
813            let cluster = &arg[1..];
814            let cluster_bytes = cluster.as_bytes();
815            let mut j = 0;
816
817            loop {
818                if j >= cluster_bytes.len() {
819                    i += 1;
820                    continue 'outer;
821                }
822                match cluster_bytes[j] as char {
823                    // No-operand short flags
824                    '-' | 'i' | '0' | 'v' => {
825                        j += 1;
826                    }
827                    // Short flags that take an operand
828                    'u' | 'C' | 'P' | 'a' => {
829                        if j + 1 < cluster_bytes.len() {
830                            // Clumped: -uVAR → operand is rest of cluster, skip 1 arg
831                            i += 1;
832                        } else {
833                            // Separate: skip flag arg + next arg (the operand)
834                            i += 2;
835                        }
836                        continue 'outer;
837                    }
838                    // -S / --split-string equivalent
839                    'S' => {
840                        if !allow_split {
841                            return vec![];
842                        }
843                        if j + 1 < cluster_bytes.len() {
844                            // Compact: -Spayload
845                            let payload = &cluster[j + 1..];
846                            let sub = shlex_split(payload);
847                            return env_command_args_inner(&sub, false);
848                        } else {
849                            // Separate: rest of args is the split-string payload
850                            if i + 1 < args.len() {
851                                let packed = args[i + 1..].join(" ");
852                                let sub = shlex_split(&packed);
853                                return env_command_args_inner(&sub, false);
854                            }
855                            return vec![];
856                        }
857                    }
858                    // Unknown short flag
859                    _ => {
860                        return vec![];
861                    }
862                }
863            }
864        }
865
866        // Single dash (equivalent to --ignore-environment)
867        if arg == "-" {
868            i += 1;
869            continue;
870        }
871
872        // Assignment NAME=VALUE
873        if arg.contains('=') {
874            i += 1;
875            continue;
876        }
877
878        // First non-option token is the interpreter (start of argv)
879        return args[i..].to_vec();
880    }
881    vec![]
882}
883
884// ---------------------------------------------------------------------------
885// Tests
886// ---------------------------------------------------------------------------
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891    use std::fs;
892
893    fn setup_test_dir(name: &str) -> PathBuf {
894        let dir = std::env::temp_dir().join(format!("codesynapse_detect_{name}"));
895        let _ = fs::remove_dir_all(&dir);
896        fs::create_dir_all(&dir).unwrap();
897        dir
898    }
899
900    fn teardown(dir: &Path) {
901        let _ = fs::remove_dir_all(dir);
902    }
903
904    // -----------------------------------------------------------------------
905    // classify_file — basic extension tests
906    // -----------------------------------------------------------------------
907
908    #[test]
909    fn test_classify_python() {
910        assert_eq!(classify_file(Path::new("foo.py")), Some(FileType::Code));
911    }
912
913    #[test]
914    fn test_classify_typescript() {
915        assert_eq!(classify_file(Path::new("bar.ts")), Some(FileType::Code));
916    }
917
918    #[test]
919    fn test_classify_markdown() {
920        assert_eq!(
921            classify_file(Path::new("README.md")),
922            Some(FileType::Document)
923        );
924    }
925
926    #[test]
927    fn test_classify_pdf() {
928        assert_eq!(classify_file(Path::new("paper.pdf")), Some(FileType::Paper));
929    }
930
931    #[test]
932    fn test_classify_pdf_in_xcassets_skipped() {
933        let p = Path::new("MyApp/Images.xcassets/icon.imageset/icon.pdf");
934        assert_eq!(classify_file(p), None);
935    }
936
937    #[test]
938    fn test_classify_pdf_in_xcassets_root_skipped() {
939        let p = Path::new("Pods/HXPHPicker/Assets.xcassets/photo.pdf");
940        assert_eq!(classify_file(p), None);
941    }
942
943    #[test]
944    fn test_classify_unknown_returns_none() {
945        assert_eq!(classify_file(Path::new("archive.zip")), None);
946    }
947
948    #[test]
949    fn test_classify_image() {
950        assert_eq!(
951            classify_file(Path::new("screenshot.png")),
952            Some(FileType::Image)
953        );
954        assert_eq!(
955            classify_file(Path::new("design.jpg")),
956            Some(FileType::Image)
957        );
958        assert_eq!(
959            classify_file(Path::new("diagram.webp")),
960            Some(FileType::Image)
961        );
962    }
963
964    #[test]
965    fn test_classify_video_extensions() {
966        assert_eq!(
967            classify_file(Path::new("lecture.mp4")),
968            Some(FileType::Video)
969        );
970        assert_eq!(
971            classify_file(Path::new("podcast.mp3")),
972            Some(FileType::Video)
973        );
974        assert_eq!(classify_file(Path::new("talk.mov")), Some(FileType::Video));
975        assert_eq!(
976            classify_file(Path::new("recording.wav")),
977            Some(FileType::Video)
978        );
979        assert_eq!(
980            classify_file(Path::new("webinar.webm")),
981            Some(FileType::Video)
982        );
983        assert_eq!(classify_file(Path::new("audio.m4a")), Some(FileType::Video));
984    }
985
986    #[test]
987    fn test_classify_google_workspace_shortcuts() {
988        assert_eq!(
989            classify_file(Path::new("notes.gdoc")),
990            Some(FileType::Document)
991        );
992        assert_eq!(
993            classify_file(Path::new("budget.gsheet")),
994            Some(FileType::Document)
995        );
996        assert_eq!(
997            classify_file(Path::new("deck.gslides")),
998            Some(FileType::Document)
999        );
1000    }
1001
1002    #[test]
1003    fn test_classify_md_paper_by_signals() {
1004        let dir = setup_test_dir("classify_md_paper");
1005        let paper = dir.join("paper.md");
1006        fs::write(
1007            &paper,
1008            "# Abstract\n\nWe propose a new method. See [1] and [23].\n\
1009             This work was published in the Journal of AI. ArXiv preprint.\n\
1010             See Equation 3 for details. \\cite{vaswani2017}.\n",
1011        )
1012        .unwrap();
1013        assert_eq!(classify_file(&paper), Some(FileType::Paper));
1014        teardown(&dir);
1015    }
1016
1017    #[test]
1018    fn test_classify_md_doc_without_signals() {
1019        let dir = setup_test_dir("classify_md_doc");
1020        let doc = dir.join("notes.md");
1021        fs::write(
1022            &doc,
1023            "# My Notes\n\nHere are some notes about the project.\n",
1024        )
1025        .unwrap();
1026        assert_eq!(classify_file(&doc), Some(FileType::Document));
1027        teardown(&dir);
1028    }
1029
1030    // -----------------------------------------------------------------------
1031    // count_words
1032    // -----------------------------------------------------------------------
1033
1034    #[test]
1035    fn test_count_words_simple() {
1036        let dir = setup_test_dir("count_words");
1037        fs::write(dir.join("doc.md"), "hello world foo bar baz").unwrap();
1038        assert_eq!(count_words(&dir.join("doc.md")), 5);
1039        teardown(&dir);
1040    }
1041
1042    #[test]
1043    fn test_count_words_multiline() {
1044        let dir = setup_test_dir("count_words_multi");
1045        fs::write(dir.join("doc.txt"), "one two\nthree four\nfive").unwrap();
1046        assert_eq!(count_words(&dir.join("doc.txt")), 5);
1047        teardown(&dir);
1048    }
1049
1050    // -----------------------------------------------------------------------
1051    // detect() function
1052    // -----------------------------------------------------------------------
1053
1054    #[test]
1055    fn test_detect_finds_files() {
1056        let dir = setup_test_dir("detect_find_files");
1057        fs::write(dir.join("main.py"), "print('hi')").unwrap();
1058        fs::write(dir.join("utils.js"), "const x = 1;").unwrap();
1059        fs::write(dir.join("readme.md"), "# Docs").unwrap();
1060
1061        let result = detect(&dir, Some(false), &[]);
1062        assert!(result.files["code"].len() >= 2);
1063        assert!(!result.files["document"].is_empty());
1064        teardown(&dir);
1065    }
1066
1067    #[test]
1068    fn test_detect_warns_small_corpus() {
1069        let dir = setup_test_dir("detect_small_corpus");
1070        fs::write(dir.join("main.py"), "x = 1").unwrap();
1071        fs::write(dir.join("utils.py"), "y = 2").unwrap();
1072
1073        let result = detect(&dir, Some(false), &[]);
1074        assert!(
1075            !result.needs_graph,
1076            "fewer than 5 files → needs_graph=false"
1077        );
1078        assert!(result.warning.is_some(), "small corpus → warning present");
1079        teardown(&dir);
1080    }
1081
1082    #[test]
1083    fn test_detect_needs_graph_true() {
1084        let dir = setup_test_dir("detect_needs_graph");
1085        for i in 0..6 {
1086            fs::write(dir.join(format!("f{i}.py")), "x = 1").unwrap();
1087        }
1088        let result = detect(&dir, Some(false), &[]);
1089        assert!(result.needs_graph);
1090        assert!(result.warning.is_none());
1091        teardown(&dir);
1092    }
1093
1094    #[test]
1095    fn test_detect_skips_noise_dot_dirs() {
1096        let dir = setup_test_dir("detect_noise_dot");
1097        fs::create_dir_all(dir.join(".next").join("cache")).unwrap();
1098        fs::write(dir.join(".next").join("cache").join("build.js"), "x").unwrap();
1099        fs::create_dir_all(dir.join(".codesynapse").join("cache")).unwrap();
1100        fs::write(
1101            dir.join(".codesynapse").join("cache").join("data.json"),
1102            "{}",
1103        )
1104        .unwrap();
1105        fs::write(dir.join("app.py"), "def go(): pass").unwrap();
1106
1107        let result = detect(&dir, Some(false), &[]);
1108        let all: Vec<&str> = result
1109            .files
1110            .values()
1111            .flatten()
1112            .map(|s| s.as_str())
1113            .collect();
1114        assert!(all.iter().any(|f| f.contains("app.py")));
1115        assert!(!all.iter().any(|f| f.contains(".next")));
1116        assert!(!all.iter().any(|f| f.contains(".codesynapse")));
1117        teardown(&dir);
1118    }
1119
1120    #[test]
1121    fn test_detect_includes_video_key() {
1122        let dir = setup_test_dir("detect_video_key");
1123        fs::write(dir.join("main.py"), "x = 1").unwrap();
1124
1125        let result = detect(&dir, Some(false), &[]);
1126        assert!(result.files.contains_key("video"));
1127        teardown(&dir);
1128    }
1129
1130    #[test]
1131    fn test_detect_finds_video_files() {
1132        let dir = setup_test_dir("detect_video_files");
1133        fs::write(dir.join("lecture.mp4"), b"fake video").unwrap();
1134        fs::write(dir.join("notes.md"), "# Notes\nSome content here.").unwrap();
1135
1136        let result = detect(&dir, Some(false), &[]);
1137        assert_eq!(result.files["video"].len(), 1);
1138        assert!(result.files["video"][0].contains("lecture.mp4"));
1139        teardown(&dir);
1140    }
1141
1142    #[test]
1143    fn test_detect_video_not_in_words() {
1144        let dir = setup_test_dir("detect_video_words");
1145        fs::write(dir.join("clip.mp4"), &[0u8; 100][..]).unwrap();
1146
1147        let result = detect(&dir, Some(false), &[]);
1148        assert_eq!(result.total_words, 0);
1149        teardown(&dir);
1150    }
1151
1152    #[test]
1153    fn test_detect_skips_google_workspace_shortcuts_by_default() {
1154        let dir = setup_test_dir("detect_workspace_skip");
1155        fs::write(dir.join("notes.gdoc"), r#"{"doc_id":"doc-1"}"#).unwrap();
1156
1157        let result = detect(&dir, Some(false), &[]);
1158        assert!(result.files["document"].is_empty());
1159        assert!(result
1160            .skipped_sensitive
1161            .iter()
1162            .any(|s| s.contains("Google Workspace shortcut skipped")));
1163        teardown(&dir);
1164    }
1165
1166    // -----------------------------------------------------------------------
1167    // codesynapseignore tests
1168    // -----------------------------------------------------------------------
1169
1170    #[test]
1171    fn test_codesynapseignore_excludes_file() {
1172        let dir = setup_test_dir("codesynapseignore_excludes");
1173        fs::write(dir.join(".codesynapseignore"), "vendor/\n*.generated.py\n").unwrap();
1174        fs::create_dir_all(dir.join("vendor")).unwrap();
1175        fs::write(dir.join("vendor").join("lib.py"), "x = 1").unwrap();
1176        fs::write(dir.join("main.py"), "print('hi')").unwrap();
1177        fs::write(dir.join("schema.generated.py"), "x = 1").unwrap();
1178
1179        let result = detect(&dir, Some(false), &[]);
1180        let code = &result.files["code"];
1181        assert!(code.iter().any(|f| f.contains("main.py")));
1182        assert!(!code.iter().any(|f| f.contains("vendor")));
1183        assert!(!code.iter().any(|f| f.contains("generated")));
1184        assert_eq!(result.codesynapseignore_patterns, 2);
1185        teardown(&dir);
1186    }
1187
1188    #[test]
1189    fn test_codesynapseignore_missing_is_fine() {
1190        let dir = setup_test_dir("codesynapseignore_missing");
1191        fs::write(dir.join("main.py"), "x = 1").unwrap();
1192
1193        let result = detect(&dir, Some(false), &[]);
1194        assert_eq!(result.codesynapseignore_patterns, 0);
1195        teardown(&dir);
1196    }
1197
1198    #[test]
1199    fn test_codesynapseignore_comments_ignored() {
1200        let dir = setup_test_dir("codesynapseignore_comments");
1201        fs::write(
1202            dir.join(".codesynapseignore"),
1203            "# this is a comment\n\nmain.py\n",
1204        )
1205        .unwrap();
1206        fs::write(dir.join("main.py"), "x = 1").unwrap();
1207        fs::write(dir.join("other.py"), "x = 2").unwrap();
1208
1209        let result = detect(&dir, Some(false), &[]);
1210        let code = &result.files["code"];
1211        assert!(!code.iter().any(|f| f.contains("main.py")));
1212        assert!(code.iter().any(|f| f.contains("other.py")));
1213        teardown(&dir);
1214    }
1215
1216    #[test]
1217    fn test_codesynapseignore_hermetic_without_vcs() {
1218        let dir = setup_test_dir("codesynapseignore_hermetic");
1219        fs::write(dir.join(".codesynapseignore"), "vendor/\n").unwrap();
1220        let sub = dir.join("packages").join("mylib");
1221        fs::create_dir_all(&sub).unwrap();
1222        fs::write(sub.join("main.py"), "x = 1").unwrap();
1223        let vendor = sub.join("vendor");
1224        fs::create_dir_all(&vendor).unwrap();
1225        fs::write(vendor.join("dep.py"), "y = 2").unwrap();
1226
1227        let result = detect(&sub, Some(false), &[]);
1228        let code = &result.files["code"];
1229        assert!(code.iter().any(|f| f.contains("main.py")));
1230        // parent .codesynapseignore must NOT leak (no VCS root)
1231        assert!(code.iter().any(|f| f.contains("vendor")));
1232        assert_eq!(result.codesynapseignore_patterns, 0);
1233        teardown(&dir);
1234    }
1235
1236    #[test]
1237    fn test_codesynapseignore_discovered_from_parent_in_vcs() {
1238        let dir = setup_test_dir("codesynapseignore_parent_vcs");
1239        fs::create_dir_all(dir.join(".git")).unwrap();
1240        fs::write(dir.join(".codesynapseignore"), "vendor/\n").unwrap();
1241        let sub = dir.join("packages").join("mylib");
1242        fs::create_dir_all(&sub).unwrap();
1243        fs::write(sub.join("main.py"), "x = 1").unwrap();
1244        let vendor = sub.join("vendor");
1245        fs::create_dir_all(&vendor).unwrap();
1246        fs::write(vendor.join("dep.py"), "y = 2").unwrap();
1247
1248        let result = detect(&sub, Some(false), &[]);
1249        let code = &result.files["code"];
1250        assert!(code.iter().any(|f| f.contains("main.py")));
1251        assert!(!code.iter().any(|f| f.contains("vendor")));
1252        assert!(result.codesynapseignore_patterns >= 1);
1253        teardown(&dir);
1254    }
1255
1256    #[test]
1257    fn test_codesynapseignore_stops_at_git_boundary() {
1258        let dir = setup_test_dir("codesynapseignore_git_boundary");
1259        fs::write(dir.join(".codesynapseignore"), "main.py\n").unwrap();
1260        let repo = dir.join("repo");
1261        fs::create_dir_all(repo.join(".git")).unwrap();
1262        let sub = repo.join("sub");
1263        fs::create_dir_all(&sub).unwrap();
1264        fs::write(sub.join("main.py"), "x = 1").unwrap();
1265
1266        let result = detect(&sub, Some(false), &[]);
1267        let code = &result.files["code"];
1268        assert!(code.iter().any(|f| f.contains("main.py")));
1269        assert_eq!(result.codesynapseignore_patterns, 0);
1270        teardown(&dir);
1271    }
1272
1273    #[test]
1274    fn test_codesynapseignore_at_git_root_is_included() {
1275        let dir = setup_test_dir("codesynapseignore_git_root");
1276        let repo = dir.join("repo");
1277        fs::create_dir_all(repo.join(".git")).unwrap();
1278        fs::write(repo.join(".codesynapseignore"), "vendor/\n").unwrap();
1279        let sub = repo.join("packages").join("mylib");
1280        fs::create_dir_all(&sub).unwrap();
1281        fs::write(sub.join("main.py"), "x = 1").unwrap();
1282        let vendor = sub.join("vendor");
1283        fs::create_dir_all(&vendor).unwrap();
1284        fs::write(vendor.join("dep.py"), "y = 2").unwrap();
1285
1286        let result = detect(&sub, Some(false), &[]);
1287        let code = &result.files["code"];
1288        assert!(code.iter().any(|f| f.contains("main.py")));
1289        assert!(!code.iter().any(|f| f.contains("vendor")));
1290        assert_eq!(result.codesynapseignore_patterns, 1);
1291        teardown(&dir);
1292    }
1293
1294    // -----------------------------------------------------------------------
1295    // gitignore fallback tests
1296    // -----------------------------------------------------------------------
1297
1298    #[test]
1299    fn test_gitignore_fallback_when_no_codesynapseignore() {
1300        let dir = setup_test_dir("gitignore_fallback");
1301        fs::create_dir_all(dir.join(".git")).unwrap();
1302        fs::write(dir.join(".gitignore"), "vendor/\n*.generated.py\n").unwrap();
1303        fs::create_dir_all(dir.join("vendor")).unwrap();
1304        fs::write(dir.join("vendor").join("lib.py"), "x = 1").unwrap();
1305        fs::write(dir.join("main.py"), "print('hi')").unwrap();
1306        fs::write(dir.join("schema.generated.py"), "x = 1").unwrap();
1307
1308        let result = detect(&dir, Some(false), &[]);
1309        let code = &result.files["code"];
1310        assert!(code.iter().any(|f| f.contains("main.py")));
1311        assert!(!code.iter().any(|f| f.contains("vendor")));
1312        assert!(!code.iter().any(|f| f.contains("generated")));
1313        teardown(&dir);
1314    }
1315
1316    #[test]
1317    fn test_codesynapseignore_takes_precedence_over_gitignore() {
1318        let dir = setup_test_dir("codesynapseignore_precedence");
1319        fs::create_dir_all(dir.join(".git")).unwrap();
1320        fs::write(dir.join(".gitignore"), "main.py\n").unwrap();
1321        fs::write(dir.join(".codesynapseignore"), "other.py\n").unwrap();
1322        fs::write(dir.join("main.py"), "x = 1").unwrap();
1323        fs::write(dir.join("other.py"), "x = 2").unwrap();
1324
1325        let result = detect(&dir, Some(false), &[]);
1326        let code = &result.files["code"];
1327        assert!(code.iter().any(|f| f.contains("main.py")));
1328        assert!(!code.iter().any(|f| f.contains("other.py")));
1329        teardown(&dir);
1330    }
1331
1332    // -----------------------------------------------------------------------
1333    // Symlink tests
1334    // -----------------------------------------------------------------------
1335
1336    #[test]
1337    #[cfg(unix)]
1338    fn test_detect_follows_symlinked_directory() {
1339        let dir = setup_test_dir("detect_symlink_dir");
1340        let real_dir = dir.join("real_lib");
1341        fs::create_dir_all(&real_dir).unwrap();
1342        fs::write(real_dir.join("util.py"), "x = 1").unwrap();
1343        std::os::unix::fs::symlink(&real_dir, dir.join("linked_lib")).unwrap();
1344
1345        let result_no = detect(&dir, Some(false), &[]);
1346        let result_yes = detect(&dir, Some(true), &[]);
1347
1348        assert!(result_no.files["code"]
1349            .iter()
1350            .any(|f| f.contains("real_lib")));
1351        assert!(!result_no.files["code"]
1352            .iter()
1353            .any(|f| f.contains("linked_lib")));
1354        assert!(result_yes.files["code"]
1355            .iter()
1356            .any(|f| f.contains("linked_lib")));
1357        teardown(&dir);
1358    }
1359
1360    #[test]
1361    #[cfg(unix)]
1362    fn test_detect_follows_symlinked_file() {
1363        let dir = setup_test_dir("detect_symlink_file");
1364        fs::write(dir.join("real.py"), "x = 1").unwrap();
1365        std::os::unix::fs::symlink(dir.join("real.py"), dir.join("link.py")).unwrap();
1366
1367        let result = detect(&dir, Some(true), &[]);
1368        let code = &result.files["code"];
1369        assert!(code.iter().any(|f| f.contains("real.py")));
1370        assert!(code.iter().any(|f| f.contains("link.py")));
1371        teardown(&dir);
1372    }
1373
1374    #[test]
1375    #[cfg(unix)]
1376    fn test_detect_handles_circular_symlinks() {
1377        let dir = setup_test_dir("detect_circular_symlink");
1378        let sub = dir.join("a");
1379        fs::create_dir_all(&sub).unwrap();
1380        fs::write(sub.join("main.py"), "x = 1").unwrap();
1381        std::os::unix::fs::symlink(&dir, sub.join("loop")).unwrap();
1382
1383        let result = detect(&dir, Some(true), &[]);
1384        assert!(result.files["code"].iter().any(|f| f.contains("main.py")));
1385        teardown(&dir);
1386    }
1387
1388    #[test]
1389    #[cfg(unix)]
1390    fn test_detect_auto_detects_direct_symlink_child() {
1391        let dir = setup_test_dir("detect_auto_symlink");
1392        let real_dir = dir.join("real_lib");
1393        fs::create_dir_all(&real_dir).unwrap();
1394        fs::write(real_dir.join("util.py"), "x = 1").unwrap();
1395        std::os::unix::fs::symlink(&real_dir, dir.join("linked_lib")).unwrap();
1396
1397        // Default (None) → auto-detect → follows because of linked_lib symlink
1398        let result = detect(&dir, None, &[]);
1399        assert!(result.files["code"]
1400            .iter()
1401            .any(|f| f.contains("linked_lib")));
1402        teardown(&dir);
1403    }
1404
1405    #[test]
1406    fn test_detect_default_does_not_follow_when_no_symlinks() {
1407        let dir = setup_test_dir("detect_no_symlinks");
1408        fs::write(dir.join("main.py"), "x = 1").unwrap();
1409        let sub = dir.join("sub");
1410        fs::create_dir_all(&sub).unwrap();
1411        fs::write(sub.join("other.py"), "y = 2").unwrap();
1412
1413        let result = detect(&dir, None, &[]);
1414        assert!(result.files["code"].iter().any(|f| f.contains("main.py")));
1415        assert!(result.files["code"].iter().any(|f| f.contains("other.py")));
1416        teardown(&dir);
1417    }
1418
1419    #[test]
1420    #[cfg(unix)]
1421    fn test_detect_explicit_false_overrides_auto_detect() {
1422        let dir = setup_test_dir("detect_explicit_false");
1423        let real_dir = dir.join("real_lib");
1424        fs::create_dir_all(&real_dir).unwrap();
1425        fs::write(real_dir.join("util.py"), "x = 1").unwrap();
1426        std::os::unix::fs::symlink(&real_dir, dir.join("linked_lib")).unwrap();
1427
1428        // Explicit false overrides auto-detect
1429        let result = detect(&dir, Some(false), &[]);
1430        assert!(!result.files["code"]
1431            .iter()
1432            .any(|f| f.contains("linked_lib")));
1433        teardown(&dir);
1434    }
1435
1436    // -----------------------------------------------------------------------
1437    // Noise dir skip tests
1438    // -----------------------------------------------------------------------
1439
1440    #[test]
1441    fn test_detect_skips_coverage_dir() {
1442        let dir = setup_test_dir("detect_cvg");
1443        let cov = dir.join("coverage").join("lcov-report");
1444        fs::create_dir_all(&cov).unwrap();
1445        fs::write(cov.join("index.html"), "<html>coverage</html>").unwrap();
1446        fs::write(dir.join("main.py"), "def hello(): pass").unwrap();
1447
1448        let result = detect(&dir, Some(false), &[]);
1449        let all: Vec<&str> = result
1450            .files
1451            .values()
1452            .flatten()
1453            .map(|s| s.as_str())
1454            .collect();
1455        assert!(all.iter().any(|f| f.contains("main.py")));
1456        let cov_prefix = dir.join("coverage").to_string_lossy().to_string();
1457        assert!(!all.iter().any(|f| f.starts_with(&cov_prefix)));
1458        teardown(&dir);
1459    }
1460
1461    #[test]
1462    fn test_detect_skips_visual_tests_dir() {
1463        let dir = setup_test_dir("detect_visual_tests");
1464        let vt = dir.join("visual-tests");
1465        fs::create_dir_all(&vt).unwrap();
1466        fs::write(vt.join("bundle.js"), "var u3=function(){};").unwrap();
1467        fs::write(dir.join("app.py"), "def main(): pass").unwrap();
1468
1469        let result = detect(&dir, Some(false), &[]);
1470        let all: Vec<&str> = result
1471            .files
1472            .values()
1473            .flatten()
1474            .map(|s| s.as_str())
1475            .collect();
1476        assert!(!all.iter().any(|f| f.contains("visual-tests")));
1477        assert!(all.iter().any(|f| f.contains("app.py")));
1478        teardown(&dir);
1479    }
1480
1481    #[test]
1482    fn test_detect_skips_snapshots_dir() {
1483        let dir = setup_test_dir("detect_snapshots");
1484        let snaps = dir.join("__snapshots__");
1485        fs::create_dir_all(&snaps).unwrap();
1486        fs::write(snaps.join("app.test.ts.snap"), "// Snapshot").unwrap();
1487        fs::write(dir.join("app.ts"), "export function greet() {}").unwrap();
1488
1489        let result = detect(&dir, Some(false), &[]);
1490        let all: Vec<&str> = result
1491            .files
1492            .values()
1493            .flatten()
1494            .map(|s| s.as_str())
1495            .collect();
1496        assert!(!all.iter().any(|f| f.contains("__snapshots__")));
1497        assert!(all.iter().any(|f| f.contains("app.ts")));
1498        teardown(&dir);
1499    }
1500
1501    #[test]
1502    fn test_detect_skips_storybook_static_dir() {
1503        let dir = setup_test_dir("detect_storybook");
1504        let sb = dir.join("storybook-static");
1505        fs::create_dir_all(&sb).unwrap();
1506        fs::write(sb.join("main.js"), "(function(){})()").unwrap();
1507        fs::write(
1508            dir.join("Button.tsx"),
1509            "export const Button = () => <button/>",
1510        )
1511        .unwrap();
1512
1513        let result = detect(&dir, Some(false), &[]);
1514        let all: Vec<&str> = result
1515            .files
1516            .values()
1517            .flatten()
1518            .map(|s| s.as_str())
1519            .collect();
1520        assert!(!all.iter().any(|f| f.contains("storybook-static")));
1521        assert!(all.iter().any(|f| f.contains("Button.tsx")));
1522        teardown(&dir);
1523    }
1524
1525    #[test]
1526    fn test_detect_allows_github_dir() {
1527        let dir = setup_test_dir("detect_github_dir");
1528        let gh = dir.join(".github").join("workflows");
1529        fs::create_dir_all(&gh).unwrap();
1530        fs::write(gh.join("ci.yml"), "name: CI\non: push\n").unwrap();
1531        fs::write(dir.join("main.py"), "def run(): pass").unwrap();
1532
1533        let result = detect(&dir, Some(false), &[]);
1534        let all: Vec<&str> = result
1535            .files
1536            .values()
1537            .flatten()
1538            .map(|s| s.as_str())
1539            .collect();
1540        assert!(
1541            all.iter().any(|f| f.contains(".github")),
1542            ".github/ci.yml should be detected"
1543        );
1544        teardown(&dir);
1545    }
1546
1547    #[test]
1548    fn test_detect_skips_next_cache() {
1549        let dir = setup_test_dir("detect_next_cache");
1550        let next_dir = dir.join(".next").join("cache");
1551        fs::create_dir_all(&next_dir).unwrap();
1552        fs::write(next_dir.join("build.js"), "(function(){})()").unwrap();
1553        let pages = dir.join("pages");
1554        fs::create_dir_all(&pages).unwrap();
1555        fs::write(pages.join("index.tsx"), "export default function Home() {}").unwrap();
1556
1557        let result = detect(&dir, Some(false), &[]);
1558        let all: Vec<&str> = result
1559            .files
1560            .values()
1561            .flatten()
1562            .map(|s| s.as_str())
1563            .collect();
1564        assert!(!all.iter().any(|f| f.contains(".next")));
1565        assert!(all.iter().any(|f| f.contains("index.tsx")));
1566        teardown(&dir);
1567    }
1568
1569    #[test]
1570    fn test_detect_skips_codesynapse_own_cache() {
1571        let dir = setup_test_dir("detect_codesynapse_cache");
1572        let cache = dir.join(".codesynapse").join("cache");
1573        fs::create_dir_all(&cache).unwrap();
1574        fs::write(cache.join("abc.json"), r#"{"nodes":[]}"#).unwrap();
1575        fs::write(dir.join("app.py"), "def go(): pass").unwrap();
1576
1577        let result = detect(&dir, Some(false), &[]);
1578        let all: Vec<&str> = result
1579            .files
1580            .values()
1581            .flatten()
1582            .map(|s| s.as_str())
1583            .collect();
1584        assert!(!all.iter().any(|f| f.contains(".codesynapse")));
1585        assert!(all.iter().any(|f| f.contains("app.py")));
1586        teardown(&dir);
1587    }
1588
1589    #[test]
1590    fn test_detect_skips_worktrees_dir() {
1591        let dir = setup_test_dir("detect_worktrees");
1592        let wt = dir.join(".worktrees").join("feature-branch");
1593        fs::create_dir_all(&wt).unwrap();
1594        fs::write(wt.join("main.py"), "x = 1").unwrap();
1595        fs::write(dir.join("app.py"), "y = 2").unwrap();
1596
1597        let result = detect(&dir, Some(false), &[]);
1598        let code = &result.files["code"];
1599        assert!(code.iter().any(|f| f.contains("app.py")));
1600        assert!(!code.iter().any(|f| f.contains(".worktrees")));
1601        teardown(&dir);
1602    }
1603
1604    #[test]
1605    fn test_detect_skips_nested_worktrees_dir() {
1606        let dir = setup_test_dir("detect_nested_wt");
1607        let wt = dir.join(".claude").join("worktrees").join("feature-branch");
1608        fs::create_dir_all(&wt).unwrap();
1609        fs::write(wt.join("main.py"), "x = 1").unwrap();
1610        fs::write(dir.join("app.py"), "y = 2").unwrap();
1611
1612        let result = detect(&dir, Some(false), &[]);
1613        let code = &result.files["code"];
1614        assert!(code.iter().any(|f| f.contains("app.py")));
1615        let wt_prefix = dir
1616            .join(".claude")
1617            .join("worktrees")
1618            .to_string_lossy()
1619            .to_string();
1620        assert!(!code.iter().any(|f| f.starts_with(&wt_prefix)));
1621        teardown(&dir);
1622    }
1623
1624    #[test]
1625    fn test_detect_extra_excludes_pattern() {
1626        let dir = setup_test_dir("detect_extra_excludes");
1627        fs::write(dir.join("main.py"), "x = 1").unwrap();
1628        fs::write(dir.join("secret.py"), "API_KEY = 'abc'").unwrap();
1629        let legacy = dir.join("legacy");
1630        fs::create_dir_all(&legacy).unwrap();
1631        fs::write(legacy.join("old.py"), "y = 2").unwrap();
1632
1633        let result = detect(&dir, Some(false), &["secret.py", "legacy/"]);
1634        let code = &result.files["code"];
1635        assert!(code.iter().any(|f| f.contains("main.py")));
1636        assert!(!code.iter().any(|f| f.contains("secret.py")));
1637        assert!(!code.iter().any(|f| f.contains("legacy")));
1638        teardown(&dir);
1639    }
1640
1641    // -----------------------------------------------------------------------
1642    // Negation tests (gitignore ! re-include semantics)
1643    // -----------------------------------------------------------------------
1644
1645    #[test]
1646    fn test_negation_cannot_rescue_file_under_excluded_dir() {
1647        let dir = setup_test_dir("negation_no_rescue");
1648        let android = dir.join("android").join("app").join("src");
1649        fs::create_dir_all(&android).unwrap();
1650        fs::write(android.join("Main.kt"), "fun main() {}").unwrap();
1651        fs::write(dir.join(".codesynapseignore"), "android/\n!src/\n").unwrap();
1652
1653        let result = detect(&dir, Some(false), &[]);
1654        let kt_found = result.files["code"].iter().any(|f| f.contains("Main.kt"));
1655        assert!(
1656            !kt_found,
1657            "Main.kt under android/ must remain ignored even with !src/"
1658        );
1659        teardown(&dir);
1660    }
1661
1662    #[test]
1663    fn test_negation_works_when_no_ancestor_excluded() {
1664        let dir = setup_test_dir("negation_works");
1665        let src = dir.join("src");
1666        fs::create_dir_all(&src).unwrap();
1667        fs::write(src.join("keep.py"), "x = 1").unwrap();
1668        fs::write(dir.join(".codesynapseignore"), "*.py\n!src/keep.py\n").unwrap();
1669
1670        let result = detect(&dir, Some(false), &[]);
1671        let found = result.files["code"].iter().any(|f| f.contains("keep.py"));
1672        assert!(found, "src/keep.py should be un-ignored by !src/keep.py");
1673        teardown(&dir);
1674    }
1675
1676    #[test]
1677    fn test_negation_ancestor_itself_reincluded() {
1678        let dir = setup_test_dir("negation_ancestor_reincluded");
1679        let vendor_lib = dir.join("vendor").join("lib");
1680        fs::create_dir_all(&vendor_lib).unwrap();
1681        fs::write(vendor_lib.join("utils.py"), "x = 1").unwrap();
1682        fs::write(dir.join(".codesynapseignore"), "vendor/\n!vendor/\n").unwrap();
1683
1684        let result = detect(&dir, Some(false), &[]);
1685        let found = result.files["code"].iter().any(|f| f.contains("utils.py"));
1686        assert!(
1687            found,
1688            "vendor/ excluded then re-included → file should be visible"
1689        );
1690        teardown(&dir);
1691    }
1692
1693    // -----------------------------------------------------------------------
1694    // Sensitive file tests (individual is_sensitive checks)
1695    // -----------------------------------------------------------------------
1696
1697    #[test]
1698    fn test_sensitive_flags_api_token_txt() {
1699        assert!(is_sensitive(Path::new("api_token.txt")));
1700    }
1701
1702    #[test]
1703    fn test_sensitive_flags_oauth_token_json() {
1704        assert!(is_sensitive(Path::new("oauth_token.json")));
1705    }
1706
1707    #[test]
1708    fn test_sensitive_flags_underscore_secret() {
1709        assert!(is_sensitive(Path::new("app_secret.yaml")));
1710    }
1711
1712    #[test]
1713    fn test_sensitive_does_not_flag_tokenizer_py() {
1714        assert!(!is_sensitive(Path::new("tokenizer.py")));
1715    }
1716
1717    #[test]
1718    fn test_sensitive_does_not_flag_tokenize_py() {
1719        assert!(!is_sensitive(Path::new("tokenize.py")));
1720    }
1721
1722    #[test]
1723    fn test_sensitive_flags_passwords_py() {
1724        assert!(is_sensitive(Path::new("passwords.py")));
1725    }
1726
1727    #[test]
1728    fn test_sensitive_flags_ssh_dir() {
1729        assert!(is_sensitive(Path::new("/home/user/.ssh/id_rsa")));
1730    }
1731
1732    #[test]
1733    fn test_sensitive_flags_secrets_dir() {
1734        assert!(is_sensitive(Path::new("config/secrets/db.json")));
1735    }
1736
1737    #[test]
1738    fn test_sensitive_flags_token_txt() {
1739        assert!(is_sensitive(Path::new("token.txt")));
1740    }
1741
1742    #[test]
1743    fn test_sensitive_flags_credentials_json() {
1744        assert!(is_sensitive(Path::new("credentials.json")));
1745    }
1746
1747    #[test]
1748    fn test_sensitive_does_not_flag_root_file_named_credentials() {
1749        // Root-level "credentials" has no parent dir named credentials;
1750        // Stage 1 (dir check) passes. Stage 2 (name pattern) catches it.
1751        let p = Path::new("credentials");
1752        assert!(is_sensitive(p));
1753    }
1754
1755    #[test]
1756    fn test_sensitive_secret_handler_txt() {
1757        // "secret_handler.txt" — "secret" followed by "_" → flagged
1758        assert!(is_sensitive(Path::new("secret_handler.txt")));
1759    }
1760
1761    #[test]
1762    fn test_sensitive_token_config_yaml() {
1763        // "token_config.yaml" — "token" followed by "_" → flagged
1764        assert!(is_sensitive(Path::new("token_config.yaml")));
1765    }
1766
1767    // -----------------------------------------------------------------------
1768    // Shebang interpreter tests
1769    // -----------------------------------------------------------------------
1770
1771    #[test]
1772    fn test_shebang_interpreter_plain() {
1773        let dir = setup_test_dir("shebang_plain");
1774        let script = dir.join("plain");
1775        fs::write(&script, b"#!/usr/bin/python3\nprint('x')\n").unwrap();
1776        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1777        teardown(&dir);
1778    }
1779
1780    #[test]
1781    fn test_shebang_interpreter_env_single_arg() {
1782        let dir = setup_test_dir("shebang_env_single");
1783        let script = dir.join("env_single");
1784        fs::write(&script, b"#!/usr/bin/env python3\nprint('x')\n").unwrap();
1785        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1786        teardown(&dir);
1787    }
1788
1789    #[test]
1790    fn test_shebang_interpreter_env_dash_s() {
1791        let dir = setup_test_dir("shebang_env_dashs");
1792        let script = dir.join("env_dashs");
1793        fs::write(&script, b"#!/usr/bin/env -S python3 -u\nprint('x')\n").unwrap();
1794        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1795        teardown(&dir);
1796    }
1797
1798    #[test]
1799    fn test_shebang_interpreter_env_with_flags() {
1800        let dir = setup_test_dir("shebang_env_flags");
1801        let script = dir.join("env_flags");
1802        fs::write(&script, b"#!/usr/bin/env -i bash\necho hi\n").unwrap();
1803        assert_eq!(shebang_interpreter(&script).as_deref(), Some("bash"));
1804        teardown(&dir);
1805    }
1806
1807    #[test]
1808    fn test_shebang_interpreter_env_with_assignment() {
1809        let dir = setup_test_dir("shebang_env_assign");
1810        let script = dir.join("env_assign");
1811        fs::write(&script, b"#!/usr/bin/env DEBUG=1 python3\nprint('x')\n").unwrap();
1812        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1813        teardown(&dir);
1814    }
1815
1816    #[test]
1817    fn test_shebang_interpreter_no_shebang() {
1818        let dir = setup_test_dir("shebang_none");
1819        let script = dir.join("no_shebang");
1820        fs::write(&script, b"print('x')\n").unwrap();
1821        assert_eq!(shebang_interpreter(&script), None);
1822        teardown(&dir);
1823    }
1824
1825    #[test]
1826    fn test_shebang_interpreter_quoted_path() {
1827        let dir = setup_test_dir("shebang_quoted");
1828        let script = dir.join("quoted");
1829        fs::write(&script, b"#!\"/usr/local/bin/python3\"\nprint(\"x\")\n").unwrap();
1830        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1831        teardown(&dir);
1832    }
1833
1834    #[test]
1835    fn test_shebang_file_type_classifies_via_interpreter() {
1836        let dir = setup_test_dir("shebang_file_type");
1837        let script = dir.join("tool");
1838        fs::write(&script, b"#!/usr/bin/env -S python3 -u\nprint('x')\n").unwrap();
1839        assert_eq!(classify_file(&script), Some(FileType::Code));
1840        teardown(&dir);
1841    }
1842
1843    #[test]
1844    fn test_shebang_interpreter_unreadable_returns_none() {
1845        let dir = setup_test_dir("shebang_missing");
1846        let missing = dir.join("does_not_exist");
1847        assert_eq!(shebang_interpreter(&missing), None);
1848        teardown(&dir);
1849    }
1850
1851    #[test]
1852    fn test_shebang_interpreter_env_unset_with_operand() {
1853        let dir = setup_test_dir("shebang_env_unset");
1854        let script = dir.join("env_unset");
1855        fs::write(
1856            &script,
1857            b"#!/usr/bin/env -u PYTHONPATH python3\nprint('x')\n",
1858        )
1859        .unwrap();
1860        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1861        assert_eq!(classify_file(&script), Some(FileType::Code));
1862        teardown(&dir);
1863    }
1864
1865    #[test]
1866    fn test_shebang_interpreter_env_chdir_with_operand() {
1867        let dir = setup_test_dir("shebang_env_chdir");
1868        let script = dir.join("env_chdir");
1869        fs::write(&script, b"#!/usr/bin/env -C /tmp python3\nprint('x')\n").unwrap();
1870        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1871        assert_eq!(classify_file(&script), Some(FileType::Code));
1872        teardown(&dir);
1873    }
1874
1875    #[test]
1876    fn test_shebang_interpreter_env_path_with_operand() {
1877        let dir = setup_test_dir("shebang_env_path");
1878        let script = dir.join("env_path");
1879        fs::write(&script, b"#!/usr/bin/env -P /bin python3\nprint('x')\n").unwrap();
1880        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1881        assert_eq!(classify_file(&script), Some(FileType::Code));
1882        teardown(&dir);
1883    }
1884
1885    #[test]
1886    fn test_shebang_interpreter_env_dash_s_after_flag() {
1887        let dir = setup_test_dir("shebang_env_flag_dashs");
1888        let script = dir.join("env_flag_dash_s");
1889        fs::write(
1890            &script,
1891            b"#!/usr/bin/env -i -S \"python3 -u\"\nprint(\"x\")\n",
1892        )
1893        .unwrap();
1894        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1895        assert_eq!(classify_file(&script), Some(FileType::Code));
1896        teardown(&dir);
1897    }
1898
1899    #[test]
1900    fn test_shebang_interpreter_env_clumped_u_operand() {
1901        let dir = setup_test_dir("shebang_env_clumped");
1902        let script = dir.join("env_clumped");
1903        fs::write(
1904            &script,
1905            b"#!/usr/bin/env -uPYTHONPATH python3\nprint('x')\n",
1906        )
1907        .unwrap();
1908        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1909        assert_eq!(classify_file(&script), Some(FileType::Code));
1910        teardown(&dir);
1911    }
1912
1913    #[test]
1914    fn test_shebang_interpreter_env_missing_operand_returns_none() {
1915        let dir = setup_test_dir("shebang_env_missing_op");
1916        let script = dir.join("env_missing_op");
1917        fs::write(&script, b"#!/usr/bin/env -u\n").unwrap();
1918        assert_eq!(shebang_interpreter(&script), None);
1919        teardown(&dir);
1920    }
1921
1922    #[test]
1923    fn test_shebang_interpreter_env_gnu_split_string_equals() {
1924        let dir = setup_test_dir("shebang_split_eq");
1925        let script = dir.join("env_split_eq");
1926        fs::write(
1927            &script,
1928            b"#!/usr/bin/env --split-string='python3 -u'\nprint('x')\n",
1929        )
1930        .unwrap();
1931        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1932        assert_eq!(classify_file(&script), Some(FileType::Code));
1933        teardown(&dir);
1934    }
1935
1936    #[test]
1937    fn test_shebang_interpreter_env_gnu_split_string_separate() {
1938        let dir = setup_test_dir("shebang_split_sep");
1939        let script = dir.join("env_split_sep");
1940        fs::write(
1941            &script,
1942            b"#!/usr/bin/env --split-string \"python3 -u\"\nprint(\"x\")\n",
1943        )
1944        .unwrap();
1945        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1946        assert_eq!(classify_file(&script), Some(FileType::Code));
1947        teardown(&dir);
1948    }
1949
1950    #[test]
1951    fn test_shebang_interpreter_env_gnu_argv0_operand() {
1952        let dir = setup_test_dir("shebang_env_argv0");
1953        let script = dir.join("env_argv0");
1954        fs::write(&script, b"#!/usr/bin/env -a alias python3\nprint('x')\n").unwrap();
1955        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1956        assert_eq!(classify_file(&script), Some(FileType::Code));
1957        teardown(&dir);
1958    }
1959
1960    #[test]
1961    fn test_shebang_interpreter_env_compact_dash_s() {
1962        let dir = setup_test_dir("shebang_compact_s");
1963        let script = dir.join("env_compact_dash_s");
1964        fs::write(&script, b"#!/usr/bin/env -Spython3 -u\nprint('x')\n").unwrap();
1965        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1966        assert_eq!(classify_file(&script), Some(FileType::Code));
1967        teardown(&dir);
1968    }
1969
1970    #[test]
1971    fn test_shebang_interpreter_env_compact_v_then_s() {
1972        let dir = setup_test_dir("shebang_compact_vs");
1973        let script = dir.join("env_compact_vs");
1974        fs::write(&script, b"#!/usr/bin/env -vSpython3 -u\nprint('x')\n").unwrap();
1975        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1976        assert_eq!(classify_file(&script), Some(FileType::Code));
1977        teardown(&dir);
1978    }
1979
1980    #[test]
1981    fn test_shebang_interpreter_env_long_unset_separate_operand() {
1982        let dir = setup_test_dir("shebang_long_unset");
1983        let script = dir.join("env_long_unset");
1984        fs::write(
1985            &script,
1986            b"#!/usr/bin/env --unset PYTHONPATH python3\nprint('x')\n",
1987        )
1988        .unwrap();
1989        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
1990        assert_eq!(classify_file(&script), Some(FileType::Code));
1991        teardown(&dir);
1992    }
1993
1994    #[test]
1995    fn test_shebang_interpreter_env_long_unset_equals() {
1996        let dir = setup_test_dir("shebang_long_unset_eq");
1997        let script = dir.join("env_long_unset_eq");
1998        fs::write(
1999            &script,
2000            b"#!/usr/bin/env --unset=PYTHONPATH python3\nprint('x')\n",
2001        )
2002        .unwrap();
2003        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
2004        assert_eq!(classify_file(&script), Some(FileType::Code));
2005        teardown(&dir);
2006    }
2007
2008    #[test]
2009    fn test_shebang_interpreter_env_long_chdir_separate_operand() {
2010        let dir = setup_test_dir("shebang_long_chdir");
2011        let script = dir.join("env_long_chdir");
2012        fs::write(
2013            &script,
2014            b"#!/usr/bin/env --chdir /tmp python3\nprint('x')\n",
2015        )
2016        .unwrap();
2017        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
2018        assert_eq!(classify_file(&script), Some(FileType::Code));
2019        teardown(&dir);
2020    }
2021
2022    #[test]
2023    fn test_shebang_interpreter_env_long_chdir_equals() {
2024        let dir = setup_test_dir("shebang_long_chdir_eq");
2025        let script = dir.join("env_long_chdir_eq");
2026        fs::write(
2027            &script,
2028            b"#!/usr/bin/env --chdir=/tmp python3\nprint('x')\n",
2029        )
2030        .unwrap();
2031        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
2032        assert_eq!(classify_file(&script), Some(FileType::Code));
2033        teardown(&dir);
2034    }
2035
2036    #[test]
2037    fn test_shebang_interpreter_env_signal_flags() {
2038        let dir = setup_test_dir("shebang_signal");
2039        let script = dir.join("env_signal");
2040        fs::write(
2041            &script,
2042            b"#!/usr/bin/env --default-signal=TERM --ignore-signal=PIPE python3\n",
2043        )
2044        .unwrap();
2045        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
2046        assert_eq!(classify_file(&script), Some(FileType::Code));
2047        teardown(&dir);
2048    }
2049
2050    #[test]
2051    fn test_shebang_interpreter_env_unknown_option_returns_none() {
2052        let dir = setup_test_dir("shebang_unknown");
2053        let script = dir.join("env_unknown");
2054        fs::write(&script, b"#!/usr/bin/env --no-such-flag python3\n").unwrap();
2055        assert_eq!(shebang_interpreter(&script), None);
2056        teardown(&dir);
2057    }
2058
2059    #[test]
2060    fn test_shebang_interpreter_env_dash_s_assignment_before_interpreter() {
2061        let dir = setup_test_dir("shebang_s_assign");
2062        let script = dir.join("env_s_assignment");
2063        fs::write(
2064            &script,
2065            b"#!/usr/bin/env -S PYTHONPATH=/opt/custom:${PYTHONPATH} python3\nprint('x')\n",
2066        )
2067        .unwrap();
2068        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
2069        assert_eq!(classify_file(&script), Some(FileType::Code));
2070        teardown(&dir);
2071    }
2072
2073    #[test]
2074    fn test_shebang_interpreter_env_dash_s_flag_before_interpreter() {
2075        let dir = setup_test_dir("shebang_s_flag");
2076        let script = dir.join("env_s_flag");
2077        fs::write(
2078            &script,
2079            b"#!/usr/bin/env -S -i OLDUSER=${USER} python3\nprint('x')\n",
2080        )
2081        .unwrap();
2082        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
2083        assert_eq!(classify_file(&script), Some(FileType::Code));
2084        teardown(&dir);
2085    }
2086
2087    #[test]
2088    fn test_shebang_interpreter_env_long_split_assignment_before_interpreter() {
2089        let dir = setup_test_dir("shebang_long_split_assign");
2090        let script = dir.join("env_long_split_assignment");
2091        fs::write(
2092            &script,
2093            b"#!/usr/bin/env --split-string='PYTHONPATH=/opt/custom:${PYTHONPATH} python3 -u'\nprint('x')\n",
2094        )
2095        .unwrap();
2096        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
2097        assert_eq!(classify_file(&script), Some(FileType::Code));
2098        teardown(&dir);
2099    }
2100
2101    #[test]
2102    fn test_shebang_interpreter_env_long_split_flag_before_interpreter() {
2103        let dir = setup_test_dir("shebang_long_split_flag");
2104        let script = dir.join("env_long_split_flag");
2105        fs::write(
2106            &script,
2107            b"#!/usr/bin/env --split-string='-i python3 -u'\nprint('x')\n",
2108        )
2109        .unwrap();
2110        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
2111        assert_eq!(classify_file(&script), Some(FileType::Code));
2112        teardown(&dir);
2113    }
2114
2115    #[test]
2116    fn test_shebang_interpreter_env_nested_split_string_rejected() {
2117        let dir = setup_test_dir("shebang_nested_split");
2118        let script = dir.join("env_nested_split");
2119        fs::write(&script, b"#!/usr/bin/env -S -S python3 -u\nprint('x')\n").unwrap();
2120        assert_eq!(shebang_interpreter(&script), None);
2121        teardown(&dir);
2122    }
2123
2124    #[test]
2125    fn test_shebang_interpreter_env_vs_assignment_before_interpreter() {
2126        let dir = setup_test_dir("shebang_vs_assign");
2127        let script = dir.join("env_vs_assignment");
2128        fs::write(
2129            &script,
2130            b"#!/usr/bin/env -vS DEBUG=1 python3 -u\nprint('x')\n",
2131        )
2132        .unwrap();
2133        assert_eq!(shebang_interpreter(&script).as_deref(), Some("python3"));
2134        assert_eq!(classify_file(&script), Some(FileType::Code));
2135        teardown(&dir);
2136    }
2137
2138    // -----------------------------------------------------------------------
2139    // Legacy Detector tests (kept from original 22)
2140    // -----------------------------------------------------------------------
2141
2142    #[test]
2143    fn test_detect_basic() {
2144        let dir = setup_test_dir("detect_basic");
2145        fs::write(dir.join("main.py"), "print('hello')").unwrap();
2146        fs::write(dir.join("utils.js"), "const x = 1;").unwrap();
2147        fs::write(dir.join("readme.md"), "# Docs").unwrap();
2148
2149        let detector = Detector::new(&dir);
2150        let files = detector.discover(&dir).unwrap();
2151
2152        let code_count = files
2153            .iter()
2154            .filter(|f| f.file_type == FileType::Code)
2155            .count();
2156        let doc_count = files
2157            .iter()
2158            .filter(|f| f.file_type == FileType::Document)
2159            .count();
2160
2161        assert_eq!(code_count, 2);
2162        assert_eq!(doc_count, 1);
2163        teardown(&dir);
2164    }
2165
2166    #[test]
2167    fn test_detect_gitignore() {
2168        let dir = setup_test_dir("detect_gitignore");
2169        fs::create_dir_all(dir.join("node_modules")).unwrap();
2170        fs::write(dir.join("node_modules/package.js"), "// ignored").unwrap();
2171        fs::write(dir.join("main.py"), "print('ok')").unwrap();
2172
2173        let detector = Detector::new(&dir);
2174        let files = detector.discover(&dir).unwrap();
2175
2176        assert_eq!(files.len(), 1);
2177        assert_eq!(files[0].relative_path, "main.py");
2178        teardown(&dir);
2179    }
2180
2181    #[test]
2182    fn test_detect_sensitive() {
2183        let dir = setup_test_dir("detect_sensitive");
2184        fs::write(dir.join(".env"), "SECRET=1").unwrap();
2185        fs::write(dir.join("main.py"), "print('ok')").unwrap();
2186
2187        let detector = Detector::new(&dir);
2188        let files = detector.discover(&dir).unwrap();
2189
2190        assert_eq!(files.len(), 1);
2191        assert_eq!(files[0].relative_path, "main.py");
2192        teardown(&dir);
2193    }
2194
2195    #[test]
2196    fn test_detect_noise_dirs() {
2197        let dir = setup_test_dir("detect_noise_dirs");
2198        fs::create_dir_all(dir.join("__pycache__")).unwrap();
2199        fs::create_dir_all(dir.join("src")).unwrap();
2200        fs::write(dir.join("__pycache__/foo.pyc"), "ignored").unwrap();
2201        fs::write(dir.join("src/main.py"), "print('ok')").unwrap();
2202
2203        let detector = Detector::new(&dir);
2204        let files = detector.discover(&dir).unwrap();
2205
2206        assert_eq!(files.len(), 1);
2207        assert_eq!(files[0].relative_path, "src/main.py");
2208        teardown(&dir);
2209    }
2210
2211    #[test]
2212    fn test_detect_empty_dir() {
2213        let dir = setup_test_dir("detect_empty");
2214        let detector = Detector::new(&dir);
2215        let files = detector.discover(&dir).unwrap();
2216        assert!(files.is_empty());
2217        teardown(&dir);
2218    }
2219
2220    #[test]
2221    fn test_detect_paper_signal() {
2222        let dir = setup_test_dir("detect_paper");
2223        let content = "# A Novel Approach\n\n\
2224                       arXiv: 1706.03762\n\n\
2225                       Abstract: We propose a new method.\n\n\
2226                       Our literature review shows that this journal has \
2227                       published many proceedings on this topic.\n";
2228        fs::write(dir.join("paper.md"), content).unwrap();
2229        fs::write(dir.join("notes.txt"), "Just some notes").unwrap();
2230
2231        let detector = Detector::new(&dir);
2232        let files = detector.discover(&dir).unwrap();
2233
2234        let paper_count = files
2235            .iter()
2236            .filter(|f| f.file_type == FileType::Paper)
2237            .count();
2238        assert_eq!(paper_count, 1);
2239        let doc_count = files
2240            .iter()
2241            .filter(|f| f.file_type == FileType::Document)
2242            .count();
2243        assert_eq!(doc_count, 1);
2244        teardown(&dir);
2245    }
2246
2247    #[test]
2248    fn test_detect_shebang() {
2249        let dir = setup_test_dir("detect_shebang");
2250        fs::write(dir.join("script"), "#!/usr/bin/env python3\nprint('hi')").unwrap();
2251        fs::write(dir.join("runner"), "#!/usr/bin/bash\necho ok").unwrap();
2252        fs::write(dir.join("no_shebang"), "just text").unwrap();
2253        fs::write(dir.join("main.py"), "print('ok')").unwrap();
2254
2255        let detector = Detector::new(&dir);
2256        let files = detector.discover(&dir).unwrap();
2257
2258        let code_count = files
2259            .iter()
2260            .filter(|f| f.file_type == FileType::Code)
2261            .count();
2262        assert_eq!(code_count, 3);
2263        teardown(&dir);
2264    }
2265
2266    #[test]
2267    fn test_detect_build_not_ignored() {
2268        let dir = setup_test_dir("detect_build_not_ignored");
2269        fs::create_dir_all(dir.join("src").join("build")).unwrap();
2270        fs::write(
2271            dir.join("src").join("build").join("Main.java"),
2272            "class Main {}",
2273        )
2274        .unwrap();
2275        fs::write(dir.join("main.py"), "print('ok')").unwrap();
2276
2277        let detector = Detector::new(&dir);
2278        let files = detector.discover(&dir).unwrap();
2279
2280        let src_count = files
2281            .iter()
2282            .filter(|f| f.relative_path.starts_with("src"))
2283            .count();
2284        assert_eq!(src_count, 1);
2285        assert_eq!(files.len(), 2);
2286        teardown(&dir);
2287    }
2288
2289    #[test]
2290    fn test_detect_symlink_follow() {
2291        let dir = setup_test_dir("detect_symlink");
2292        fs::create_dir_all(dir.join("real")).unwrap();
2293        fs::write(dir.join("real").join("target.rs"), "fn main() {}").unwrap();
2294        #[cfg(unix)]
2295        {
2296            std::os::unix::fs::symlink(dir.join("real"), dir.join("link")).unwrap();
2297        }
2298
2299        let mut detector = Detector::new(&dir);
2300        detector.follow_symlinks = true;
2301        let files = detector.discover(&dir).unwrap();
2302        assert!(!files.is_empty());
2303        teardown(&dir);
2304    }
2305
2306    #[test]
2307    fn test_detect_office_conversion() {
2308        let dir = setup_test_dir("detect_office");
2309        fs::write(dir.join("report.docx"), "fake docx content").unwrap();
2310        fs::write(dir.join("data.xlsx"), "fake xlsx content").unwrap();
2311
2312        let detector = Detector::new(&dir);
2313        let files = detector.discover(&dir).unwrap();
2314
2315        let doc_count = files
2316            .iter()
2317            .filter(|f| f.file_type == FileType::Document)
2318            .count();
2319        assert_eq!(doc_count, 2);
2320        teardown(&dir);
2321    }
2322
2323    #[test]
2324    fn test_detect_incremental() {
2325        let dir = setup_test_dir("detect_incremental");
2326        fs::write(dir.join("main.py"), "v1").unwrap();
2327        fs::write(dir.join("utils.py"), "v1").unwrap();
2328
2329        let detector = Detector::new(&dir);
2330        let all = detector.discover(&dir).unwrap();
2331        assert_eq!(all.len(), 2);
2332
2333        fs::write(dir.join("new.py"), "new").unwrap();
2334
2335        let prev: Vec<String> = all.iter().map(|f| f.relative_path.clone()).collect();
2336        let incremental = detector.detect_incremental(&dir, &prev).unwrap();
2337        assert_eq!(incremental.len(), 1);
2338        assert!(incremental[0].relative_path.ends_with("new.py"));
2339        teardown(&dir);
2340    }
2341
2342    #[test]
2343    fn test_detect_unknown_extension_skipped() {
2344        let dir = setup_test_dir("detect_unknown_ext");
2345        fs::write(dir.join("data.bin"), "\x00\x01\x02").unwrap();
2346        fs::write(dir.join("main.py"), "print('ok')").unwrap();
2347        fs::write(dir.join("image.png"), "PNG").unwrap();
2348
2349        let detector = Detector::new(&dir);
2350        let files = detector.discover(&dir).unwrap();
2351
2352        assert_eq!(files.len(), 2);
2353        let types: Vec<FileType> = files.iter().map(|f| f.file_type).collect();
2354        assert!(types.contains(&FileType::Code));
2355        assert!(types.contains(&FileType::Image));
2356        teardown(&dir);
2357    }
2358
2359    #[test]
2360    fn test_detect_lock_files_skipped() {
2361        let dir = setup_test_dir("detect_lock_files");
2362        fs::write(dir.join("package-lock.json"), "{}").unwrap();
2363        fs::write(dir.join("Cargo.lock"), "").unwrap();
2364        fs::write(dir.join("main.py"), "print('ok')").unwrap();
2365
2366        let detector = Detector::new(&dir);
2367        let files = detector.discover(&dir).unwrap();
2368
2369        assert_eq!(files.len(), 1);
2370        assert_eq!(files[0].relative_path, "main.py");
2371        teardown(&dir);
2372    }
2373
2374    #[test]
2375    fn test_detect_skip_files_constants() {
2376        assert!(SKIP_FILES.contains(&"package-lock.json"));
2377        assert!(SKIP_FILES.contains(&"yarn.lock"));
2378        assert!(SKIP_FILES.contains(&"pnpm-lock.yaml"));
2379        assert!(SKIP_FILES.contains(&"Cargo.lock"));
2380        assert!(SKIP_FILES.contains(&"poetry.lock"));
2381        assert!(SKIP_FILES.contains(&"Gemfile.lock"));
2382        assert!(SKIP_FILES.contains(&"composer.lock"));
2383        assert!(SKIP_FILES.contains(&"go.sum"));
2384        assert!(SKIP_FILES.contains(&"go.work.sum"));
2385    }
2386
2387    #[test]
2388    fn test_detect_sensitive_patterns() {
2389        let dir = setup_test_dir("detect_sensitive_patterns");
2390        fs::write(dir.join("id_rsa"), "key").unwrap();
2391        fs::write(dir.join("credentials.txt"), "user:pass").unwrap();
2392        fs::write(dir.join(".env.local"), "SECRET=1").unwrap();
2393        fs::write(dir.join("cert.pem"), "cert").unwrap();
2394        fs::write(dir.join("main.py"), "ok").unwrap();
2395
2396        let detector = Detector::new(&dir);
2397        let files = detector.discover(&dir).unwrap();
2398
2399        assert_eq!(files.len(), 1);
2400        assert_eq!(files[0].relative_path, "main.py");
2401        teardown(&dir);
2402    }
2403
2404    #[test]
2405    fn test_detect_build_noise_removed() {
2406        assert!(
2407            !SKIP_DIRS.contains(&"build"),
2408            "build should NOT be in SKIP_DIRS"
2409        );
2410    }
2411
2412    // ---------------------------------------------------------------------------
2413    // Java package allowlist: target / out under src/{main,test}/java
2414    // ---------------------------------------------------------------------------
2415
2416    #[test]
2417    fn test_is_noise_dir_target_no_context_is_noise() {
2418        assert!(is_noise_dir("target", None));
2419    }
2420
2421    #[test]
2422    fn test_is_noise_dir_out_no_context_is_noise() {
2423        assert!(is_noise_dir("out", None));
2424    }
2425
2426    #[test]
2427    fn test_is_noise_dir_target_under_src_main_java_not_noise() {
2428        let p = std::path::Path::new("src/main/java/com/example/target");
2429        assert!(!is_noise_dir("target", Some(p)));
2430    }
2431
2432    #[test]
2433    fn test_is_noise_dir_out_under_src_main_java_not_noise() {
2434        let p = std::path::Path::new("src/main/java/com/example/out");
2435        assert!(!is_noise_dir("out", Some(p)));
2436    }
2437
2438    #[test]
2439    fn test_is_noise_dir_target_under_src_test_java_not_noise() {
2440        let p = std::path::Path::new("src/test/java/com/example/target");
2441        assert!(!is_noise_dir("target", Some(p)));
2442    }
2443
2444    #[test]
2445    fn test_is_noise_dir_out_under_src_test_java_not_noise() {
2446        let p = std::path::Path::new("src/test/java/com/example/out");
2447        assert!(!is_noise_dir("out", Some(p)));
2448    }
2449
2450    #[test]
2451    fn test_is_noise_dir_target_outside_java_root_is_noise() {
2452        let p = std::path::Path::new("build/target");
2453        assert!(is_noise_dir("target", Some(p)));
2454    }
2455
2456    #[test]
2457    fn test_is_noise_dir_out_outside_java_root_is_noise() {
2458        let p = std::path::Path::new("generated/out");
2459        assert!(is_noise_dir("out", Some(p)));
2460    }
2461
2462    #[test]
2463    fn test_detect_target_package_under_java_discovered() {
2464        let dir = setup_test_dir("detect_java_target_pkg");
2465        let pkg = dir.join("src/main/java/com/example/target");
2466        fs::create_dir_all(&pkg).unwrap();
2467        fs::write(
2468            pkg.join("Foo.java"),
2469            "package com.example.target; public class Foo {}",
2470        )
2471        .unwrap();
2472        fs::write(dir.join("main.py"), "print('ok')").unwrap();
2473
2474        let detector = Detector::new(&dir);
2475        let files = detector.discover(&dir).unwrap();
2476
2477        let java_files: Vec<_> = files
2478            .iter()
2479            .filter(|f| f.relative_path.ends_with(".java"))
2480            .collect();
2481        assert_eq!(
2482            java_files.len(),
2483            1,
2484            "Foo.java inside target package must be discovered"
2485        );
2486        assert_eq!(
2487            java_files[0].relative_path,
2488            "src/main/java/com/example/target/Foo.java"
2489        );
2490        teardown(&dir);
2491    }
2492
2493    #[test]
2494    fn test_detect_out_package_under_java_discovered() {
2495        let dir = setup_test_dir("detect_java_out_pkg");
2496        let pkg = dir.join("src/main/java/com/example/out");
2497        fs::create_dir_all(&pkg).unwrap();
2498        fs::write(
2499            pkg.join("Bar.java"),
2500            "package com.example.out; public class Bar {}",
2501        )
2502        .unwrap();
2503
2504        let detector = Detector::new(&dir);
2505        let files = detector.discover(&dir).unwrap();
2506
2507        let java_files: Vec<_> = files
2508            .iter()
2509            .filter(|f| f.relative_path.ends_with(".java"))
2510            .collect();
2511        assert_eq!(
2512            java_files.len(),
2513            1,
2514            "Bar.java inside out package must be discovered"
2515        );
2516        teardown(&dir);
2517    }
2518
2519    #[test]
2520    fn test_detect_target_package_under_test_java_discovered() {
2521        let dir = setup_test_dir("detect_java_target_test_pkg");
2522        let pkg = dir.join("src/test/java/com/example/target");
2523        fs::create_dir_all(&pkg).unwrap();
2524        fs::write(
2525            pkg.join("FooTest.java"),
2526            "package com.example.target; public class FooTest {}",
2527        )
2528        .unwrap();
2529
2530        let detector = Detector::new(&dir);
2531        let files = detector.discover(&dir).unwrap();
2532
2533        let java_files: Vec<_> = files
2534            .iter()
2535            .filter(|f| f.relative_path.ends_with(".java"))
2536            .collect();
2537        assert_eq!(
2538            java_files.len(),
2539            1,
2540            "FooTest.java inside target package under src/test/java must be discovered"
2541        );
2542        teardown(&dir);
2543    }
2544
2545    #[test]
2546    fn test_detect_bare_target_dir_still_skipped() {
2547        let dir = setup_test_dir("detect_bare_target");
2548        fs::create_dir_all(dir.join("target/classes")).unwrap();
2549        fs::write(dir.join("target/classes/Foo.class"), "compiled").unwrap();
2550        fs::write(dir.join("main.py"), "print('ok')").unwrap();
2551
2552        let detector = Detector::new(&dir);
2553        let files = detector.discover(&dir).unwrap();
2554
2555        assert_eq!(files.len(), 1);
2556        assert_eq!(files[0].relative_path, "main.py");
2557        teardown(&dir);
2558    }
2559
2560    #[test]
2561    fn test_detect_bare_out_dir_still_skipped() {
2562        let dir = setup_test_dir("detect_bare_out");
2563        fs::create_dir_all(dir.join("out/production")).unwrap();
2564        fs::write(dir.join("out/production/Main.class"), "compiled").unwrap();
2565        fs::write(dir.join("main.py"), "print('ok')").unwrap();
2566
2567        let detector = Detector::new(&dir);
2568        let files = detector.discover(&dir).unwrap();
2569
2570        assert_eq!(files.len(), 1);
2571        assert_eq!(files[0].relative_path, "main.py");
2572        teardown(&dir);
2573    }
2574
2575    #[test]
2576    fn test_detect_git_dir_ignored() {
2577        let dir = setup_test_dir("detect_git");
2578        fs::create_dir_all(dir.join(".git/objects")).unwrap();
2579        fs::write(dir.join(".git/config"), "[core]").unwrap();
2580        fs::write(dir.join("main.py"), "ok").unwrap();
2581
2582        let detector = Detector::new(&dir);
2583        let files = detector.discover(&dir).unwrap();
2584
2585        assert_eq!(files.len(), 1);
2586        assert_eq!(files[0].relative_path, "main.py");
2587        teardown(&dir);
2588    }
2589
2590    #[test]
2591    fn test_detect_hidden_dirs_skipped() {
2592        let dir = setup_test_dir("detect_hidden");
2593        fs::create_dir_all(dir.join(".venv")).unwrap();
2594        fs::write(dir.join(".venv/lib.py"), "ignored").unwrap();
2595        fs::create_dir_all(dir.join(".cache")).unwrap();
2596        fs::write(dir.join(".cache/data"), "ignored").unwrap();
2597        fs::write(dir.join("main.py"), "ok").unwrap();
2598
2599        let detector = Detector::new(&dir);
2600        let files = detector.discover(&dir).unwrap();
2601
2602        assert_eq!(files.len(), 1);
2603        assert_eq!(files[0].relative_path, "main.py");
2604        teardown(&dir);
2605    }
2606
2607    #[test]
2608    fn test_detect_idea_dir_skipped() {
2609        let dir = setup_test_dir("detect_idea");
2610        fs::create_dir_all(dir.join(".idea")).unwrap();
2611        fs::write(dir.join(".idea/workspace.xml"), "ignored").unwrap();
2612        fs::write(dir.join("main.py"), "ok").unwrap();
2613
2614        let detector = Detector::new(&dir);
2615        let files = detector.discover(&dir).unwrap();
2616
2617        assert_eq!(files.len(), 1);
2618        assert_eq!(files[0].relative_path, "main.py");
2619        teardown(&dir);
2620    }
2621
2622    #[test]
2623    fn test_detect_deeply_nested() {
2624        let dir = setup_test_dir("detect_deep");
2625        let mut current = dir.clone();
2626        for i in 0..20 {
2627            current = current.join(format!("nested_{i}"));
2628            fs::create_dir_all(&current).unwrap();
2629        }
2630        fs::write(current.join("deep.py"), "x = 1").unwrap();
2631
2632        let detector = Detector::new(&dir);
2633        let files = detector.discover(&dir).unwrap();
2634
2635        assert_eq!(files.len(), 1);
2636        teardown(&dir);
2637    }
2638
2639    #[test]
2640    fn test_detect_only_hidden_dirs() {
2641        let dir = setup_test_dir("detect_only_hidden");
2642        fs::create_dir_all(dir.join(".hidden")).unwrap();
2643        fs::write(dir.join(".hidden/secret.py"), "x = 1").unwrap();
2644
2645        let detector = Detector::new(&dir);
2646        let files = detector.discover(&dir).unwrap();
2647
2648        assert!(files.is_empty());
2649        teardown(&dir);
2650    }
2651
2652    #[test]
2653    fn test_detect_dotfiles_visible() {
2654        let dir = setup_test_dir("detect_dotfiles");
2655        fs::write(dir.join(".gitignore"), "*.log").unwrap();
2656        fs::write(dir.join("main.py"), "ok").unwrap();
2657
2658        let detector = Detector::new(&dir);
2659        let files = detector.discover(&dir).unwrap();
2660
2661        assert_eq!(files.len(), 1);
2662        assert_eq!(files[0].file_type, FileType::Code);
2663        teardown(&dir);
2664    }
2665
2666    #[test]
2667    fn test_detect_skips_nested_git_repo() {
2668        let dir = setup_test_dir("detect_nested_git");
2669        fs::create_dir_all(dir.join(".git")).unwrap();
2670        let nested = dir.join("vendor").join("dep");
2671        fs::create_dir_all(nested.join(".git")).unwrap();
2672        fs::write(nested.join("lib.py"), "x = 1").unwrap();
2673        fs::write(dir.join("main.py"), "y = 2").unwrap();
2674
2675        let result = detect(&dir, Some(false), &[]);
2676        let code = &result.files["code"];
2677        assert!(code.iter().any(|f| f.contains("main.py")));
2678        assert!(!code.iter().any(|f| f.contains("lib.py")));
2679        teardown(&dir);
2680    }
2681
2682    #[test]
2683    fn test_detect_skips_nested_git_repo_at_depth_one() {
2684        let dir = setup_test_dir("detect_nested_git_d1");
2685        fs::create_dir_all(dir.join(".git")).unwrap();
2686        let repo = dir.join("submodule");
2687        fs::create_dir_all(repo.join("src")).unwrap();
2688        fs::create_dir_all(repo.join(".git")).unwrap();
2689        fs::write(repo.join("src").join("main.rs"), "fn main() {}").unwrap();
2690        fs::write(dir.join("app.py"), "x = 1").unwrap();
2691
2692        let result = detect(&dir, Some(false), &[]);
2693        let code = &result.files["code"];
2694        assert!(code.iter().any(|f| f.contains("app.py")));
2695        assert!(!code.iter().any(|f| f.contains("submodule")));
2696        teardown(&dir);
2697    }
2698
2699    #[test]
2700    fn test_detect_root_git_not_skipped() {
2701        let dir = setup_test_dir("detect_root_git_ok");
2702        fs::create_dir_all(dir.join(".git")).unwrap();
2703        fs::write(dir.join("main.py"), "x = 1").unwrap();
2704
2705        let result = detect(&dir, Some(false), &[]);
2706        assert!(result.files["code"].iter().any(|f| f.contains("main.py")));
2707        teardown(&dir);
2708    }
2709}