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