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