1pub mod worktree;
4
5use blake3::Hasher;
6use ignore::{DirEntry, WalkBuilder, WalkState, gitignore::GitignoreBuilder};
7use projectatlas_core::language::{
8 LANGUAGE_CONTENT_DETECTION_MAX_BYTES, LanguageDetection, LanguageDetectionRequest,
9 detect_language_request, language_capability,
10};
11use projectatlas_core::{
12 CoreError, IndexCancellation, IndexWorkControl, IndexWorkFailure, IndexWorkResource,
13 IndexWorkStage, MAX_GIT_WORKTREE_REGISTRATIONS, Node, NodeKind, normalize_repo_path,
14 normalized_extension, normalized_parent,
15};
16use std::collections::{BTreeMap, BTreeSet};
17use std::fs;
18use std::io::{self, Read};
19use std::path::{Path, PathBuf};
20use std::sync::{
21 Arc, Mutex,
22 atomic::{AtomicU64, Ordering},
23};
24use std::thread;
25use std::time::{Duration, SystemTime, UNIX_EPOCH};
26use thiserror::Error;
27
28const RESERVED_METADATA_FILE_NAMES: &[&str] = &[".purpose"];
30
31const INDEXED_PROJECTATLAS_INPUT_PATHS: &[&str] = &[
33 ".projectatlas",
34 ".projectatlas/config.toml",
35 ".projectatlas/projectatlas-nonsource-files.toon",
36 ".projectatlas/projectatlas-purpose-review.json",
37];
38
39const SCAN_WORKER_SAFE_CEILING: usize = 32;
41const DEFAULT_SCAN_MAX_ENTRIES: u64 = 1_000_000;
43const DEFAULT_SCAN_MAX_SOURCE_BYTES: u64 = 16 * 1_024 * 1_024 * 1_024;
45const DEFAULT_SCAN_TIMEOUT: Duration = Duration::from_mins(30);
47const HASH_BUFFER_BYTES: usize = 8_192;
49const GIT_DIRECTORY_POINTER_MAX_BYTES: u64 = 64 * 1_024;
51#[derive(Debug, Error)]
53pub enum FsError {
54 #[error("{0}")]
56 Core(#[from] CoreError),
57 #[error("filesystem error for {path:?}: {source}")]
59 Io {
60 path: PathBuf,
62 source: io::Error,
64 },
65 #[error("scan root is not a directory: {0:?}")]
67 RootNotDirectory(PathBuf),
68 #[error("repository boundary could not be validated for {path:?}: {source}")]
70 RepositoryBoundary {
71 path: PathBuf,
73 source: io::Error,
75 },
76 #[error("{0}")]
78 IndexWork(#[from] IndexWorkFailure),
79}
80
81pub type FsResult<T> = Result<T, FsError>;
83
84#[derive(Clone, Debug)]
86pub struct ScanOptions {
87 pub exclude_dir_names: Vec<String>,
89 pub exclude_dir_suffixes: Vec<String>,
91 pub exclude_path_prefixes: Vec<String>,
93 pub language_overrides: BTreeMap<String, String>,
95 pub admit_optional_languages: bool,
97}
98
99impl Default for ScanOptions {
100 fn default() -> Self {
101 Self {
102 exclude_dir_names: vec![
103 ".git".to_string(),
104 ".projectatlas".to_string(),
105 ".venv".to_string(),
106 "__pycache__".to_string(),
107 "node_modules".to_string(),
108 "dist".to_string(),
109 "build".to_string(),
110 "target".to_string(),
111 ],
112 exclude_dir_suffixes: Vec::new(),
113 exclude_path_prefixes: Vec::new(),
114 language_overrides: BTreeMap::new(),
115 admit_optional_languages: false,
116 }
117 }
118}
119
120impl ScanOptions {
121 #[must_use]
123 pub fn excludes_relative_path(&self, relative_path: &str) -> bool {
124 if is_indexed_projectatlas_input(relative_path) {
125 return false;
126 }
127 has_excluded_directory_component(relative_path, self)
128 || has_excluded_path_prefix(relative_path, self)
129 }
130}
131
132#[derive(Clone, Debug)]
134pub struct RootScanPolicy {
135 root: PathBuf,
137 options: ScanOptions,
139}
140
141impl RootScanPolicy {
142 pub fn discover(
149 root: &Path,
150 options: &ScanOptions,
151 control: &IndexWorkControl,
152 ) -> FsResult<Self> {
153 control.check(IndexWorkStage::RepositoryTraversal)?;
154 if !root.is_dir() {
155 return Err(FsError::RootNotDirectory(root.to_path_buf()));
156 }
157 let root = root.canonicalize().map_err(|source| FsError::Io {
158 path: root.to_path_buf(),
159 source,
160 })?;
161 let options = scan_options_for_root(&root, options, control)?;
162 Ok(Self { root, options })
163 }
164
165 pub fn excludes_path(&self, path: &Path) -> FsResult<bool> {
173 let absolute = if path.is_absolute() {
174 path.to_path_buf()
175 } else {
176 self.root.join(path)
177 };
178 if should_skip_path(&self.root, &absolute, &self.options) {
179 return Ok(true);
180 }
181 standard_ignore_excludes_path(&self.root, &absolute)
182 }
183}
184
185#[derive(Clone, Copy, Debug, Eq, PartialEq)]
187pub struct ScanLimits {
188 entries: u64,
190 source_bytes: u64,
192 workers: usize,
194}
195
196impl ScanLimits {
197 #[must_use]
199 pub const fn new(max_entries: u64, max_source_bytes: u64, max_workers: usize) -> Self {
200 Self {
201 entries: max_entries,
202 source_bytes: max_source_bytes,
203 workers: max_workers,
204 }
205 }
206
207 #[must_use]
209 pub const fn max_entries(self) -> u64 {
210 self.entries
211 }
212
213 #[must_use]
215 pub const fn max_source_bytes(self) -> u64 {
216 self.source_bytes
217 }
218
219 #[must_use]
221 pub const fn max_workers(self) -> usize {
222 self.workers
223 }
224
225 #[must_use]
227 pub fn effective_workers(self) -> usize {
228 let available = thread::available_parallelism().map_or(1, usize::from);
229 self.workers.min(available).min(SCAN_WORKER_SAFE_CEILING)
230 }
231}
232
233impl Default for ScanLimits {
234 fn default() -> Self {
235 Self::new(
236 DEFAULT_SCAN_MAX_ENTRIES,
237 DEFAULT_SCAN_MAX_SOURCE_BYTES,
238 SCAN_WORKER_SAFE_CEILING,
239 )
240 }
241}
242
243#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
245pub struct ScanWork {
246 pub entries: u64,
248 pub source_bytes: u64,
250}
251
252#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct ScanOutcome {
255 pub nodes: Vec<Node>,
257 pub work: ScanWork,
259}
260
261fn effective_scan_workers(limits: ScanLimits, control: &IndexWorkControl) -> usize {
263 let workers = limits.effective_workers();
264 control
265 .worker_ceiling()
266 .map_or(workers, |ceiling| workers.min(ceiling))
267}
268
269#[derive(Debug)]
271struct ScanBudget {
272 limits: ScanLimits,
274 control: IndexWorkControl,
276 entries: AtomicU64,
278 source_bytes: AtomicU64,
280}
281
282impl ScanBudget {
283 fn new(limits: ScanLimits, control: IndexWorkControl) -> Self {
285 Self {
286 limits,
287 control,
288 entries: AtomicU64::new(0),
289 source_bytes: AtomicU64::new(0),
290 }
291 }
292
293 fn claim_entry(&self) -> Result<(), IndexWorkFailure> {
295 self.control.check(IndexWorkStage::RepositoryTraversal)?;
296 claim_resource(
297 &self.entries,
298 1,
299 self.limits.entries,
300 IndexWorkStage::RepositoryTraversal,
301 IndexWorkResource::Entries,
302 )
303 }
304
305 fn claim_source_bytes(&self, bytes: u64) -> Result<(), IndexWorkFailure> {
307 claim_resource(
308 &self.source_bytes,
309 bytes,
310 self.limits.source_bytes,
311 IndexWorkStage::SourceHash,
312 IndexWorkResource::SourceBytes,
313 )
314 }
315
316 fn work(&self) -> ScanWork {
318 ScanWork {
319 entries: self.entries.load(Ordering::Relaxed),
320 source_bytes: self.source_bytes.load(Ordering::Relaxed),
321 }
322 }
323}
324
325fn claim_resource(
327 counter: &AtomicU64,
328 amount: u64,
329 limit: u64,
330 stage: IndexWorkStage,
331 resource: IndexWorkResource,
332) -> Result<(), IndexWorkFailure> {
333 counter
334 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
335 current
336 .checked_add(amount)
337 .filter(|observed| *observed <= limit)
338 })
339 .map(|_previous| ())
340 .map_err(|current| {
341 IndexWorkFailure::resource_limit(stage, resource, limit, current.saturating_add(amount))
342 })
343}
344
345pub fn scan_repo(root: &Path, options: &ScanOptions) -> FsResult<Vec<Node>> {
352 let control = IndexWorkControl::new(IndexCancellation::new(), Some(DEFAULT_SCAN_TIMEOUT));
353 scan_repo_controlled(root, options, ScanLimits::default(), &control)
354}
355
356pub fn scan_repo_controlled(
367 root: &Path,
368 options: &ScanOptions,
369 limits: ScanLimits,
370 control: &IndexWorkControl,
371) -> FsResult<Vec<Node>> {
372 scan_repo_controlled_with_work(root, options, limits, control).map(|outcome| outcome.nodes)
373}
374
375pub fn scan_repo_controlled_with_work(
386 root: &Path,
387 options: &ScanOptions,
388 limits: ScanLimits,
389 control: &IndexWorkControl,
390) -> FsResult<ScanOutcome> {
391 let policy = RootScanPolicy::discover(root, options, control)?;
392 let root = policy.root;
393 let options = policy.options;
394 let mut builder = WalkBuilder::new(&root);
395 builder
396 .hidden(false)
397 .git_ignore(true)
398 .git_exclude(true)
399 .require_git(false);
400 let effective_workers = effective_scan_workers(limits, control);
401 if effective_workers == 0 {
402 return Err(IndexWorkFailure::resource_limit(
403 IndexWorkStage::RepositoryTraversal,
404 IndexWorkResource::Workers,
405 0,
406 1,
407 )
408 .into());
409 }
410 builder.threads(effective_workers);
411
412 let nodes = Arc::new(Mutex::new(Vec::new()));
413 let errors = Arc::new(Mutex::new(Vec::new()));
414 let budget = Arc::new(ScanBudget::new(limits, control.clone()));
415 builder.build_parallel().run(|| {
416 let root = root.clone();
417 let options = options.clone();
418 let nodes = Arc::clone(&nodes);
419 let errors = Arc::clone(&errors);
420 let budget = Arc::clone(&budget);
421 Box::new(move |result| {
422 if let Err(error) = budget.claim_entry() {
423 push_error(&errors, error.into());
424 return WalkState::Quit;
425 }
426 let entry = match result {
427 Ok(entry) => entry,
428 Err(error) => {
429 push_error(
430 &errors,
431 FsError::Io {
432 path: root.clone(),
433 source: io::Error::other(error.to_string()),
434 },
435 );
436 return WalkState::Quit;
437 }
438 };
439 let path = entry.path();
440 if should_skip_path(&root, path, &options) {
441 return skip_entry_state(&entry);
442 }
443 match scanned_node(&root, path, &options, &budget) {
444 Ok(Some(node)) => {
445 if let Ok(mut guard) = nodes.lock() {
446 guard.push(node);
447 WalkState::Continue
448 } else {
449 push_error(&errors, lock_error(&root));
450 WalkState::Quit
451 }
452 }
453 Ok(None) => WalkState::Continue,
454 Err(error) => {
455 push_error(&errors, error);
456 WalkState::Quit
457 }
458 }
459 })
460 });
461 let errors = Arc::try_unwrap(errors)
462 .map_err(|_remaining| state_error(&root, "parallel scanner error state still shared"))?;
463 let mut errors = errors.into_inner().map_err(|source| {
464 state_error(
465 &root,
466 &format!("parallel scanner error state lock failed: {source}"),
467 )
468 })?;
469 if let Some(error) = errors.pop() {
470 return Err(error);
471 }
472 control.check(IndexWorkStage::ScanFinalization)?;
473 let nodes = Arc::try_unwrap(nodes)
474 .map_err(|_remaining| state_error(&root, "parallel scanner node state still shared"))?;
475 let mut nodes = nodes.into_inner().map_err(|source| {
476 state_error(
477 &root,
478 &format!("parallel scanner node state lock failed: {source}"),
479 )
480 })?;
481 nodes.sort_by(|left, right| left.path.cmp(&right.path));
482 control.check(IndexWorkStage::ScanFinalization)?;
483 Ok(ScanOutcome {
484 nodes,
485 work: budget.work(),
486 })
487}
488
489pub fn scan_path(root: &Path, path: &Path, options: &ScanOptions) -> FsResult<Option<Node>> {
495 let control = IndexWorkControl::new(IndexCancellation::new(), Some(DEFAULT_SCAN_TIMEOUT));
496 scan_path_controlled(root, path, options, ScanLimits::default(), &control)
497}
498
499pub fn scan_path_controlled(
506 root: &Path,
507 path: &Path,
508 options: &ScanOptions,
509 limits: ScanLimits,
510 control: &IndexWorkControl,
511) -> FsResult<Option<Node>> {
512 let policy = RootScanPolicy::discover(root, options, control)?;
513 scan_path_with_policy_controlled(&policy, path, limits, control)
514}
515
516pub fn scan_path_with_policy_controlled(
523 policy: &RootScanPolicy,
524 path: &Path,
525 limits: ScanLimits,
526 control: &IndexWorkControl,
527) -> FsResult<Option<Node>> {
528 let budget = ScanBudget::new(limits, control.clone());
529 budget.claim_entry()?;
530 let root = &policy.root;
531 let options = &policy.options;
532 let absolute = if path.is_absolute() {
533 path.to_path_buf()
534 } else {
535 root.join(path)
536 };
537 if !absolute.exists() {
538 control.check(IndexWorkStage::ScanFinalization)?;
539 return Ok(None);
540 }
541 let symlink_checked_absolute = path_for_symlink_component_check(&absolute)?;
542 if path_has_symlink_component(root, &symlink_checked_absolute)? {
543 control.check(IndexWorkStage::ScanFinalization)?;
544 return Ok(None);
545 }
546 let absolute = symlink_checked_absolute
547 .canonicalize()
548 .map_err(|source| FsError::Io {
549 path: symlink_checked_absolute.clone(),
550 source,
551 })?;
552 if !absolute.starts_with(root) {
553 control.check(IndexWorkStage::ScanFinalization)?;
554 return Ok(None);
555 }
556 if policy.excludes_path(&absolute)? {
557 control.check(IndexWorkStage::ScanFinalization)?;
558 return Ok(None);
559 }
560 let node = scanned_node(root, &absolute, options, &budget)?;
561 control.check(IndexWorkStage::ScanFinalization)?;
562 Ok(node)
563}
564
565fn path_for_symlink_component_check(absolute: &Path) -> FsResult<PathBuf> {
567 if absolute.is_dir() {
568 return absolute.canonicalize().map_err(|source| FsError::Io {
569 path: absolute.to_path_buf(),
570 source,
571 });
572 }
573 let Some(parent) = absolute.parent() else {
574 return Ok(absolute.to_path_buf());
575 };
576 let parent = parent.canonicalize().map_err(|source| FsError::Io {
577 path: parent.to_path_buf(),
578 source,
579 })?;
580 if let Some(file_name) = absolute.file_name() {
581 Ok(parent.join(file_name))
582 } else {
583 Ok(parent)
584 }
585}
586
587fn path_has_symlink_component(root: &Path, absolute: &Path) -> FsResult<bool> {
589 let Ok(relative) = absolute.strip_prefix(root) else {
590 return Ok(true);
591 };
592 let mut current = root.to_path_buf();
593 for component in relative.components() {
594 current.push(component.as_os_str());
595 if fs::symlink_metadata(¤t)
596 .map_err(|source| FsError::Io {
597 path: current.clone(),
598 source,
599 })?
600 .file_type()
601 .is_symlink()
602 {
603 return Ok(true);
604 }
605 }
606 Ok(false)
607}
608
609pub fn gitignore_excludes_path(root: &Path, path: &Path) -> FsResult<bool> {
619 let input_root = root;
620 let root = root.canonicalize().map_err(|source| FsError::Io {
621 path: root.to_path_buf(),
622 source,
623 })?;
624 let absolute = if path.is_absolute() {
625 if let Ok(relative) = path.strip_prefix(input_root) {
626 root.join(relative)
627 } else if let Ok(relative) = path.strip_prefix(&root) {
628 root.join(relative)
629 } else {
630 path.to_path_buf()
631 }
632 } else {
633 root.join(path)
634 };
635 let absolute = if absolute.exists() {
636 absolute.canonicalize().map_err(|source| FsError::Io {
637 path: absolute.clone(),
638 source,
639 })?
640 } else {
641 absolute
642 };
643 Ok(ignore_family_match(&root, &absolute, ".gitignore")?.unwrap_or(false))
644}
645
646fn standard_ignore_excludes_path(root: &Path, path: &Path) -> FsResult<bool> {
648 let relative = normalize_repo_path(root, path)?;
649 if relative == "." || relative.split('/').any(|component| component == "..") {
650 return Ok(false);
651 }
652 if let Some(ignored) = ignore_family_match(root, path, ".ignore")? {
653 return Ok(ignored);
654 }
655 if let Some(ignored) = ignore_family_match(root, path, ".gitignore")? {
656 return Ok(ignored);
657 }
658 if let Some(common_git_dir) = common_git_directory(root)?
659 && let Some(ignored) = ignore_file_match(root, path, &common_git_dir.join("info/exclude"))?
660 {
661 return Ok(ignored);
662 }
663 let (global, error) = GitignoreBuilder::new(root).build_global();
664 if let Some(error) = error {
665 return Err(FsError::Io {
666 path: git_global_excludes_path().unwrap_or_else(|| root.to_path_buf()),
667 source: io::Error::other(error.to_string()),
668 });
669 }
670 Ok(ignore_match(&global, path).unwrap_or(false))
671}
672
673fn ignore_family_match(root: &Path, path: &Path, file_name: &str) -> FsResult<Option<bool>> {
675 let is_dir = path.metadata().is_ok_and(|metadata| metadata.is_dir());
676 let target_dir = if is_dir {
677 path
678 } else {
679 path.parent().unwrap_or(root)
680 };
681 let mut outcome = None;
682 for directory in gitignore_search_dirs(root, target_dir) {
683 let ignore_path = directory.join(file_name);
684 if let Some(matched) = ignore_file_match(&directory, path, &ignore_path)? {
685 outcome = Some(matched);
686 }
687 }
688 Ok(outcome)
689}
690
691fn ignore_file_match(root: &Path, path: &Path, source: &Path) -> FsResult<Option<bool>> {
693 if !source.exists() {
694 return Ok(None);
695 }
696 let mut builder = GitignoreBuilder::new(root);
697 if let Some(error) = builder.add(source) {
698 return Err(FsError::Io {
699 path: source.to_path_buf(),
700 source: io::Error::other(error.to_string()),
701 });
702 }
703 let matcher = builder.build().map_err(|error| FsError::Io {
704 path: source.to_path_buf(),
705 source: io::Error::other(error.to_string()),
706 })?;
707 Ok(ignore_match(&matcher, path))
708}
709
710fn ignore_match(matcher: &ignore::gitignore::Gitignore, path: &Path) -> Option<bool> {
712 let is_dir = path.metadata().is_ok_and(|metadata| metadata.is_dir());
713 let matched = matcher.matched_path_or_any_parents(path, is_dir);
714 if matched.is_ignore() {
715 Some(true)
716 } else if matched.is_whitelist() {
717 Some(false)
718 } else {
719 None
720 }
721}
722
723#[must_use]
728pub fn git_global_excludes_path() -> Option<PathBuf> {
729 ignore::gitignore::gitconfig_excludes_path()
730}
731
732pub fn source_selection_policy_paths(root: &Path) -> FsResult<Vec<PathBuf>> {
745 let control = IndexWorkControl::new(IndexCancellation::new(), Some(DEFAULT_SCAN_TIMEOUT));
746 source_selection_policy_paths_controlled(root, &control)
747}
748
749pub fn source_selection_policy_paths_controlled(
756 root: &Path,
757 control: &IndexWorkControl,
758) -> FsResult<Vec<PathBuf>> {
759 control.check(IndexWorkStage::RepositoryTraversal)?;
760 let root = root.canonicalize().map_err(|source| FsError::Io {
761 path: root.to_path_buf(),
762 source,
763 })?;
764 let mut paths = BTreeSet::new();
765 for ancestor in root.ancestors() {
766 control.check(IndexWorkStage::RepositoryTraversal)?;
767 paths.insert(ancestor.join(".gitignore"));
768 paths.insert(ancestor.join(".ignore"));
769 let git = ancestor.join(".git");
770 paths.insert(git.clone());
771 match fs::metadata(&git) {
772 Ok(metadata) if metadata.is_dir() => {
773 paths.insert(git.join("info").join("exclude"));
774 }
775 Ok(metadata) if metadata.is_file() => {
776 let directory = worktree::read_prefixed_pointer(&git, "gitdir:")
777 .map_err(git_pointer_io_error)?;
778 let directory = if directory.is_absolute() {
779 directory
780 } else {
781 ancestor.join(&directory)
782 };
783 paths.insert(directory.join("info").join("exclude"));
784 let common_dir_pointer = directory.join("commondir");
785 paths.insert(common_dir_pointer.clone());
786 if let Some(common_dir) =
787 read_git_directory_pointer(&common_dir_pointer, &directory)?
788 {
789 paths.insert(common_dir.join("info").join("exclude"));
790 }
791 }
792 Ok(_metadata) => {}
793 Err(source) if source.kind() == io::ErrorKind::NotFound => {}
794 Err(source) => {
795 return Err(FsError::Io { path: git, source });
796 }
797 }
798 }
799 if let Some(home) = home_directory() {
800 paths.insert(home.join(".gitconfig"));
801 paths.insert(home.join(".config").join("git").join("config"));
802 paths.insert(home.join(".config").join("git").join("ignore"));
803 }
804 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME")
805 .filter(|value| !value.is_empty())
806 .map(PathBuf::from)
807 {
808 paths.insert(xdg.join("git").join("config"));
809 paths.insert(xdg.join("git").join("ignore"));
810 }
811 if let Some(global_excludes) = git_global_excludes_path() {
812 paths.insert(global_excludes);
813 }
814 if let Some(common_git_dir) = common_git_directory(&root)? {
815 let registrations = common_git_dir.join("worktrees");
816 paths.insert(registrations.clone());
817 match fs::read_dir(®istrations) {
818 Ok(entries) => {
819 for (index, entry) in entries.enumerate() {
820 check_registered_worktree(control, index)?;
821 let entry = entry.map_err(|source| FsError::RepositoryBoundary {
822 path: registrations.clone(),
823 source,
824 })?;
825 paths.insert(entry.path().join("gitdir"));
826 }
827 }
828 Err(source) if source.kind() == io::ErrorKind::NotFound => {}
829 Err(source) => {
830 return Err(FsError::RepositoryBoundary {
831 path: registrations,
832 source,
833 });
834 }
835 }
836 }
837 Ok(paths.into_iter().collect())
838}
839
840fn read_git_directory_pointer(path: &Path, base: &Path) -> FsResult<Option<PathBuf>> {
842 let metadata = match fs::metadata(path) {
843 Ok(metadata) => metadata,
844 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
845 Err(source) => {
846 return Err(FsError::Io {
847 path: path.to_path_buf(),
848 source,
849 });
850 }
851 };
852 if !metadata.is_file() {
853 return Ok(None);
854 }
855 let value = worktree::read_plain_pointer(path).map_err(git_pointer_io_error)?;
856 Ok(Some(if value.is_absolute() {
857 value
858 } else {
859 base.join(value)
860 }))
861}
862
863fn git_pointer_io_error(issue: worktree::GitStructureIssue) -> FsError {
865 let (path, source) = issue.into_io_error();
866 FsError::Io { path, source }
867}
868
869fn git_pointer_boundary_error(issue: worktree::GitStructureIssue) -> FsError {
871 let (path, source) = issue.into_io_error();
872 FsError::RepositoryBoundary { path, source }
873}
874
875fn scan_options_for_root(
877 root: &Path,
878 options: &ScanOptions,
879 control: &IndexWorkControl,
880) -> FsResult<ScanOptions> {
881 let mut options = options.clone();
882 options
883 .exclude_path_prefixes
884 .extend(linked_worktree_excluded_prefixes(root, control)?);
885 Ok(options)
886}
887
888fn linked_worktree_excluded_prefixes(
890 root: &Path,
891 control: &IndexWorkControl,
892) -> FsResult<Vec<String>> {
893 let Some(common_git_dir) = common_git_directory(root)? else {
894 return Ok(Vec::new());
895 };
896 let registrations = common_git_dir.join("worktrees");
897 let entries = match fs::read_dir(®istrations) {
898 Ok(entries) => entries,
899 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
900 Err(source) => {
901 return Err(FsError::RepositoryBoundary {
902 path: registrations,
903 source,
904 });
905 }
906 };
907 let mut prefixes = BTreeSet::new();
908 for (index, entry) in entries.enumerate() {
909 check_registered_worktree(control, index)?;
910 let entry = entry.map_err(|source| FsError::RepositoryBoundary {
911 path: registrations.clone(),
912 source,
913 })?;
914 let file_type = entry
915 .file_type()
916 .map_err(|source| FsError::RepositoryBoundary {
917 path: entry.path(),
918 source,
919 })?;
920 if !file_type.is_dir() {
921 return Err(FsError::RepositoryBoundary {
922 path: entry.path(),
923 source: io::Error::new(
924 io::ErrorKind::InvalidData,
925 "registered worktree metadata entry is not a directory",
926 ),
927 });
928 }
929 let gitdir_path = entry.path().join("gitdir");
930 let git_control_path = read_repository_boundary_pointer(
931 &gitdir_path,
932 &entry.path(),
933 "registered worktree gitdir",
934 )?
935 .ok_or_else(|| FsError::RepositoryBoundary {
936 path: gitdir_path.clone(),
937 source: io::Error::new(
938 io::ErrorKind::InvalidData,
939 "registered worktree gitdir is missing",
940 ),
941 })?;
942 let git_control_path =
943 git_control_path
944 .canonicalize()
945 .map_err(|source| FsError::RepositoryBoundary {
946 path: gitdir_path.clone(),
947 source,
948 })?;
949 let git_control_metadata =
950 fs::metadata(&git_control_path).map_err(|source| FsError::RepositoryBoundary {
951 path: gitdir_path.clone(),
952 source,
953 })?;
954 if !git_control_metadata.is_file()
955 || git_control_path.file_name().and_then(|name| name.to_str()) != Some(".git")
956 {
957 return Err(FsError::RepositoryBoundary {
958 path: gitdir_path,
959 source: io::Error::new(
960 io::ErrorKind::InvalidData,
961 "registered worktree gitdir does not address a .git control file",
962 ),
963 });
964 }
965 let worktree_root = git_control_path
966 .parent()
967 .ok_or_else(|| FsError::RepositoryBoundary {
968 path: gitdir_path.clone(),
969 source: io::Error::new(
970 io::ErrorKind::InvalidData,
971 "registered worktree gitdir has no checkout parent",
972 ),
973 })?
974 .canonicalize()
975 .map_err(|source| FsError::RepositoryBoundary {
976 path: gitdir_path,
977 source,
978 })?;
979 if common_git_directory(&worktree_root)?.as_ref() != Some(&common_git_dir) {
980 return Err(FsError::RepositoryBoundary {
981 path: git_control_path,
982 source: io::Error::new(
983 io::ErrorKind::InvalidData,
984 "registered worktree does not resolve to the selected common Git directory",
985 ),
986 });
987 }
988 if worktree_root != root && worktree_root.starts_with(root) {
989 let prefix = normalize_repo_path(root, &worktree_root).map_err(FsError::Core)?;
990 if prefix != "." {
991 prefixes.insert(prefix);
992 }
993 }
994 }
995 Ok(prefixes.into_iter().collect())
996}
997
998fn check_registered_worktree(control: &IndexWorkControl, index: usize) -> FsResult<()> {
1000 control.check(IndexWorkStage::RepositoryTraversal)?;
1001 let observed = index.saturating_add(1);
1002 if observed > MAX_GIT_WORKTREE_REGISTRATIONS {
1003 return Err(IndexWorkFailure::resource_limit(
1004 IndexWorkStage::RepositoryTraversal,
1005 IndexWorkResource::Entries,
1006 u64::try_from(MAX_GIT_WORKTREE_REGISTRATIONS).unwrap_or(u64::MAX),
1007 u64::try_from(observed).unwrap_or(u64::MAX),
1008 )
1009 .into());
1010 }
1011 Ok(())
1012}
1013
1014fn common_git_directory(root: &Path) -> FsResult<Option<PathBuf>> {
1016 let git = root.join(".git");
1017 let metadata = match fs::metadata(&git) {
1018 Ok(metadata) => metadata,
1019 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
1020 Err(source) => {
1021 return Err(FsError::RepositoryBoundary { path: git, source });
1022 }
1023 };
1024 if metadata.is_dir() {
1025 return git
1026 .canonicalize()
1027 .map(Some)
1028 .map_err(|source| FsError::RepositoryBoundary { path: git, source });
1029 }
1030 if !metadata.is_file() {
1031 return Err(FsError::RepositoryBoundary {
1032 path: git,
1033 source: io::Error::new(
1034 io::ErrorKind::InvalidData,
1035 "repository .git control path is neither a file nor a directory",
1036 ),
1037 });
1038 }
1039 let git_dir =
1040 worktree::read_prefixed_pointer(&git, "gitdir:").map_err(git_pointer_boundary_error)?;
1041 let git_dir = if git_dir.is_absolute() {
1042 git_dir
1043 } else {
1044 root.join(&git_dir)
1045 };
1046 let git_dir = git_dir
1047 .canonicalize()
1048 .map_err(|source| FsError::RepositoryBoundary {
1049 path: git.clone(),
1050 source,
1051 })?;
1052 let common_dir_pointer = git_dir.join("commondir");
1053 let common_dir = read_repository_boundary_pointer(
1054 &common_dir_pointer,
1055 &git_dir,
1056 "linked-worktree commondir",
1057 )?
1058 .unwrap_or(git_dir);
1059 common_dir
1060 .canonicalize()
1061 .map(Some)
1062 .map_err(|source| FsError::RepositoryBoundary {
1063 path: common_dir_pointer,
1064 source,
1065 })
1066}
1067
1068fn read_repository_boundary_pointer(
1070 path: &Path,
1071 base: &Path,
1072 description: &str,
1073) -> FsResult<Option<PathBuf>> {
1074 let metadata = match fs::metadata(path) {
1075 Ok(metadata) => metadata,
1076 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
1077 Err(source) => {
1078 return Err(FsError::RepositoryBoundary {
1079 path: path.to_path_buf(),
1080 source,
1081 });
1082 }
1083 };
1084 if !metadata.is_file() {
1085 return Err(FsError::RepositoryBoundary {
1086 path: path.to_path_buf(),
1087 source: io::Error::new(
1088 io::ErrorKind::InvalidData,
1089 format!("{description} is not a file"),
1090 ),
1091 });
1092 }
1093 match read_git_directory_pointer(path, base) {
1094 Ok(Some(value)) => Ok(Some(value)),
1095 Ok(None) => Err(FsError::RepositoryBoundary {
1096 path: path.to_path_buf(),
1097 source: io::Error::new(
1098 io::ErrorKind::InvalidData,
1099 format!("{description} is empty"),
1100 ),
1101 }),
1102 Err(FsError::Io { path, source }) => Err(FsError::RepositoryBoundary { path, source }),
1103 Err(other) => Err(other),
1104 }
1105}
1106
1107fn home_directory() -> Option<PathBuf> {
1109 std::env::var_os("HOME")
1110 .or_else(|| std::env::var_os("USERPROFILE"))
1111 .filter(|value| !value.is_empty())
1112 .map(PathBuf::from)
1113}
1114
1115fn gitignore_search_dirs(root: &Path, target_dir: &Path) -> Vec<PathBuf> {
1117 let mut directories = Vec::new();
1118 let mut current = target_dir;
1119 loop {
1120 directories.push(current.to_path_buf());
1121 if current == root {
1122 break;
1123 }
1124 let Some(parent) = current.parent() else {
1125 break;
1126 };
1127 current = parent;
1128 }
1129 directories.reverse();
1130 directories
1131}
1132
1133fn skip_entry_state(entry: &DirEntry) -> WalkState {
1135 if entry
1136 .file_type()
1137 .is_some_and(|file_type| file_type.is_dir())
1138 {
1139 WalkState::Skip
1140 } else {
1141 WalkState::Continue
1142 }
1143}
1144
1145fn scanned_node(
1147 root: &Path,
1148 path: &Path,
1149 options: &ScanOptions,
1150 budget: &ScanBudget,
1151) -> FsResult<Option<Node>> {
1152 budget.control.check(IndexWorkStage::SourceMetadata)?;
1153 let metadata = fs::symlink_metadata(path).map_err(|source| FsError::Io {
1154 path: path.to_path_buf(),
1155 source,
1156 })?;
1157 if metadata.file_type().is_symlink() {
1158 return Ok(None);
1159 }
1160 if metadata.is_dir() {
1161 return folder_node(root, path).map(Some);
1162 }
1163 if metadata.is_file() {
1164 return file_node(root, path, &metadata, options, budget).map(Some);
1165 }
1166 Ok(None)
1167}
1168
1169fn push_error(errors: &Arc<Mutex<Vec<FsError>>>, error: FsError) {
1171 if let Ok(mut guard) = errors.lock() {
1172 guard.push(error);
1173 }
1174}
1175
1176fn lock_error(root: &Path) -> FsError {
1178 state_error(root, "parallel scanner state lock failed")
1179}
1180
1181fn state_error(root: &Path, message: &str) -> FsError {
1183 FsError::Io {
1184 path: root.to_path_buf(),
1185 source: io::Error::other(message.to_string()),
1186 }
1187}
1188
1189fn should_skip_path(root: &Path, path: &Path, options: &ScanOptions) -> bool {
1191 match normalize_repo_path(root, path) {
1192 Ok(relative) => {
1193 relative != "."
1194 && (options.excludes_relative_path(&relative) || is_reserved_metadata_file(path))
1195 }
1196 Err(_) => true,
1197 }
1198}
1199
1200fn has_excluded_directory_component(relative_path: &str, options: &ScanOptions) -> bool {
1202 relative_path.split('/').any(|name| {
1203 options
1204 .exclude_dir_names
1205 .iter()
1206 .any(|excluded| excluded == name)
1207 || options
1208 .exclude_dir_suffixes
1209 .iter()
1210 .any(|suffix| !suffix.is_empty() && name.ends_with(suffix))
1211 })
1212}
1213
1214fn has_excluded_path_prefix(relative_path: &str, options: &ScanOptions) -> bool {
1216 options.exclude_path_prefixes.iter().any(|prefix| {
1217 let prefix = prefix.replace('\\', "/");
1218 let prefix = prefix.trim_matches('/');
1219 !prefix.is_empty()
1220 && (relative_path == prefix
1221 || relative_path
1222 .strip_prefix(prefix)
1223 .is_some_and(|rest| rest.starts_with('/')))
1224 })
1225}
1226
1227fn is_indexed_projectatlas_input(relative_path: &str) -> bool {
1229 let normalized = relative_path.replace('\\', "/");
1230 let normalized = normalized.trim_matches('/');
1231 INDEXED_PROJECTATLAS_INPUT_PATHS.contains(&normalized)
1232}
1233
1234fn is_reserved_metadata_file(path: &Path) -> bool {
1236 path.file_name().is_some_and(|name| {
1237 let name = name.to_string_lossy();
1238 RESERVED_METADATA_FILE_NAMES.contains(&name.as_ref())
1239 })
1240}
1241
1242fn folder_node(root: &Path, path: &Path) -> FsResult<Node> {
1244 let normalized = normalize_repo_path(root, path)?;
1245 Ok(Node {
1246 parent_path: normalized_parent(&normalized),
1247 path: normalized,
1248 kind: NodeKind::Folder,
1249 extension: None,
1250 language: None,
1251 size_bytes: None,
1252 mtime_ns: None,
1253 content_hash: None,
1254 })
1255}
1256
1257fn file_node(
1259 root: &Path,
1260 path: &Path,
1261 metadata: &fs::Metadata,
1262 options: &ScanOptions,
1263 budget: &ScanBudget,
1264) -> FsResult<Node> {
1265 let normalized = normalize_repo_path(root, path)?;
1266 let extension = normalized_extension(path);
1267 budget.control.check(IndexWorkStage::SourceHash)?;
1268 let explicit_override = explicit_language_override(
1269 &normalized,
1270 extension.as_deref(),
1271 &options.language_overrides,
1272 );
1273 let preliminary_language = admitted_scan_language(
1274 detect_language_request(LanguageDetectionRequest {
1275 path: &normalized,
1276 extension: extension.as_deref(),
1277 explicit_override,
1278 content_prefix: None,
1279 })
1280 .map_err(|source| FsError::Io {
1281 path: path.to_path_buf(),
1282 source: io::Error::new(io::ErrorKind::InvalidInput, source),
1283 })?,
1284 options.admit_optional_languages,
1285 );
1286 let hashed = hash_file(path, budget, preliminary_language.is_none())?;
1287 let language = if let Some(detected) = preliminary_language {
1288 Some(detected.language.to_string())
1289 } else {
1290 admitted_scan_language(
1291 detect_language_request(LanguageDetectionRequest {
1292 path: "",
1293 extension: None,
1294 explicit_override: None,
1295 content_prefix: hashed.content_prefix.as_deref(),
1296 })
1297 .map_err(|source| FsError::Io {
1298 path: path.to_path_buf(),
1299 source: io::Error::new(io::ErrorKind::InvalidInput, source),
1300 })?,
1301 options.admit_optional_languages,
1302 )
1303 .map(|detected| detected.language.to_string())
1304 };
1305 let mtime_ns = metadata
1306 .modified()
1307 .ok()
1308 .and_then(system_time_to_ns)
1309 .map(|value| i64::try_from(value).unwrap_or(i64::MAX));
1310 Ok(Node {
1311 parent_path: normalized_parent(&normalized),
1312 path: normalized,
1313 kind: NodeKind::File,
1314 extension,
1315 language,
1316 size_bytes: Some(hashed.size_bytes),
1317 mtime_ns,
1318 content_hash: Some(hashed.digest),
1319 })
1320}
1321
1322fn admitted_scan_language(
1324 detected: Option<LanguageDetection>,
1325 admit_optional_languages: bool,
1326) -> Option<LanguageDetection> {
1327 detected.filter(|detected| {
1328 admit_optional_languages
1329 || language_capability(detected.language)
1330 .is_none_or(|capability| capability.optional_pack.is_none())
1331 })
1332}
1333
1334#[must_use]
1337pub fn explicit_language_override<'a>(
1338 path: &str,
1339 extension: Option<&str>,
1340 overrides: &'a BTreeMap<String, String>,
1341) -> Option<&'a str> {
1342 if overrides.is_empty() {
1343 return None;
1344 }
1345 let file_name = path.rsplit(['/', '\\']).next().unwrap_or(path);
1346 if let Some(language) = overrides.get(file_name) {
1347 return Some(language);
1348 }
1349 let lower_file_name = file_name.to_ascii_lowercase();
1350 overrides
1351 .iter()
1352 .filter(|(selector, _)| selector.starts_with('.'))
1353 .filter(|(selector, _)| {
1354 lower_file_name.ends_with(selector.as_str())
1355 || extension.is_some_and(|extension| extension.eq_ignore_ascii_case(selector))
1356 })
1357 .max_by_key(|(selector, _)| selector.len())
1358 .map(|(_, language)| language.as_str())
1359}
1360
1361#[derive(Debug)]
1363struct HashedFile {
1364 digest: String,
1366 size_bytes: u64,
1368 content_prefix: Option<Vec<u8>>,
1370}
1371
1372fn hash_file(
1374 path: &Path,
1375 budget: &ScanBudget,
1376 retain_content_prefix: bool,
1377) -> FsResult<HashedFile> {
1378 budget.control.check(IndexWorkStage::SourceHash)?;
1379 let file = fs::File::open(path).map_err(|source| FsError::Io {
1380 path: path.to_path_buf(),
1381 source,
1382 })?;
1383 hash_reader(path, file, budget, retain_content_prefix)
1384}
1385
1386fn hash_reader(
1388 path: &Path,
1389 mut reader: impl Read,
1390 budget: &ScanBudget,
1391 retain_content_prefix: bool,
1392) -> FsResult<HashedFile> {
1393 let mut hasher = Hasher::new();
1394 let mut buffer = [0_u8; HASH_BUFFER_BYTES];
1395 let mut size_bytes = 0_u64;
1396 let mut content_prefix =
1397 retain_content_prefix.then(|| Vec::with_capacity(LANGUAGE_CONTENT_DETECTION_MAX_BYTES));
1398 loop {
1399 budget.control.check(IndexWorkStage::SourceHash)?;
1400 let count = reader.read(&mut buffer).map_err(|source| FsError::Io {
1401 path: path.to_path_buf(),
1402 source,
1403 })?;
1404 if count == 0 {
1405 break;
1406 }
1407 budget.claim_source_bytes(count as u64)?;
1408 size_bytes = size_bytes.saturating_add(count as u64);
1409 hasher.update(&buffer[..count]);
1410 if let Some(content_prefix) = &mut content_prefix {
1411 let retained =
1412 LANGUAGE_CONTENT_DETECTION_MAX_BYTES.saturating_sub(content_prefix.len());
1413 content_prefix.extend_from_slice(&buffer[..count.min(retained)]);
1414 }
1415 }
1416 budget.control.check(IndexWorkStage::SourceHash)?;
1417 Ok(HashedFile {
1418 digest: hasher.finalize().to_hex().to_string(),
1419 size_bytes,
1420 content_prefix,
1421 })
1422}
1423
1424fn system_time_to_ns(time: SystemTime) -> Option<u128> {
1426 time.duration_since(UNIX_EPOCH)
1427 .ok()
1428 .map(|duration| duration.as_nanos())
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433 use super::*;
1434 use std::error::Error;
1435 use std::io;
1436 use std::process::Command;
1437 use std::time::Instant;
1438
1439 struct CancelAfterFirstChunk<R> {
1441 inner: R,
1443 cancellation: IndexCancellation,
1445 canceled: bool,
1447 }
1448
1449 impl<R: Read> Read for CancelAfterFirstChunk<R> {
1450 fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
1451 let count = self.inner.read(buffer)?;
1452 if count > 0 && !self.canceled {
1453 self.cancellation.cancel();
1454 self.canceled = true;
1455 }
1456 Ok(count)
1457 }
1458 }
1459
1460 fn run_git(repo: &Path, arguments: &[&str]) -> Result<(), Box<dyn Error>> {
1462 let output = Command::new("git")
1463 .current_dir(repo)
1464 .args(arguments)
1465 .output()?;
1466 if output.status.success() {
1467 return Ok(());
1468 }
1469 Err(io::Error::other(format!(
1470 "git {arguments:?} failed: {}{}",
1471 String::from_utf8_lossy(&output.stdout),
1472 String::from_utf8_lossy(&output.stderr)
1473 ))
1474 .into())
1475 }
1476
1477 #[test]
1478 fn controlled_scan_refuses_precancelled_work() -> Result<(), Box<dyn Error>> {
1479 let temp = tempfile::tempdir()?;
1480 fs::write(temp.path().join("source.rs"), "fn source() {}\n")?;
1481 let cancellation = IndexCancellation::new();
1482 cancellation.cancel();
1483 let control = IndexWorkControl::new(cancellation, None);
1484
1485 let result = scan_repo_controlled(
1486 temp.path(),
1487 &ScanOptions::default(),
1488 ScanLimits::default(),
1489 &control,
1490 );
1491 require(
1492 matches!(
1493 result,
1494 Err(FsError::IndexWork(IndexWorkFailure::Cancelled {
1495 stage: IndexWorkStage::RepositoryTraversal,
1496 }))
1497 ),
1498 "pre-canceled repository scan did not return typed cancellation",
1499 )?;
1500
1501 let expired = IndexWorkControl::with_deadline(IndexCancellation::new(), Instant::now());
1502 let deadline_result = scan_repo_controlled(
1503 temp.path(),
1504 &ScanOptions::default(),
1505 ScanLimits::default(),
1506 &expired,
1507 );
1508 require(
1509 matches!(
1510 deadline_result,
1511 Err(FsError::IndexWork(IndexWorkFailure::DeadlineExceeded {
1512 stage: IndexWorkStage::RepositoryTraversal,
1513 }))
1514 ),
1515 "expired repository scan did not return the typed deadline",
1516 )?;
1517 Ok(())
1518 }
1519
1520 #[test]
1521 fn registered_worktree_inventory_is_controlled_and_bounded() {
1522 let cancellation = IndexCancellation::new();
1523 cancellation.cancel();
1524 let canceled = check_registered_worktree(&IndexWorkControl::new(cancellation, None), 0);
1525 assert!(matches!(
1526 canceled,
1527 Err(FsError::IndexWork(IndexWorkFailure::Cancelled {
1528 stage: IndexWorkStage::RepositoryTraversal
1529 }))
1530 ));
1531
1532 let control = IndexWorkControl::new(IndexCancellation::new(), None);
1533 assert!(check_registered_worktree(&control, MAX_GIT_WORKTREE_REGISTRATIONS - 1).is_ok());
1534 assert!(matches!(
1535 check_registered_worktree(&control, MAX_GIT_WORKTREE_REGISTRATIONS),
1536 Err(FsError::IndexWork(
1537 IndexWorkFailure::ResourceLimitExceeded {
1538 stage: IndexWorkStage::RepositoryTraversal,
1539 resource: IndexWorkResource::Entries,
1540 limit,
1541 observed
1542 }
1543 )) if limit == MAX_GIT_WORKTREE_REGISTRATIONS as u64
1544 && observed == MAX_GIT_WORKTREE_REGISTRATIONS as u64 + 1
1545 ));
1546 }
1547
1548 #[test]
1549 fn controlled_scan_enforces_entry_and_byte_limits_without_partial_results()
1550 -> Result<(), Box<dyn Error>> {
1551 let temp = tempfile::tempdir()?;
1552 fs::write(temp.path().join("source.rs"), "four")?;
1553 let control = IndexWorkControl::new(IndexCancellation::new(), None);
1554
1555 let entry_result = scan_repo_controlled(
1556 temp.path(),
1557 &ScanOptions::default(),
1558 ScanLimits::new(1, 64, 1),
1559 &control,
1560 );
1561 require(
1562 matches!(
1563 entry_result,
1564 Err(FsError::IndexWork(
1565 IndexWorkFailure::ResourceLimitExceeded {
1566 resource: IndexWorkResource::Entries,
1567 ..
1568 }
1569 ))
1570 ),
1571 "entry-bounded scan did not return the typed entry limit",
1572 )?;
1573
1574 let byte_result = scan_repo_controlled(
1575 temp.path(),
1576 &ScanOptions::default(),
1577 ScanLimits::new(8, 3, 1),
1578 &control,
1579 );
1580 require(
1581 matches!(
1582 byte_result,
1583 Err(FsError::IndexWork(
1584 IndexWorkFailure::ResourceLimitExceeded {
1585 resource: IndexWorkResource::SourceBytes,
1586 ..
1587 }
1588 ))
1589 ),
1590 "byte-bounded scan did not return the typed source-byte limit",
1591 )?;
1592
1593 let host_workers = thread::available_parallelism().map_or(1, usize::from);
1594 let bounded_workers = ScanLimits::new(8, 64, usize::MAX).effective_workers();
1595 require(
1596 bounded_workers == host_workers.min(SCAN_WORKER_SAFE_CEILING),
1597 "effective workers did not honor host availability and the safety cap",
1598 )?;
1599 let operation_bounded_workers = effective_scan_workers(
1600 ScanLimits::new(8, 64, usize::MAX),
1601 &control.with_worker_ceiling(1),
1602 );
1603 require(
1604 operation_bounded_workers == 1,
1605 "operation-owned worker ceiling did not reach the repository scanner",
1606 )?;
1607
1608 let worker_result = scan_repo_controlled(
1609 temp.path(),
1610 &ScanOptions::default(),
1611 ScanLimits::new(8, 64, 0),
1612 &control,
1613 );
1614 require(
1615 matches!(
1616 worker_result,
1617 Err(FsError::IndexWork(
1618 IndexWorkFailure::ResourceLimitExceeded {
1619 resource: IndexWorkResource::Workers,
1620 ..
1621 }
1622 ))
1623 ),
1624 "zero-worker scan did not return the typed worker limit",
1625 )?;
1626 Ok(())
1627 }
1628
1629 #[test]
1630 fn controlled_scan_reports_admitted_work() -> Result<(), Box<dyn Error>> {
1631 let temp = tempfile::tempdir()?;
1632 fs::write(temp.path().join("source.rs"), "four")?;
1633 let control = IndexWorkControl::new(IndexCancellation::new(), None);
1634
1635 let outcome = scan_repo_controlled_with_work(
1636 temp.path(),
1637 &ScanOptions::default(),
1638 ScanLimits::new(8, 64, 1),
1639 &control,
1640 )?;
1641
1642 require_path(&outcome.nodes, ".")?;
1643 require_path(&outcome.nodes, "source.rs")?;
1644 require(
1645 outcome.work
1646 == ScanWork {
1647 entries: 2,
1648 source_bytes: 4,
1649 },
1650 "scan work did not match the admitted entry and source-byte counters",
1651 )?;
1652 Ok(())
1653 }
1654
1655 #[test]
1656 fn source_policy_inventory_covers_absent_rules_and_linked_worktrees()
1657 -> Result<(), Box<dyn Error>> {
1658 let temp = tempfile::tempdir()?;
1659 let repo = temp.path().join("repo");
1660 let worktree_git_dir = temp
1661 .path()
1662 .join("git-metadata")
1663 .join("worktrees")
1664 .join("repo");
1665 let common_git_dir = temp.path().join("git-metadata");
1666 fs::create_dir_all(&repo)?;
1667 fs::create_dir_all(&worktree_git_dir)?;
1668 fs::create_dir_all(common_git_dir.join("info"))?;
1669 fs::write(
1670 repo.join(".git"),
1671 format!("gitdir: {}\n", worktree_git_dir.display()),
1672 )?;
1673 fs::write(
1674 worktree_git_dir.join("commondir"),
1675 format!("{}\n", common_git_dir.display()),
1676 )?;
1677 fs::write(common_git_dir.join("info").join("exclude"), "ignored.rs\n")?;
1678
1679 let canonical_repo = repo.canonicalize()?;
1680 let canonical_common_git_dir = common_git_dir.canonicalize()?;
1681 let canonical_worktree_git_dir = worktree_git_dir.canonicalize()?;
1682 let paths = source_selection_policy_paths(&repo)?;
1683
1684 require(
1685 paths.contains(&canonical_repo.join(".ignore")),
1686 "policy inventory omitted the possibly absent root .ignore",
1687 )?;
1688 require(
1689 paths.contains(&canonical_repo.join(".gitignore")),
1690 "policy inventory omitted the possibly absent root .gitignore",
1691 )?;
1692 require(
1693 paths.contains(&canonical_repo.join(".git")),
1694 "policy inventory omitted the linked-worktree pointer",
1695 )?;
1696 require(
1697 paths.contains(&worktree_git_dir.join("commondir")),
1698 "policy inventory omitted the linked-worktree commondir pointer",
1699 )?;
1700 require(
1701 paths.contains(&common_git_dir.join("info").join("exclude")),
1702 "policy inventory omitted the common Git exclude file",
1703 )?;
1704 require(
1705 paths.contains(&canonical_common_git_dir.join("worktrees")),
1706 "policy inventory omitted the registered-worktree directory",
1707 )?;
1708 require(
1709 paths.contains(&canonical_worktree_git_dir.join("gitdir")),
1710 "policy inventory omitted the registered-worktree root pointer",
1711 )?;
1712 Ok(())
1713 }
1714
1715 #[test]
1716 fn scan_excludes_registered_in_root_worktree_without_an_ignore_rule()
1717 -> Result<(), Box<dyn Error>> {
1718 let temp = tempfile::tempdir()?;
1719 let repo = temp.path().join("repo");
1720 fs::create_dir(&repo)?;
1721 run_git(&repo, &["init"])?;
1722 run_git(&repo, &["config", "user.name", "ProjectAtlas Test"])?;
1723 run_git(
1724 &repo,
1725 &["config", "user.email", "projectatlas@example.invalid"],
1726 )?;
1727 fs::create_dir(repo.join("src"))?;
1728 fs::write(repo.join("src").join("main.rs"), "fn main_checkout() {}\n")?;
1729 run_git(&repo, &["add", "."])?;
1730 run_git(&repo, &["commit", "-m", "fixture"])?;
1731
1732 let linked = repo.join("linked-checkout");
1733 let output = Command::new("git")
1734 .current_dir(&repo)
1735 .args(["worktree", "add", "-b", "linked-branch"])
1736 .arg(&linked)
1737 .output()?;
1738 require(
1739 output.status.success(),
1740 &format!(
1741 "git worktree add failed: {}{}",
1742 String::from_utf8_lossy(&output.stdout),
1743 String::from_utf8_lossy(&output.stderr)
1744 ),
1745 )?;
1746 fs::write(
1747 linked.join("src").join("branch_only.rs"),
1748 "fn linked_branch_only() {}\n",
1749 )?;
1750
1751 let nested_repo = repo.join("vendor").join("unrelated");
1752 fs::create_dir_all(nested_repo.join("src"))?;
1753 run_git(&nested_repo, &["init"])?;
1754 fs::write(
1755 nested_repo.join("src").join("lib.rs"),
1756 "fn unrelated_nested_repo() {}\n",
1757 )?;
1758 require(
1759 !repo.join(".gitignore").exists(),
1760 "fixture unexpectedly depended on a worktree-container ignore rule",
1761 )?;
1762
1763 let nodes = scan_repo(&repo, &ScanOptions::default())?;
1764 require_path(&nodes, "src/main.rs")?;
1765 reject_path(&nodes, "linked-checkout")?;
1766 reject_path(&nodes, "linked-checkout/src/branch_only.rs")?;
1767 require_path(&nodes, "vendor/unrelated/src/lib.rs")?;
1768
1769 let single = scan_path(
1770 &repo,
1771 &linked.join("src").join("branch_only.rs"),
1772 &ScanOptions::default(),
1773 )?;
1774 require(
1775 single.is_none(),
1776 "single-path refresh crossed a registered sibling worktree boundary",
1777 )?;
1778 Ok(())
1779 }
1780
1781 #[test]
1782 fn scan_fails_typed_when_registered_worktree_boundary_is_unreadable()
1783 -> Result<(), Box<dyn Error>> {
1784 let temp = tempfile::tempdir()?;
1785 let repo = temp.path().join("repo");
1786 let registration = repo.join(".git").join("worktrees").join("missing");
1787 fs::create_dir_all(®istration)?;
1788 fs::write(
1789 registration.join("gitdir"),
1790 repo.join("missing-worktree")
1791 .join(".git")
1792 .display()
1793 .to_string(),
1794 )?;
1795 fs::write(repo.join("source.rs"), "fn source() {}\n")?;
1796
1797 let result = scan_repo(&repo, &ScanOptions::default());
1798 require(
1799 matches!(result, Err(FsError::RepositoryBoundary { .. })),
1800 "uncertain registered worktree boundary did not fail with the typed error",
1801 )?;
1802 Ok(())
1803 }
1804
1805 #[test]
1806 fn exact_hash_loop_observes_cancellation_between_chunks() {
1807 let exact_budget = ScanBudget::new(
1808 ScanLimits::new(8, (HASH_BUFFER_BYTES * 2) as u64, 1),
1809 IndexWorkControl::new(IndexCancellation::new(), None),
1810 );
1811 let exact_source = vec![3_u8; HASH_BUFFER_BYTES + 17];
1812 let exact_digest = blake3::hash(&exact_source).to_hex().to_string();
1813 let exact = hash_reader(
1814 Path::new("exact-source.rs"),
1815 io::Cursor::new(exact_source),
1816 &exact_budget,
1817 false,
1818 );
1819 assert!(matches!(
1820 exact,
1821 Ok(HashedFile {
1822 digest,
1823 size_bytes,
1824 content_prefix,
1825 }) if digest == exact_digest
1826 && size_bytes == (HASH_BUFFER_BYTES + 17) as u64
1827 && content_prefix.is_none()
1828 ));
1829
1830 let cancellation = IndexCancellation::new();
1831 let control = IndexWorkControl::new(cancellation.clone(), None);
1832 let budget = ScanBudget::new(ScanLimits::default(), control);
1833 let reader = CancelAfterFirstChunk {
1834 inner: io::Cursor::new(vec![7_u8; HASH_BUFFER_BYTES * 2]),
1835 cancellation,
1836 canceled: false,
1837 };
1838
1839 let result = hash_reader(Path::new("source.rs"), reader, &budget, false);
1840 assert!(matches!(
1841 result,
1842 Err(FsError::IndexWork(IndexWorkFailure::Cancelled {
1843 stage: IndexWorkStage::SourceHash,
1844 }))
1845 ));
1846
1847 let byte_budget = ScanBudget::new(
1848 ScanLimits::new(8, HASH_BUFFER_BYTES as u64, 1),
1849 IndexWorkControl::new(IndexCancellation::new(), None),
1850 );
1851 let oversized = hash_reader(
1852 Path::new("growing-source.rs"),
1853 io::Cursor::new(vec![7_u8; HASH_BUFFER_BYTES * 2]),
1854 &byte_budget,
1855 false,
1856 );
1857 assert!(matches!(
1858 oversized,
1859 Err(FsError::IndexWork(
1860 IndexWorkFailure::ResourceLimitExceeded {
1861 stage: IndexWorkStage::SourceHash,
1862 resource: IndexWorkResource::SourceBytes,
1863 ..
1864 }
1865 ))
1866 ));
1867 }
1868
1869 #[test]
1870 fn classified_hash_retains_no_content_prefix() -> Result<(), Box<dyn Error>> {
1871 let preliminary_language =
1872 detect_language_request(LanguageDetectionRequest::new("source.rs", Some(".rs")))?;
1873 let budget = ScanBudget::new(
1874 ScanLimits::new(8, 64, 1),
1875 IndexWorkControl::new(IndexCancellation::new(), None),
1876 );
1877
1878 let hashed = hash_reader(
1879 Path::new("source.rs"),
1880 io::Cursor::new(b"fn main() {}\n"),
1881 &budget,
1882 preliminary_language.is_none(),
1883 )?;
1884
1885 require(
1886 preliminary_language.map(|detected| detected.language) == Some("rust"),
1887 "preliminary extension classification did not select Rust",
1888 )?;
1889 require(
1890 hashed.content_prefix.is_none(),
1891 "classified hash retained a content prefix",
1892 )?;
1893 Ok(())
1894 }
1895
1896 #[test]
1897 fn scans_files_and_folders() -> Result<(), Box<dyn Error>> {
1898 let temp = tempfile::tempdir()?;
1899 let src = temp.path().join("src");
1900 fs::create_dir(&src)?;
1901 fs::write(src.join("main.rs"), "fn main() {}\n")?;
1902 fs::write(src.join(".purpose"), "Rust source folder\n")?;
1903
1904 let nodes = scan_repo(temp.path(), &ScanOptions::default())?;
1905 require_path(&nodes, ".")?;
1906 require_path(&nodes, "src")?;
1907 require_path(&nodes, "src/main.rs")?;
1908 reject_path(&nodes, "src/.purpose")?;
1909 Ok(())
1910 }
1911
1912 #[test]
1913 fn scan_uses_explicit_language_override_before_builtin_filename_rules()
1914 -> Result<(), Box<dyn Error>> {
1915 let temp = tempfile::tempdir()?;
1916 fs::write(temp.path().join("Cargo.toml"), "#!/usr/bin/env node\n")?;
1917 let mut options = ScanOptions::default();
1918 options
1919 .language_overrides
1920 .insert(".toml".to_string(), "python".to_string());
1921
1922 let nodes = scan_repo(temp.path(), &options)?;
1923 let cargo = nodes
1924 .iter()
1925 .find(|node| node.path == "Cargo.toml")
1926 .ok_or_else(|| io::Error::other("Cargo.toml was not scanned"))?;
1927 require(
1928 cargo.language.as_deref() == Some("python"),
1929 "explicit language override did not win",
1930 )?;
1931 Ok(())
1932 }
1933
1934 #[test]
1935 fn invalid_language_override_fails_before_source_hashing() -> Result<(), Box<dyn Error>> {
1936 let temp = tempfile::tempdir()?;
1937 let source_path = temp.path().join("source.rs");
1938 fs::write(&source_path, "fn main() {}\n")?;
1939 let mut options = ScanOptions::default();
1940 options
1941 .language_overrides
1942 .insert(".rs".to_string(), "missing-language".to_string());
1943
1944 for _ in 0..2 {
1945 let result = scan_path_controlled(
1946 temp.path(),
1947 &source_path,
1948 &options,
1949 ScanLimits::new(8, 0, 1),
1950 &IndexWorkControl::new(IndexCancellation::new(), None),
1951 );
1952 match result {
1953 Err(FsError::Io { source, .. }) => {
1954 require(
1955 source.kind() == io::ErrorKind::InvalidInput,
1956 "invalid override did not return InvalidInput",
1957 )?;
1958 require(
1959 source.to_string()
1960 == "unknown explicit language override \"missing-language\"",
1961 "invalid override diagnostic was not deterministic",
1962 )?;
1963 }
1964 other => {
1965 return Err(io::Error::other(format!("unexpected result: {other:?}")).into());
1966 }
1967 }
1968 }
1969 Ok(())
1970 }
1971
1972 #[test]
1973 fn scan_detects_bounded_shebang_from_the_existing_hash_read() -> Result<(), Box<dyn Error>> {
1974 let temp = tempfile::tempdir()?;
1975 fs::write(
1976 temp.path().join("tool"),
1977 "#!/usr/bin/env python\nprint('atlas')\n",
1978 )?;
1979
1980 let nodes = scan_repo(temp.path(), &ScanOptions::default())?;
1981 let tool = nodes
1982 .iter()
1983 .find(|node| node.path == "tool")
1984 .ok_or_else(|| io::Error::other("extensionless tool was not scanned"))?;
1985 require(
1986 tool.language.as_deref() == Some("python"),
1987 "extensionless shebang was not detected from the retained prefix",
1988 )?;
1989 Ok(())
1990 }
1991
1992 #[test]
1993 fn default_scan_keeps_optional_catalog_recognition_inactive() -> Result<(), Box<dyn Error>> {
1994 let temp = tempfile::tempdir()?;
1995 let optional_path = temp.path().join("report.awk");
1996 fs::write(&optional_path, "{ print $1 }\n")?;
1997 fs::write(temp.path().join("main.rs"), "fn main() {}\n")?;
1998
1999 let recognized =
2000 detect_language_request(LanguageDetectionRequest::new("report.awk", Some(".awk")))?;
2001 require(
2002 recognized.map(|detected| detected.language) == Some("awk"),
2003 "optional AWK recognition was removed from the core catalog",
2004 )?;
2005
2006 let nodes = scan_repo(temp.path(), &ScanOptions::default())?;
2007 let optional = nodes
2008 .iter()
2009 .find(|node| node.path == "report.awk")
2010 .ok_or_else(|| io::Error::other("optional source was not scanned"))?;
2011 require(
2012 optional.language.is_none(),
2013 "default scan admitted an inactive optional language",
2014 )?;
2015 let built_in = nodes
2016 .iter()
2017 .find(|node| node.path == "main.rs")
2018 .ok_or_else(|| io::Error::other("built-in Rust source was not scanned"))?;
2019 require(
2020 built_in.language.as_deref() == Some("rust"),
2021 "optional-language admission changed built-in recognition",
2022 )?;
2023
2024 let refreshed = scan_path(temp.path(), &optional_path, &ScanOptions::default())?
2025 .ok_or_else(|| io::Error::other("optional source was not refreshed"))?;
2026 require(
2027 refreshed.language.is_none(),
2028 "single-path refresh admitted an inactive optional language",
2029 )?;
2030 Ok(())
2031 }
2032
2033 #[test]
2034 fn enabled_scan_admits_optional_language_for_full_and_single_path_scans()
2035 -> Result<(), Box<dyn Error>> {
2036 let temp = tempfile::tempdir()?;
2037 let optional_path = temp.path().join("report.awk");
2038 fs::write(&optional_path, "{ print $1 }\n")?;
2039 let options = ScanOptions {
2040 admit_optional_languages: true,
2041 ..ScanOptions::default()
2042 };
2043
2044 let nodes = scan_repo(temp.path(), &options)?;
2045 let optional = nodes
2046 .iter()
2047 .find(|node| node.path == "report.awk")
2048 .ok_or_else(|| io::Error::other("optional source was not scanned"))?;
2049 require(
2050 optional.language.as_deref() == Some("awk"),
2051 "enabled scan did not admit the optional AWK language",
2052 )?;
2053
2054 let refreshed = scan_path(temp.path(), &optional_path, &options)?
2055 .ok_or_else(|| io::Error::other("optional source was not refreshed"))?;
2056 require(
2057 refreshed.language.as_deref() == Some("awk"),
2058 "enabled single-path refresh did not admit the optional AWK language",
2059 )?;
2060 Ok(())
2061 }
2062
2063 #[test]
2064 fn optional_override_respects_admission_and_rejected_extension_uses_content_fallback()
2065 -> Result<(), Box<dyn Error>> {
2066 let temp = tempfile::tempdir()?;
2067 fs::write(temp.path().join("report.txt"), "{ print $1 }\n")?;
2068 fs::write(
2069 temp.path().join("tool.awk"),
2070 "#!/usr/bin/env python\nprint('atlas')\n",
2071 )?;
2072 let mut options = ScanOptions::default();
2073 options
2074 .language_overrides
2075 .insert(".txt".to_string(), "awk".to_string());
2076
2077 let nodes = scan_repo(temp.path(), &options)?;
2078 let overridden = nodes
2079 .iter()
2080 .find(|node| node.path == "report.txt")
2081 .ok_or_else(|| io::Error::other("overridden source was not scanned"))?;
2082 require(
2083 overridden.language.is_none(),
2084 "explicit override bypassed optional-language admission",
2085 )?;
2086 let content_detected = nodes
2087 .iter()
2088 .find(|node| node.path == "tool.awk")
2089 .ok_or_else(|| io::Error::other("content-detected source was not scanned"))?;
2090 require(
2091 content_detected.language.as_deref() == Some("python"),
2092 "rejected optional extension did not fall through to built-in content detection",
2093 )?;
2094
2095 options.admit_optional_languages = true;
2096 let nodes = scan_repo(temp.path(), &options)?;
2097 let overridden = nodes
2098 .iter()
2099 .find(|node| node.path == "report.txt")
2100 .ok_or_else(|| io::Error::other("enabled overridden source was not scanned"))?;
2101 require(
2102 overridden.language.as_deref() == Some("awk"),
2103 "enabled scan did not admit an explicit optional-language override",
2104 )?;
2105 Ok(())
2106 }
2107
2108 #[test]
2109 fn default_scan_uses_gitignore_for_local_state() -> Result<(), Box<dyn Error>> {
2110 let temp = tempfile::tempdir()?;
2111 let repo = temp.path().join("repo");
2112 fs::create_dir_all(repo.join("local-agent-state").join("rules").join("memory"))?;
2113 fs::create_dir(repo.join("src"))?;
2114 fs::write(
2115 repo.join("local-agent-state")
2116 .join("rules")
2117 .join("memory")
2118 .join("activeContext.md"),
2119 "private local agent state\n",
2120 )?;
2121 fs::write(repo.join("src").join("main.rs"), "fn main() {}\n")?;
2122 fs::write(repo.join(".gitignore"), "local-agent-state/\n")?;
2123
2124 let nodes = scan_repo(&repo, &ScanOptions::default())?;
2125 reject_path(&nodes, "local-agent-state")?;
2126 reject_path(&nodes, "local-agent-state/rules/memory/activeContext.md")?;
2127 require_path(&nodes, "src/main.rs")?;
2128 Ok(())
2129 }
2130
2131 #[test]
2132 fn scans_repo_under_excluded_named_parent() -> Result<(), Box<dyn Error>> {
2133 let temp = tempfile::tempdir()?;
2134 let repo = temp.path().join("target").join("repo");
2135 let src = repo.join("src");
2136 fs::create_dir_all(&src)?;
2137 fs::write(src.join("main.rs"), "fn main() {}\n")?;
2138
2139 let nodes = scan_repo(&repo, &ScanOptions::default())?;
2140 require_path(&nodes, ".")?;
2141 require_path(&nodes, "src")?;
2142 require_path(&nodes, "src/main.rs")?;
2143 Ok(())
2144 }
2145
2146 #[test]
2147 fn excludes_configured_path_prefix_without_hiding_same_named_source()
2148 -> Result<(), Box<dyn Error>> {
2149 let temp = tempfile::tempdir()?;
2150 let repo = temp.path().join("repo");
2151 fs::create_dir_all(repo.join("docs").join("api"))?;
2152 fs::create_dir_all(repo.join("src").join("api"))?;
2153 fs::write(
2154 repo.join("docs").join("api").join("generated.rs"),
2155 "fn generated() {}\n",
2156 )?;
2157 fs::write(
2158 repo.join("src").join("api").join("live.rs"),
2159 "fn live() {}\n",
2160 )?;
2161 let options = ScanOptions {
2162 exclude_path_prefixes: vec!["docs\\api".to_string()],
2163 ..ScanOptions::default()
2164 };
2165
2166 let nodes = scan_repo(&repo, &options)?;
2167 reject_path(&nodes, "docs/api")?;
2168 reject_path(&nodes, "docs/api/generated.rs")?;
2169 require_path(&nodes, "docs")?;
2170 require_path(&nodes, "src/api")?;
2171 require_path(&nodes, "src/api/live.rs")?;
2172 Ok(())
2173 }
2174
2175 #[test]
2176 fn excludes_configured_directory_suffixes_for_full_and_single_path_scans()
2177 -> Result<(), Box<dyn Error>> {
2178 let temp = tempfile::tempdir()?;
2179 let repo = temp.path().join("repo");
2180 fs::create_dir_all(repo.join("vendor.egg-info"))?;
2181 fs::create_dir_all(repo.join("src").join("live"))?;
2182 fs::write(repo.join("vendor.egg-info").join("PKG-INFO"), "metadata\n")?;
2183 fs::write(
2184 repo.join("src").join("live").join("main.rs"),
2185 "fn main() {}\n",
2186 )?;
2187 let options = ScanOptions {
2188 exclude_dir_suffixes: vec![".egg-info".to_string()],
2189 ..ScanOptions::default()
2190 };
2191
2192 let nodes = scan_repo(&repo, &options)?;
2193 reject_path(&nodes, "vendor.egg-info")?;
2194 reject_path(&nodes, "vendor.egg-info/PKG-INFO")?;
2195 require_path(&nodes, "src/live/main.rs")?;
2196
2197 let single = scan_path(
2198 &repo,
2199 &repo.join("vendor.egg-info").join("PKG-INFO"),
2200 &options,
2201 )?;
2202 if single.is_some() {
2203 return Err(
2204 io::Error::other("single-path refresh indexed suffix-excluded file").into(),
2205 );
2206 }
2207 Ok(())
2208 }
2209
2210 #[test]
2211 fn default_scan_indexes_durable_projectatlas_inputs_only() -> Result<(), Box<dyn Error>> {
2212 let temp = tempfile::tempdir()?;
2213 let repo = temp.path().join("repo");
2214 let projectatlas = repo.join(".projectatlas");
2215 fs::create_dir_all(&projectatlas)?;
2216 fs::write(
2217 projectatlas.join("config.toml"),
2218 "[project]\nroot = \".\"\n",
2219 )?;
2220 fs::write(
2221 projectatlas.join("projectatlas-nonsource-files.toon"),
2222 "nonsource_files[]:\n",
2223 )?;
2224 fs::write(
2225 projectatlas.join("projectatlas-purpose-review.json"),
2226 "{\"items\":[]}\n",
2227 )?;
2228 fs::write(projectatlas.join("projectatlas.db"), b"sqlite bytes")?;
2229 fs::write(projectatlas.join("projectatlas.toon"), "generated map\n")?;
2230 fs::write(projectatlas.join("projectatlas.mcp.json"), "{}\n")?;
2231
2232 let nodes = scan_repo(&repo, &ScanOptions::default())?;
2233 require_path(&nodes, ".projectatlas")?;
2234 require_path(&nodes, ".projectatlas/config.toml")?;
2235 require_path(&nodes, ".projectatlas/projectatlas-nonsource-files.toon")?;
2236 require_path(&nodes, ".projectatlas/projectatlas-purpose-review.json")?;
2237 reject_path(&nodes, ".projectatlas/projectatlas.db")?;
2238 reject_path(&nodes, ".projectatlas/projectatlas.toon")?;
2239 reject_path(&nodes, ".projectatlas/projectatlas.mcp.json")?;
2240 Ok(())
2241 }
2242
2243 #[test]
2244 fn scan_inherits_gitignore_for_ignored_directories() -> Result<(), Box<dyn Error>> {
2245 let temp = tempfile::tempdir()?;
2246 let repo = temp.path().join("repo");
2247 fs::create_dir_all(repo.join("local-state").join("memory"))?;
2248 fs::create_dir(repo.join("src"))?;
2249 fs::write(repo.join(".gitignore"), "local-state/\n")?;
2250 fs::write(
2251 repo.join("local-state").join("memory").join("notes.md"),
2252 "local ignored notes\n",
2253 )?;
2254 fs::write(repo.join("src").join("main.rs"), "fn main() {}\n")?;
2255
2256 let nodes = scan_repo(&repo, &ScanOptions::default())?;
2257 reject_path(&nodes, "local-state")?;
2258 reject_path(&nodes, "local-state/memory/notes.md")?;
2259 require_path(&nodes, "src/main.rs")?;
2260 Ok(())
2261 }
2262
2263 #[test]
2264 fn scan_path_inherits_gitignore_for_single_path_refresh() -> Result<(), Box<dyn Error>> {
2265 let temp = tempfile::tempdir()?;
2266 let repo = temp.path().join("repo");
2267 fs::create_dir_all(repo.join("local-state").join("memory"))?;
2268 fs::create_dir(repo.join("src"))?;
2269 fs::write(repo.join(".gitignore"), "local-state/\n")?;
2270 fs::write(
2271 repo.join("local-state").join("memory").join("notes.md"),
2272 "local ignored notes\n",
2273 )?;
2274 fs::write(repo.join("src").join("main.rs"), "fn main() {}\n")?;
2275
2276 let ignored = scan_path(
2277 &repo,
2278 &repo.join("local-state").join("memory").join("notes.md"),
2279 &ScanOptions::default(),
2280 )?;
2281 let indexed = scan_path(
2282 &repo,
2283 &repo.join("src").join("main.rs"),
2284 &ScanOptions::default(),
2285 )?;
2286 if ignored.is_some() {
2287 return Err(io::Error::other("single-path refresh indexed ignored state").into());
2288 }
2289 if indexed.is_none() {
2290 return Err(io::Error::other("single-path refresh skipped indexed source").into());
2291 }
2292 Ok(())
2293 }
2294
2295 #[test]
2296 fn root_scan_policy_classifies_absent_standard_and_atlas_ignores() -> Result<(), Box<dyn Error>>
2297 {
2298 let temp = tempfile::tempdir()?;
2299 let repo = temp.path().join("repo");
2300 fs::create_dir_all(repo.join(".git").join("info"))?;
2301 fs::write(repo.join(".gitignore"), "generated/\n")?;
2302 fs::write(repo.join(".ignore"), "drafts/\n")?;
2303 fs::write(
2304 repo.join(".git").join("info").join("exclude"),
2305 "private.txt\n",
2306 )?;
2307 let control = IndexWorkControl::new(IndexCancellation::new(), None);
2308 let policy = RootScanPolicy::discover(&repo, &ScanOptions::default(), &control)?;
2309
2310 require(
2311 policy.excludes_path(&repo.join("generated").join("missing.md"))?,
2312 "absent .gitignore target was not excluded",
2313 )?;
2314 require(
2315 policy.excludes_path(&repo.join("drafts").join("missing.md"))?,
2316 "absent .ignore target was not excluded",
2317 )?;
2318 require(
2319 policy.excludes_path(&repo.join("private.txt"))?,
2320 "absent Git info/exclude target was not excluded",
2321 )?;
2322 require(
2323 policy.excludes_path(&repo.join(".projectatlas").join("state.db"))?,
2324 "Atlas-specific excluded prefix was not retained",
2325 )?;
2326 Ok(())
2327 }
2328
2329 #[test]
2330 fn scan_path_skips_symlinked_files_before_canonicalizing() -> Result<(), Box<dyn Error>> {
2331 let temp = tempfile::tempdir()?;
2332 let repo = temp.path().join("repo");
2333 fs::create_dir(&repo)?;
2334 let outside = temp.path().join("outside.txt");
2335 let link = repo.join("linked.txt");
2336 fs::write(&outside, "outside secret\n")?;
2337 if !create_file_symlink(&outside, &link)? {
2338 return Ok(());
2339 }
2340
2341 let indexed = scan_path(&repo, &link, &ScanOptions::default())?;
2342 if indexed.is_some() {
2343 return Err(io::Error::other("single-path refresh indexed a symlink").into());
2344 }
2345 Ok(())
2346 }
2347
2348 #[test]
2349 fn scan_path_skips_symlinked_ancestor_before_canonicalizing() -> Result<(), Box<dyn Error>> {
2350 let temp = tempfile::tempdir()?;
2351 let repo = temp.path().join("repo");
2352 let outside = temp.path().join("outside");
2353 fs::create_dir(&repo)?;
2354 fs::create_dir(&outside)?;
2355 fs::write(outside.join("secret.rs"), "fn secret() {}\n")?;
2356 let link = repo.join("linked");
2357 if !create_dir_symlink(&outside, &link)? {
2358 return Ok(());
2359 }
2360
2361 let indexed = scan_path(&repo, &link.join("secret.rs"), &ScanOptions::default())?;
2362 if indexed.is_some() {
2363 return Err(
2364 io::Error::other("single-path refresh indexed through a symlinked folder").into(),
2365 );
2366 }
2367 Ok(())
2368 }
2369
2370 #[cfg(unix)]
2371 #[test]
2372 fn skips_symlinked_files() -> Result<(), Box<dyn Error>> {
2373 use std::os::unix::fs::symlink;
2374
2375 let temp = tempfile::tempdir()?;
2376 let repo = temp.path().join("repo");
2377 fs::create_dir(&repo)?;
2378 let outside = temp.path().join("outside.txt");
2379 fs::write(&outside, "outside secret\n")?;
2380 symlink(&outside, repo.join("linked.txt"))?;
2381
2382 let nodes = scan_repo(&repo, &ScanOptions::default())?;
2383 reject_path(&nodes, "linked.txt")?;
2384 Ok(())
2385 }
2386
2387 #[cfg(unix)]
2389 fn create_file_symlink(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2390 std::os::unix::fs::symlink(target, link)?;
2391 Ok(true)
2392 }
2393
2394 #[cfg(windows)]
2396 fn create_file_symlink(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2397 match std::os::windows::fs::symlink_file(target, link) {
2398 Ok(()) => Ok(true),
2399 Err(error)
2400 if error.kind() == io::ErrorKind::PermissionDenied
2401 || error.raw_os_error() == Some(1314) =>
2402 {
2403 Ok(false)
2404 }
2405 Err(error) => Err(error.into()),
2406 }
2407 }
2408
2409 #[cfg(unix)]
2411 fn create_dir_symlink(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2412 std::os::unix::fs::symlink(target, link)?;
2413 Ok(true)
2414 }
2415
2416 #[cfg(windows)]
2418 fn create_dir_symlink(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2419 match std::os::windows::fs::symlink_dir(target, link) {
2420 Ok(()) => Ok(true),
2421 Err(error)
2422 if error.kind() == io::ErrorKind::PermissionDenied
2423 || error.raw_os_error() == Some(1314) =>
2424 {
2425 Ok(false)
2426 }
2427 Err(error) => Err(error.into()),
2428 }
2429 }
2430
2431 fn require_path(nodes: &[Node], expected: &str) -> Result<(), Box<dyn Error>> {
2433 if nodes.iter().any(|node| node.path == expected) {
2434 Ok(())
2435 } else {
2436 Err(io::Error::other(format!("missing scanned path {expected}")).into())
2437 }
2438 }
2439
2440 fn reject_path(nodes: &[Node], rejected: &str) -> Result<(), Box<dyn Error>> {
2442 if nodes.iter().any(|node| node.path == rejected) {
2443 Err(io::Error::other(format!("unexpected scanned path {rejected}")).into())
2444 } else {
2445 Ok(())
2446 }
2447 }
2448
2449 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
2451 if condition {
2452 Ok(())
2453 } else {
2454 Err(io::Error::other(message).into())
2455 }
2456 }
2457}