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