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
10const 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
38const 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
96static 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
129const 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
156const 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
165const VCS_MARKERS: &[&str] = &[".git", ".hg", ".svn", "_darcs", ".fossil"];
170
171const NEEDS_GRAPH_MIN: usize = 5;
173
174#[derive(Debug, Clone)]
179pub struct DiscoveredFile {
180 pub path: PathBuf,
181 pub file_type: FileType,
182 pub relative_path: String,
183}
184
185#[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
200pub 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
332pub 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
424pub 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
434fn 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
511fn 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 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 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 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
558pub 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 if arg == "--" {
747 return args[i + 1..].to_vec();
748 }
749
750 if arg.starts_with("--") {
752 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 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 if matches!(
775 arg,
776 "--ignore-environment" | "--null" | "--debug" | "--list-signal-handling"
777 ) {
778 i += 1;
779 continue;
780 }
781
782 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 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 return vec![];
806 }
807
808 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 '-' | 'i' | '0' | 'v' => {
822 j += 1;
823 }
824 'u' | 'C' | 'P' | 'a' => {
826 if j + 1 < cluster_bytes.len() {
827 i += 1;
829 } else {
830 i += 2;
832 }
833 continue 'outer;
834 }
835 'S' => {
837 if !allow_split {
838 return vec![];
839 }
840 if j + 1 < cluster_bytes.len() {
841 let payload = &cluster[j + 1..];
843 let sub = shlex_split(payload);
844 return env_command_args_inner(&sub, false);
845 } else {
846 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 _ => {
857 return vec![];
858 }
859 }
860 }
861 }
862
863 if arg == "-" {
865 i += 1;
866 continue;
867 }
868
869 if arg.contains('=') {
871 i += 1;
872 continue;
873 }
874
875 return args[i..].to_vec();
877 }
878 vec![]
879}
880
881#[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 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 #[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 #[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 #[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 let p = Path::new("credentials");
1749 assert!(is_sensitive(p));
1750 }
1751
1752 #[test]
1753 fn test_sensitive_secret_handler_txt() {
1754 assert!(is_sensitive(Path::new("secret_handler.txt")));
1756 }
1757
1758 #[test]
1759 fn test_sensitive_token_config_yaml() {
1760 assert!(is_sensitive(Path::new("token_config.yaml")));
1762 }
1763
1764 #[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 #[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 #[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(¤t).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}