Skip to main content

projectatlas_fs/
lib.rs

1//! Purpose: Scan repository files and folders for `ProjectAtlas` 3.
2
3pub 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
28/// Reserved metadata files that should not become indexed project nodes.
29const RESERVED_METADATA_FILE_NAMES: &[&str] = &[".purpose"];
30
31/// Durable `.projectatlas` inputs that are part of the project contract.
32const 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
39/// Maximum worker count used even when callers request more host parallelism.
40const SCAN_WORKER_SAFE_CEILING: usize = 32;
41/// Default maximum repository entries considered by one scan.
42const DEFAULT_SCAN_MAX_ENTRIES: u64 = 1_000_000;
43/// Default maximum source bytes hashed by one scan.
44const DEFAULT_SCAN_MAX_SOURCE_BYTES: u64 = 16 * 1_024 * 1_024 * 1_024;
45/// Default deadline for the compatibility repository scan.
46const DEFAULT_SCAN_TIMEOUT: Duration = Duration::from_mins(30);
47/// Exact-byte hash read buffer size.
48const HASH_BUFFER_BYTES: usize = 8_192;
49/// Maximum linked-worktree `.git` pointer bytes inspected for policy discovery.
50const GIT_DIRECTORY_POINTER_MAX_BYTES: u64 = 64 * 1_024;
51/// Filesystem scanner errors.
52#[derive(Debug, Error)]
53pub enum FsError {
54    /// Core normalization failed.
55    #[error("{0}")]
56    Core(#[from] CoreError),
57    /// Filesystem operation failed.
58    #[error("filesystem error for {path:?}: {source}")]
59    Io {
60        /// Path involved in the error.
61        path: PathBuf,
62        /// Source IO error.
63        source: io::Error,
64    },
65    /// The supplied root is not a directory.
66    #[error("scan root is not a directory: {0:?}")]
67    RootNotDirectory(PathBuf),
68    /// A Git worktree boundary could not be validated safely.
69    #[error("repository boundary could not be validated for {path:?}: {source}")]
70    RepositoryBoundary {
71        /// Git control path involved in the boundary failure.
72        path: PathBuf,
73        /// Source boundary error.
74        source: io::Error,
75    },
76    /// Cooperative indexing work was canceled or exceeded a declared bound.
77    #[error("{0}")]
78    IndexWork(#[from] IndexWorkFailure),
79}
80
81/// Convenient result alias for scanner operations.
82pub type FsResult<T> = Result<T, FsError>;
83
84/// Repository scanner configuration.
85#[derive(Clone, Debug)]
86pub struct ScanOptions {
87    /// Additional directory names to exclude.
88    pub exclude_dir_names: Vec<String>,
89    /// Additional directory suffixes to exclude.
90    pub exclude_dir_suffixes: Vec<String>,
91    /// Repository-relative path prefixes to exclude.
92    pub exclude_path_prefixes: Vec<String>,
93    /// Explicit filename or extension selectors mapped to canonical language IDs.
94    pub language_overrides: BTreeMap<String, String>,
95    /// Whether registry-known optional languages may be assigned to scanned files.
96    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    /// Return whether a repository-relative slash path is excluded.
122    #[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/// Canonical selected root plus its resolved repository exclusion policy.
133#[derive(Clone, Debug)]
134pub struct RootScanPolicy {
135    /// Canonical selected source root.
136    root: PathBuf,
137    /// Caller options extended with repository-derived exclusions.
138    options: ScanOptions,
139}
140
141impl RootScanPolicy {
142    /// Discover bounded repository policy once for one selected scan root.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error when the root is invalid, repository boundary metadata
147    /// cannot be validated, or cooperative work has stopped.
148    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    /// Return whether one repository-contained path is excluded by the
166    /// effective scanner policy, including rules that match an absent leaf.
167    ///
168    /// # Errors
169    ///
170    /// Returns an error when the path cannot be normalized or an ignore
171    /// source cannot be parsed safely.
172    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/// Hard repository-scan resource limits.
186#[derive(Clone, Copy, Debug, Eq, PartialEq)]
187pub struct ScanLimits {
188    /// Maximum repository entries considered, including folders.
189    entries: u64,
190    /// Maximum cumulative file bytes admitted for exact hashing.
191    source_bytes: u64,
192    /// Maximum requested scanner workers before host and safety caps.
193    workers: usize,
194}
195
196impl ScanLimits {
197    /// Create explicit repository-scan limits.
198    #[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    /// Return the maximum repository entries considered.
208    #[must_use]
209    pub const fn max_entries(self) -> u64 {
210        self.entries
211    }
212
213    /// Return the maximum cumulative source bytes hashed.
214    #[must_use]
215    pub const fn max_source_bytes(self) -> u64 {
216        self.source_bytes
217    }
218
219    /// Return the requested maximum worker count.
220    #[must_use]
221    pub const fn max_workers(self) -> usize {
222        self.workers
223    }
224
225    /// Derive the worker count from the request, host availability, and safety cap.
226    #[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/// Resource work completed by one successful repository scan.
244#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
245pub struct ScanWork {
246    /// Repository entries admitted by the scan budget, including folders.
247    pub entries: u64,
248    /// Exact source bytes admitted by the scan budget while hashing files.
249    pub source_bytes: u64,
250}
251
252/// Complete nodes and resource work from one successful repository scan.
253#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct ScanOutcome {
255    /// Complete sorted repository nodes.
256    pub nodes: Vec<Node>,
257    /// Resource work consumed while producing the nodes.
258    pub work: ScanWork,
259}
260
261/// Apply the operation-owned worker ceiling to the scan-specific limits.
262fn 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/// Shared counters and controls for one repository scan.
270#[derive(Debug)]
271struct ScanBudget {
272    /// Hard resource limits for the scan.
273    limits: ScanLimits,
274    /// Shared cancellation and deadline boundary.
275    control: IndexWorkControl,
276    /// Repository entries admitted so far.
277    entries: AtomicU64,
278    /// File bytes admitted for hashing so far.
279    source_bytes: AtomicU64,
280}
281
282impl ScanBudget {
283    /// Create an unused scan budget.
284    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    /// Check traversal state and admit one repository entry.
294    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    /// Admit source bytes before starting exact content hashing.
306    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    /// Snapshot the admitted work after a scan has completed.
317    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
325/// Atomically claim an inclusive bounded resource amount.
326fn 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
345/// Scan a repository into `ProjectAtlas` nodes.
346///
347/// # Errors
348///
349/// Returns an error when the root is invalid or filesystem metadata cannot be
350/// read.
351pub 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
356/// Scan a repository with explicit resource and cooperative-stop controls.
357///
358/// The scan hashes current file bytes exactly. Cancellation, elapsed deadlines,
359/// and resource failures discard all staged nodes instead of returning a partial
360/// repository view.
361///
362/// # Errors
363///
364/// Returns an error when the root is invalid, filesystem metadata cannot be
365/// read, cancellation or the deadline is observed, or a scan limit is exceeded.
366pub 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
375/// Scan a repository with explicit controls and report the admitted work.
376///
377/// The scan hashes current file bytes exactly. Cancellation, elapsed deadlines,
378/// and resource failures discard all staged nodes and work instead of returning
379/// a partial repository view.
380///
381/// # Errors
382///
383/// Returns an error when the root is invalid, filesystem metadata cannot be
384/// read, cancellation or the deadline is observed, or a scan limit is exceeded.
385pub 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
489/// Scan one path into a `ProjectAtlas` node when it is indexable.
490///
491/// # Errors
492///
493/// Returns an error when root canonicalization or metadata reads fail.
494pub 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
499/// Scan one path with explicit resource and cooperative-stop controls.
500///
501/// # Errors
502///
503/// Returns an error when root canonicalization or metadata reads fail,
504/// cancellation or the deadline is observed, or a scan limit is exceeded.
505pub 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
516/// Scan one path using repository policy already resolved for the selected root.
517///
518/// # Errors
519///
520/// Returns an error when path canonicalization or metadata reads fail,
521/// cancellation or the deadline is observed, or a scan limit is exceeded.
522pub 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
565/// Return a path with a canonical parent but the leaf component preserved.
566fn 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
587/// Return whether any path component below root is a symlink.
588fn 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(&current)
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
609/// Return whether repository `.gitignore` rules exclude a path.
610///
611/// This helper is for single-path refreshes. Full repository scans use
612/// `ignore::WalkBuilder` directly.
613///
614/// # Errors
615///
616/// Returns an error if the root cannot be canonicalized or a discovered
617/// `.gitignore` file cannot be parsed.
618pub 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
646/// Apply the same standard ignore-family precedence as `WalkBuilder`.
647fn 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
673/// Return the deepest matching rule from one nested ignore-file family.
674fn 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
691/// Match one ignore-format file while retaining ignore versus whitelist state.
692fn 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
710/// Reduce an ignore matcher result to its policy-relevant tri-state.
711fn 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/// Resolve the global Git excludes file selected by the same ignore engine as scans.
724///
725/// The returned path may not exist yet. Callers that retain a source-policy
726/// witness should therefore preserve the path and its absent/present state.
727#[must_use]
728pub fn git_global_excludes_path() -> Option<PathBuf> {
729    ignore::gitignore::gitconfig_excludes_path()
730}
731
732/// Return bounded external inputs that can change the standard scan ignore policy.
733///
734/// Root-contained nested `.gitignore` and `.ignore` files are covered by the
735/// recursive source observer. This inventory adds ancestor rules, repository
736/// excludes, linked-worktree metadata, and the global Git configuration inputs
737/// used by [`WalkBuilder`]. Paths are retained even when absent so creation is
738/// visible to a process-local policy witness.
739///
740/// # Errors
741///
742/// Returns an error when the root cannot be canonicalized or a linked-worktree
743/// pointer cannot be inspected within its declared bound.
744pub 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
749/// Return bounded external source-selection inputs under one shared work control.
750///
751/// # Errors
752///
753/// Returns an error when policy discovery is canceled, exceeds its deadline or
754/// registration bound, or cannot validate a linked-worktree pointer.
755pub 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(&registrations) {
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
840/// Read one bounded Git directory pointer relative to its containing directory.
841fn 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
863/// Adapt one structural pointer failure for scanner policy callers.
864fn git_pointer_io_error(issue: worktree::GitStructureIssue) -> FsError {
865    let (path, source) = issue.into_io_error();
866    FsError::Io { path, source }
867}
868
869/// Adapt one structural pointer failure for repository-boundary callers.
870fn git_pointer_boundary_error(issue: worktree::GitStructureIssue) -> FsError {
871    let (path, source) = issue.into_io_error();
872    FsError::RepositoryBoundary { path, source }
873}
874
875/// Add registered in-root sibling worktrees to the existing prefix policy.
876fn 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
888/// Return registered sibling worktrees physically nested beneath the selected root.
889fn 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(&registrations) {
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
998/// Admit one bounded registered-worktree policy entry.
999fn 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
1014/// Resolve the common Git directory for a repository or linked-worktree root.
1015fn 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
1068/// Read a Git boundary pointer while preserving a typed boundary failure.
1069fn 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
1107/// Resolve the current user's home directory without adding a platform helper dependency.
1108fn 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
1115/// Return directories whose `.gitignore` files can affect a target path.
1116fn 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
1133/// Return the correct walker state for a skipped entry.
1134fn 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
1145/// Convert one walker entry into an indexed node.
1146fn 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
1169/// Push a scanner error through the shared parallel error channel.
1170fn push_error(errors: &Arc<Mutex<Vec<FsError>>>, error: FsError) {
1171    if let Ok(mut guard) = errors.lock() {
1172        guard.push(error);
1173    }
1174}
1175
1176/// Build a lock poisoning error.
1177fn lock_error(root: &Path) -> FsError {
1178    state_error(root, "parallel scanner state lock failed")
1179}
1180
1181/// Build a scanner state error.
1182fn 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
1189/// Return whether a repository-relative path should be skipped.
1190fn 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
1200/// Return whether a repository-relative slash path contains an excluded directory.
1201fn 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
1214/// Return whether a repository-relative slash path starts with an excluded prefix.
1215fn 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
1227/// Return whether a ProjectAtlas-local metadata path should remain indexable.
1228fn 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
1234/// Return whether a path is a reserved metadata file.
1235fn 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
1242/// Build a folder node from filesystem metadata.
1243fn 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
1257/// Build a file node from filesystem metadata and content hash.
1258fn 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
1322/// Apply effective scan admission without weakening core registry recognition.
1323fn 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/// Select one configured explicit override before built-in detector rules.
1335/// Exact filenames take precedence over the longest matching extension selector.
1336#[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/// Exact hash plus the bounded prefix already observed by the same source read.
1362#[derive(Debug)]
1363struct HashedFile {
1364    /// BLAKE3 digest of every byte.
1365    digest: String,
1366    /// Exact source byte count.
1367    size_bytes: u64,
1368    /// Prefix retained only for bounded language detection.
1369    content_prefix: Option<Vec<u8>>,
1370}
1371
1372/// Hash a file with BLAKE3 for stale-purpose detection.
1373fn 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
1386/// Hash every byte from a reader while observing cancellation and deadline state.
1387fn 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
1424/// Convert a system timestamp into nanoseconds since the Unix epoch.
1425fn 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    /// Reader that requests cancellation after yielding its first non-empty chunk.
1440    struct CancelAfterFirstChunk<R> {
1441        /// Wrapped source reader.
1442        inner: R,
1443        /// Signal shared with the work control under test.
1444        cancellation: IndexCancellation,
1445        /// Whether cancellation was already requested.
1446        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    /// Run one Git command inside a test repository.
1461    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(&registration)?;
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_work_bounds() {
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 expired_budget = ScanBudget::new(
1848            ScanLimits::default(),
1849            IndexWorkControl::with_deadline(IndexCancellation::new(), Instant::now()),
1850        );
1851        let expired = hash_reader(
1852            Path::new("deadline-source.rs"),
1853            io::Cursor::new(vec![7_u8; HASH_BUFFER_BYTES * 2]),
1854            &expired_budget,
1855            false,
1856        );
1857        assert!(matches!(
1858            expired,
1859            Err(FsError::IndexWork(IndexWorkFailure::DeadlineExceeded {
1860                stage: IndexWorkStage::SourceHash,
1861            }))
1862        ));
1863
1864        let byte_budget = ScanBudget::new(
1865            ScanLimits::new(8, HASH_BUFFER_BYTES as u64, 1),
1866            IndexWorkControl::new(IndexCancellation::new(), None),
1867        );
1868        let oversized = hash_reader(
1869            Path::new("growing-source.rs"),
1870            io::Cursor::new(vec![7_u8; HASH_BUFFER_BYTES * 2]),
1871            &byte_budget,
1872            false,
1873        );
1874        assert!(matches!(
1875            oversized,
1876            Err(FsError::IndexWork(
1877                IndexWorkFailure::ResourceLimitExceeded {
1878                    stage: IndexWorkStage::SourceHash,
1879                    resource: IndexWorkResource::SourceBytes,
1880                    ..
1881                }
1882            ))
1883        ));
1884    }
1885
1886    #[test]
1887    fn classified_hash_retains_no_content_prefix() -> Result<(), Box<dyn Error>> {
1888        let preliminary_language =
1889            detect_language_request(LanguageDetectionRequest::new("source.rs", Some(".rs")))?;
1890        let budget = ScanBudget::new(
1891            ScanLimits::new(8, 64, 1),
1892            IndexWorkControl::new(IndexCancellation::new(), None),
1893        );
1894
1895        let hashed = hash_reader(
1896            Path::new("source.rs"),
1897            io::Cursor::new(b"fn main() {}\n"),
1898            &budget,
1899            preliminary_language.is_none(),
1900        )?;
1901
1902        require(
1903            preliminary_language.map(|detected| detected.language) == Some("rust"),
1904            "preliminary extension classification did not select Rust",
1905        )?;
1906        require(
1907            hashed.content_prefix.is_none(),
1908            "classified hash retained a content prefix",
1909        )?;
1910        Ok(())
1911    }
1912
1913    #[test]
1914    fn scans_files_and_folders() -> Result<(), Box<dyn Error>> {
1915        let temp = tempfile::tempdir()?;
1916        let src = temp.path().join("src");
1917        fs::create_dir(&src)?;
1918        fs::write(src.join("main.rs"), "fn main() {}\n")?;
1919        fs::write(src.join(".purpose"), "Rust source folder\n")?;
1920
1921        let nodes = scan_repo(temp.path(), &ScanOptions::default())?;
1922        require_path(&nodes, ".")?;
1923        require_path(&nodes, "src")?;
1924        require_path(&nodes, "src/main.rs")?;
1925        reject_path(&nodes, "src/.purpose")?;
1926        Ok(())
1927    }
1928
1929    #[test]
1930    fn scan_uses_explicit_language_override_before_builtin_filename_rules()
1931    -> Result<(), Box<dyn Error>> {
1932        let temp = tempfile::tempdir()?;
1933        fs::write(temp.path().join("Cargo.toml"), "#!/usr/bin/env node\n")?;
1934        let mut options = ScanOptions::default();
1935        options
1936            .language_overrides
1937            .insert(".toml".to_string(), "python".to_string());
1938
1939        let nodes = scan_repo(temp.path(), &options)?;
1940        let cargo = nodes
1941            .iter()
1942            .find(|node| node.path == "Cargo.toml")
1943            .ok_or_else(|| io::Error::other("Cargo.toml was not scanned"))?;
1944        require(
1945            cargo.language.as_deref() == Some("python"),
1946            "explicit language override did not win",
1947        )?;
1948        Ok(())
1949    }
1950
1951    #[test]
1952    fn invalid_language_override_fails_before_source_hashing() -> Result<(), Box<dyn Error>> {
1953        let temp = tempfile::tempdir()?;
1954        let source_path = temp.path().join("source.rs");
1955        fs::write(&source_path, "fn main() {}\n")?;
1956        let mut options = ScanOptions::default();
1957        options
1958            .language_overrides
1959            .insert(".rs".to_string(), "missing-language".to_string());
1960
1961        for _ in 0..2 {
1962            let result = scan_path_controlled(
1963                temp.path(),
1964                &source_path,
1965                &options,
1966                ScanLimits::new(8, 0, 1),
1967                &IndexWorkControl::new(IndexCancellation::new(), None),
1968            );
1969            match result {
1970                Err(FsError::Io { source, .. }) => {
1971                    require(
1972                        source.kind() == io::ErrorKind::InvalidInput,
1973                        "invalid override did not return InvalidInput",
1974                    )?;
1975                    require(
1976                        source.to_string()
1977                            == "unknown explicit language override \"missing-language\"",
1978                        "invalid override diagnostic was not deterministic",
1979                    )?;
1980                }
1981                other => {
1982                    return Err(io::Error::other(format!("unexpected result: {other:?}")).into());
1983                }
1984            }
1985        }
1986        Ok(())
1987    }
1988
1989    #[test]
1990    fn scan_detects_bounded_shebang_from_the_existing_hash_read() -> Result<(), Box<dyn Error>> {
1991        let temp = tempfile::tempdir()?;
1992        fs::write(
1993            temp.path().join("tool"),
1994            "#!/usr/bin/env python\nprint('atlas')\n",
1995        )?;
1996
1997        let nodes = scan_repo(temp.path(), &ScanOptions::default())?;
1998        let tool = nodes
1999            .iter()
2000            .find(|node| node.path == "tool")
2001            .ok_or_else(|| io::Error::other("extensionless tool was not scanned"))?;
2002        require(
2003            tool.language.as_deref() == Some("python"),
2004            "extensionless shebang was not detected from the retained prefix",
2005        )?;
2006        Ok(())
2007    }
2008
2009    #[test]
2010    fn default_scan_keeps_optional_catalog_recognition_inactive() -> Result<(), Box<dyn Error>> {
2011        let temp = tempfile::tempdir()?;
2012        let optional_path = temp.path().join("report.awk");
2013        fs::write(&optional_path, "{ print $1 }\n")?;
2014        fs::write(temp.path().join("main.rs"), "fn main() {}\n")?;
2015
2016        let recognized =
2017            detect_language_request(LanguageDetectionRequest::new("report.awk", Some(".awk")))?;
2018        require(
2019            recognized.map(|detected| detected.language) == Some("awk"),
2020            "optional AWK recognition was removed from the core catalog",
2021        )?;
2022
2023        let nodes = scan_repo(temp.path(), &ScanOptions::default())?;
2024        let optional = nodes
2025            .iter()
2026            .find(|node| node.path == "report.awk")
2027            .ok_or_else(|| io::Error::other("optional source was not scanned"))?;
2028        require(
2029            optional.language.is_none(),
2030            "default scan admitted an inactive optional language",
2031        )?;
2032        let built_in = nodes
2033            .iter()
2034            .find(|node| node.path == "main.rs")
2035            .ok_or_else(|| io::Error::other("built-in Rust source was not scanned"))?;
2036        require(
2037            built_in.language.as_deref() == Some("rust"),
2038            "optional-language admission changed built-in recognition",
2039        )?;
2040
2041        let refreshed = scan_path(temp.path(), &optional_path, &ScanOptions::default())?
2042            .ok_or_else(|| io::Error::other("optional source was not refreshed"))?;
2043        require(
2044            refreshed.language.is_none(),
2045            "single-path refresh admitted an inactive optional language",
2046        )?;
2047        Ok(())
2048    }
2049
2050    #[test]
2051    fn enabled_scan_admits_optional_language_for_full_and_single_path_scans()
2052    -> Result<(), Box<dyn Error>> {
2053        let temp = tempfile::tempdir()?;
2054        let optional_path = temp.path().join("report.awk");
2055        fs::write(&optional_path, "{ print $1 }\n")?;
2056        let options = ScanOptions {
2057            admit_optional_languages: true,
2058            ..ScanOptions::default()
2059        };
2060
2061        let nodes = scan_repo(temp.path(), &options)?;
2062        let optional = nodes
2063            .iter()
2064            .find(|node| node.path == "report.awk")
2065            .ok_or_else(|| io::Error::other("optional source was not scanned"))?;
2066        require(
2067            optional.language.as_deref() == Some("awk"),
2068            "enabled scan did not admit the optional AWK language",
2069        )?;
2070
2071        let refreshed = scan_path(temp.path(), &optional_path, &options)?
2072            .ok_or_else(|| io::Error::other("optional source was not refreshed"))?;
2073        require(
2074            refreshed.language.as_deref() == Some("awk"),
2075            "enabled single-path refresh did not admit the optional AWK language",
2076        )?;
2077        Ok(())
2078    }
2079
2080    #[test]
2081    fn optional_override_respects_admission_and_rejected_extension_uses_content_fallback()
2082    -> Result<(), Box<dyn Error>> {
2083        let temp = tempfile::tempdir()?;
2084        fs::write(temp.path().join("report.txt"), "{ print $1 }\n")?;
2085        fs::write(
2086            temp.path().join("tool.awk"),
2087            "#!/usr/bin/env python\nprint('atlas')\n",
2088        )?;
2089        let mut options = ScanOptions::default();
2090        options
2091            .language_overrides
2092            .insert(".txt".to_string(), "awk".to_string());
2093
2094        let nodes = scan_repo(temp.path(), &options)?;
2095        let overridden = nodes
2096            .iter()
2097            .find(|node| node.path == "report.txt")
2098            .ok_or_else(|| io::Error::other("overridden source was not scanned"))?;
2099        require(
2100            overridden.language.is_none(),
2101            "explicit override bypassed optional-language admission",
2102        )?;
2103        let content_detected = nodes
2104            .iter()
2105            .find(|node| node.path == "tool.awk")
2106            .ok_or_else(|| io::Error::other("content-detected source was not scanned"))?;
2107        require(
2108            content_detected.language.as_deref() == Some("python"),
2109            "rejected optional extension did not fall through to built-in content detection",
2110        )?;
2111
2112        options.admit_optional_languages = true;
2113        let nodes = scan_repo(temp.path(), &options)?;
2114        let overridden = nodes
2115            .iter()
2116            .find(|node| node.path == "report.txt")
2117            .ok_or_else(|| io::Error::other("enabled overridden source was not scanned"))?;
2118        require(
2119            overridden.language.as_deref() == Some("awk"),
2120            "enabled scan did not admit an explicit optional-language override",
2121        )?;
2122        Ok(())
2123    }
2124
2125    #[test]
2126    fn default_scan_uses_gitignore_for_local_state() -> Result<(), Box<dyn Error>> {
2127        let temp = tempfile::tempdir()?;
2128        let repo = temp.path().join("repo");
2129        fs::create_dir_all(repo.join("local-agent-state").join("rules").join("memory"))?;
2130        fs::create_dir(repo.join("src"))?;
2131        fs::write(
2132            repo.join("local-agent-state")
2133                .join("rules")
2134                .join("memory")
2135                .join("activeContext.md"),
2136            "private local agent state\n",
2137        )?;
2138        fs::write(repo.join("src").join("main.rs"), "fn main() {}\n")?;
2139        fs::write(repo.join(".gitignore"), "local-agent-state/\n")?;
2140
2141        let nodes = scan_repo(&repo, &ScanOptions::default())?;
2142        reject_path(&nodes, "local-agent-state")?;
2143        reject_path(&nodes, "local-agent-state/rules/memory/activeContext.md")?;
2144        require_path(&nodes, "src/main.rs")?;
2145        Ok(())
2146    }
2147
2148    #[test]
2149    fn scans_repo_under_excluded_named_parent() -> Result<(), Box<dyn Error>> {
2150        let temp = tempfile::tempdir()?;
2151        let repo = temp.path().join("target").join("repo");
2152        let src = repo.join("src");
2153        fs::create_dir_all(&src)?;
2154        fs::write(src.join("main.rs"), "fn main() {}\n")?;
2155
2156        let nodes = scan_repo(&repo, &ScanOptions::default())?;
2157        require_path(&nodes, ".")?;
2158        require_path(&nodes, "src")?;
2159        require_path(&nodes, "src/main.rs")?;
2160        Ok(())
2161    }
2162
2163    #[test]
2164    fn excludes_configured_path_prefix_without_hiding_same_named_source()
2165    -> Result<(), Box<dyn Error>> {
2166        let temp = tempfile::tempdir()?;
2167        let repo = temp.path().join("repo");
2168        fs::create_dir_all(repo.join("docs").join("api"))?;
2169        fs::create_dir_all(repo.join("src").join("api"))?;
2170        fs::write(
2171            repo.join("docs").join("api").join("generated.rs"),
2172            "fn generated() {}\n",
2173        )?;
2174        fs::write(
2175            repo.join("src").join("api").join("live.rs"),
2176            "fn live() {}\n",
2177        )?;
2178        let options = ScanOptions {
2179            exclude_path_prefixes: vec!["docs\\api".to_string()],
2180            ..ScanOptions::default()
2181        };
2182
2183        let nodes = scan_repo(&repo, &options)?;
2184        reject_path(&nodes, "docs/api")?;
2185        reject_path(&nodes, "docs/api/generated.rs")?;
2186        require_path(&nodes, "docs")?;
2187        require_path(&nodes, "src/api")?;
2188        require_path(&nodes, "src/api/live.rs")?;
2189        Ok(())
2190    }
2191
2192    #[test]
2193    fn excludes_configured_directory_suffixes_for_full_and_single_path_scans()
2194    -> Result<(), Box<dyn Error>> {
2195        let temp = tempfile::tempdir()?;
2196        let repo = temp.path().join("repo");
2197        fs::create_dir_all(repo.join("vendor.egg-info"))?;
2198        fs::create_dir_all(repo.join("src").join("live"))?;
2199        fs::write(repo.join("vendor.egg-info").join("PKG-INFO"), "metadata\n")?;
2200        fs::write(
2201            repo.join("src").join("live").join("main.rs"),
2202            "fn main() {}\n",
2203        )?;
2204        let options = ScanOptions {
2205            exclude_dir_suffixes: vec![".egg-info".to_string()],
2206            ..ScanOptions::default()
2207        };
2208
2209        let nodes = scan_repo(&repo, &options)?;
2210        reject_path(&nodes, "vendor.egg-info")?;
2211        reject_path(&nodes, "vendor.egg-info/PKG-INFO")?;
2212        require_path(&nodes, "src/live/main.rs")?;
2213
2214        let single = scan_path(
2215            &repo,
2216            &repo.join("vendor.egg-info").join("PKG-INFO"),
2217            &options,
2218        )?;
2219        if single.is_some() {
2220            return Err(
2221                io::Error::other("single-path refresh indexed suffix-excluded file").into(),
2222            );
2223        }
2224        Ok(())
2225    }
2226
2227    #[test]
2228    fn default_scan_indexes_durable_projectatlas_inputs_only() -> Result<(), Box<dyn Error>> {
2229        let temp = tempfile::tempdir()?;
2230        let repo = temp.path().join("repo");
2231        let projectatlas = repo.join(".projectatlas");
2232        fs::create_dir_all(&projectatlas)?;
2233        fs::write(
2234            projectatlas.join("config.toml"),
2235            "[project]\nroot = \".\"\n",
2236        )?;
2237        fs::write(
2238            projectatlas.join("projectatlas-nonsource-files.toon"),
2239            "nonsource_files[]:\n",
2240        )?;
2241        fs::write(
2242            projectatlas.join("projectatlas-purpose-review.json"),
2243            "{\"items\":[]}\n",
2244        )?;
2245        fs::write(projectatlas.join("projectatlas.db"), b"sqlite bytes")?;
2246        fs::write(projectatlas.join("projectatlas.toon"), "generated map\n")?;
2247        fs::write(projectatlas.join("projectatlas.mcp.json"), "{}\n")?;
2248
2249        let nodes = scan_repo(&repo, &ScanOptions::default())?;
2250        require_path(&nodes, ".projectatlas")?;
2251        require_path(&nodes, ".projectatlas/config.toml")?;
2252        require_path(&nodes, ".projectatlas/projectatlas-nonsource-files.toon")?;
2253        require_path(&nodes, ".projectatlas/projectatlas-purpose-review.json")?;
2254        reject_path(&nodes, ".projectatlas/projectatlas.db")?;
2255        reject_path(&nodes, ".projectatlas/projectatlas.toon")?;
2256        reject_path(&nodes, ".projectatlas/projectatlas.mcp.json")?;
2257        Ok(())
2258    }
2259
2260    #[test]
2261    fn scan_inherits_gitignore_for_ignored_directories() -> Result<(), Box<dyn Error>> {
2262        let temp = tempfile::tempdir()?;
2263        let repo = temp.path().join("repo");
2264        fs::create_dir_all(repo.join("local-state").join("memory"))?;
2265        fs::create_dir(repo.join("src"))?;
2266        fs::write(repo.join(".gitignore"), "local-state/\n")?;
2267        fs::write(
2268            repo.join("local-state").join("memory").join("notes.md"),
2269            "local ignored notes\n",
2270        )?;
2271        fs::write(repo.join("src").join("main.rs"), "fn main() {}\n")?;
2272
2273        let nodes = scan_repo(&repo, &ScanOptions::default())?;
2274        reject_path(&nodes, "local-state")?;
2275        reject_path(&nodes, "local-state/memory/notes.md")?;
2276        require_path(&nodes, "src/main.rs")?;
2277        Ok(())
2278    }
2279
2280    #[test]
2281    fn scan_path_inherits_gitignore_for_single_path_refresh() -> Result<(), Box<dyn Error>> {
2282        let temp = tempfile::tempdir()?;
2283        let repo = temp.path().join("repo");
2284        fs::create_dir_all(repo.join("local-state").join("memory"))?;
2285        fs::create_dir(repo.join("src"))?;
2286        fs::write(repo.join(".gitignore"), "local-state/\n")?;
2287        fs::write(
2288            repo.join("local-state").join("memory").join("notes.md"),
2289            "local ignored notes\n",
2290        )?;
2291        fs::write(repo.join("src").join("main.rs"), "fn main() {}\n")?;
2292
2293        let ignored = scan_path(
2294            &repo,
2295            &repo.join("local-state").join("memory").join("notes.md"),
2296            &ScanOptions::default(),
2297        )?;
2298        let indexed = scan_path(
2299            &repo,
2300            &repo.join("src").join("main.rs"),
2301            &ScanOptions::default(),
2302        )?;
2303        if ignored.is_some() {
2304            return Err(io::Error::other("single-path refresh indexed ignored state").into());
2305        }
2306        if indexed.is_none() {
2307            return Err(io::Error::other("single-path refresh skipped indexed source").into());
2308        }
2309        Ok(())
2310    }
2311
2312    #[test]
2313    fn root_scan_policy_classifies_absent_standard_and_atlas_ignores() -> Result<(), Box<dyn Error>>
2314    {
2315        let temp = tempfile::tempdir()?;
2316        let repo = temp.path().join("repo");
2317        fs::create_dir_all(repo.join(".git").join("info"))?;
2318        fs::write(repo.join(".gitignore"), "generated/\n")?;
2319        fs::write(repo.join(".ignore"), "drafts/\n")?;
2320        fs::write(
2321            repo.join(".git").join("info").join("exclude"),
2322            "private.txt\n",
2323        )?;
2324        let control = IndexWorkControl::new(IndexCancellation::new(), None);
2325        let policy = RootScanPolicy::discover(&repo, &ScanOptions::default(), &control)?;
2326
2327        require(
2328            policy.excludes_path(&repo.join("generated").join("missing.md"))?,
2329            "absent .gitignore target was not excluded",
2330        )?;
2331        require(
2332            policy.excludes_path(&repo.join("drafts").join("missing.md"))?,
2333            "absent .ignore target was not excluded",
2334        )?;
2335        require(
2336            policy.excludes_path(&repo.join("private.txt"))?,
2337            "absent Git info/exclude target was not excluded",
2338        )?;
2339        require(
2340            policy.excludes_path(&repo.join(".projectatlas").join("state.db"))?,
2341            "Atlas-specific excluded prefix was not retained",
2342        )?;
2343        Ok(())
2344    }
2345
2346    #[test]
2347    fn scan_path_skips_symlinked_files_before_canonicalizing() -> Result<(), Box<dyn Error>> {
2348        let temp = tempfile::tempdir()?;
2349        let repo = temp.path().join("repo");
2350        fs::create_dir(&repo)?;
2351        let outside = temp.path().join("outside.txt");
2352        let link = repo.join("linked.txt");
2353        fs::write(&outside, "outside secret\n")?;
2354        if !create_file_symlink(&outside, &link)? {
2355            return Ok(());
2356        }
2357
2358        let indexed = scan_path(&repo, &link, &ScanOptions::default())?;
2359        if indexed.is_some() {
2360            return Err(io::Error::other("single-path refresh indexed a symlink").into());
2361        }
2362        Ok(())
2363    }
2364
2365    #[test]
2366    fn scan_path_skips_symlinked_ancestor_before_canonicalizing() -> Result<(), Box<dyn Error>> {
2367        let temp = tempfile::tempdir()?;
2368        let repo = temp.path().join("repo");
2369        let outside = temp.path().join("outside");
2370        fs::create_dir(&repo)?;
2371        fs::create_dir(&outside)?;
2372        fs::write(outside.join("secret.rs"), "fn secret() {}\n")?;
2373        let link = repo.join("linked");
2374        if !create_dir_symlink(&outside, &link)? {
2375            return Ok(());
2376        }
2377
2378        let indexed = scan_path(&repo, &link.join("secret.rs"), &ScanOptions::default())?;
2379        if indexed.is_some() {
2380            return Err(
2381                io::Error::other("single-path refresh indexed through a symlinked folder").into(),
2382            );
2383        }
2384        Ok(())
2385    }
2386
2387    #[cfg(unix)]
2388    #[test]
2389    fn skips_symlinked_files() -> Result<(), Box<dyn Error>> {
2390        use std::os::unix::fs::symlink;
2391
2392        let temp = tempfile::tempdir()?;
2393        let repo = temp.path().join("repo");
2394        fs::create_dir(&repo)?;
2395        let outside = temp.path().join("outside.txt");
2396        fs::write(&outside, "outside secret\n")?;
2397        symlink(&outside, repo.join("linked.txt"))?;
2398
2399        let nodes = scan_repo(&repo, &ScanOptions::default())?;
2400        reject_path(&nodes, "linked.txt")?;
2401        Ok(())
2402    }
2403
2404    /// Create a file symlink for tests, returning false when the host forbids it.
2405    #[cfg(unix)]
2406    fn create_file_symlink(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2407        std::os::unix::fs::symlink(target, link)?;
2408        Ok(true)
2409    }
2410
2411    /// Create a file symlink for tests, returning false when the host forbids it.
2412    #[cfg(windows)]
2413    fn create_file_symlink(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2414        match std::os::windows::fs::symlink_file(target, link) {
2415            Ok(()) => Ok(true),
2416            Err(error)
2417                if error.kind() == io::ErrorKind::PermissionDenied
2418                    || error.raw_os_error() == Some(1314) =>
2419            {
2420                Ok(false)
2421            }
2422            Err(error) => Err(error.into()),
2423        }
2424    }
2425
2426    /// Create a directory symlink for tests, returning false when the host forbids it.
2427    #[cfg(unix)]
2428    fn create_dir_symlink(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2429        std::os::unix::fs::symlink(target, link)?;
2430        Ok(true)
2431    }
2432
2433    /// Create a directory symlink for tests, returning false when the host forbids it.
2434    #[cfg(windows)]
2435    fn create_dir_symlink(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2436        match std::os::windows::fs::symlink_dir(target, link) {
2437            Ok(()) => Ok(true),
2438            Err(error)
2439                if error.kind() == io::ErrorKind::PermissionDenied
2440                    || error.raw_os_error() == Some(1314) =>
2441            {
2442                Ok(false)
2443            }
2444            Err(error) => Err(error.into()),
2445        }
2446    }
2447
2448    /// Require a scanned node path to exist.
2449    fn require_path(nodes: &[Node], expected: &str) -> Result<(), Box<dyn Error>> {
2450        if nodes.iter().any(|node| node.path == expected) {
2451            Ok(())
2452        } else {
2453            Err(io::Error::other(format!("missing scanned path {expected}")).into())
2454        }
2455    }
2456
2457    /// Require a scanned node path not to exist.
2458    fn reject_path(nodes: &[Node], rejected: &str) -> Result<(), Box<dyn Error>> {
2459        if nodes.iter().any(|node| node.path == rejected) {
2460            Err(io::Error::other(format!("unexpected scanned path {rejected}")).into())
2461        } else {
2462            Ok(())
2463        }
2464    }
2465
2466    /// Require a test condition without panicking from a fallible test.
2467    fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
2468        if condition {
2469            Ok(())
2470        } else {
2471            Err(io::Error::other(message).into())
2472        }
2473    }
2474}