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_cancellation_between_chunks() {
1807        let exact_budget = ScanBudget::new(
1808            ScanLimits::new(8, (HASH_BUFFER_BYTES * 2) as u64, 1),
1809            IndexWorkControl::new(IndexCancellation::new(), None),
1810        );
1811        let exact_source = vec![3_u8; HASH_BUFFER_BYTES + 17];
1812        let exact_digest = blake3::hash(&exact_source).to_hex().to_string();
1813        let exact = hash_reader(
1814            Path::new("exact-source.rs"),
1815            io::Cursor::new(exact_source),
1816            &exact_budget,
1817            false,
1818        );
1819        assert!(matches!(
1820            exact,
1821            Ok(HashedFile {
1822                digest,
1823                size_bytes,
1824                content_prefix,
1825            }) if digest == exact_digest
1826                && size_bytes == (HASH_BUFFER_BYTES + 17) as u64
1827                && content_prefix.is_none()
1828        ));
1829
1830        let cancellation = IndexCancellation::new();
1831        let control = IndexWorkControl::new(cancellation.clone(), None);
1832        let budget = ScanBudget::new(ScanLimits::default(), control);
1833        let reader = CancelAfterFirstChunk {
1834            inner: io::Cursor::new(vec![7_u8; HASH_BUFFER_BYTES * 2]),
1835            cancellation,
1836            canceled: false,
1837        };
1838
1839        let result = hash_reader(Path::new("source.rs"), reader, &budget, false);
1840        assert!(matches!(
1841            result,
1842            Err(FsError::IndexWork(IndexWorkFailure::Cancelled {
1843                stage: IndexWorkStage::SourceHash,
1844            }))
1845        ));
1846
1847        let byte_budget = ScanBudget::new(
1848            ScanLimits::new(8, HASH_BUFFER_BYTES as u64, 1),
1849            IndexWorkControl::new(IndexCancellation::new(), None),
1850        );
1851        let oversized = hash_reader(
1852            Path::new("growing-source.rs"),
1853            io::Cursor::new(vec![7_u8; HASH_BUFFER_BYTES * 2]),
1854            &byte_budget,
1855            false,
1856        );
1857        assert!(matches!(
1858            oversized,
1859            Err(FsError::IndexWork(
1860                IndexWorkFailure::ResourceLimitExceeded {
1861                    stage: IndexWorkStage::SourceHash,
1862                    resource: IndexWorkResource::SourceBytes,
1863                    ..
1864                }
1865            ))
1866        ));
1867    }
1868
1869    #[test]
1870    fn classified_hash_retains_no_content_prefix() -> Result<(), Box<dyn Error>> {
1871        let preliminary_language =
1872            detect_language_request(LanguageDetectionRequest::new("source.rs", Some(".rs")))?;
1873        let budget = ScanBudget::new(
1874            ScanLimits::new(8, 64, 1),
1875            IndexWorkControl::new(IndexCancellation::new(), None),
1876        );
1877
1878        let hashed = hash_reader(
1879            Path::new("source.rs"),
1880            io::Cursor::new(b"fn main() {}\n"),
1881            &budget,
1882            preliminary_language.is_none(),
1883        )?;
1884
1885        require(
1886            preliminary_language.map(|detected| detected.language) == Some("rust"),
1887            "preliminary extension classification did not select Rust",
1888        )?;
1889        require(
1890            hashed.content_prefix.is_none(),
1891            "classified hash retained a content prefix",
1892        )?;
1893        Ok(())
1894    }
1895
1896    #[test]
1897    fn scans_files_and_folders() -> Result<(), Box<dyn Error>> {
1898        let temp = tempfile::tempdir()?;
1899        let src = temp.path().join("src");
1900        fs::create_dir(&src)?;
1901        fs::write(src.join("main.rs"), "fn main() {}\n")?;
1902        fs::write(src.join(".purpose"), "Rust source folder\n")?;
1903
1904        let nodes = scan_repo(temp.path(), &ScanOptions::default())?;
1905        require_path(&nodes, ".")?;
1906        require_path(&nodes, "src")?;
1907        require_path(&nodes, "src/main.rs")?;
1908        reject_path(&nodes, "src/.purpose")?;
1909        Ok(())
1910    }
1911
1912    #[test]
1913    fn scan_uses_explicit_language_override_before_builtin_filename_rules()
1914    -> Result<(), Box<dyn Error>> {
1915        let temp = tempfile::tempdir()?;
1916        fs::write(temp.path().join("Cargo.toml"), "#!/usr/bin/env node\n")?;
1917        let mut options = ScanOptions::default();
1918        options
1919            .language_overrides
1920            .insert(".toml".to_string(), "python".to_string());
1921
1922        let nodes = scan_repo(temp.path(), &options)?;
1923        let cargo = nodes
1924            .iter()
1925            .find(|node| node.path == "Cargo.toml")
1926            .ok_or_else(|| io::Error::other("Cargo.toml was not scanned"))?;
1927        require(
1928            cargo.language.as_deref() == Some("python"),
1929            "explicit language override did not win",
1930        )?;
1931        Ok(())
1932    }
1933
1934    #[test]
1935    fn invalid_language_override_fails_before_source_hashing() -> Result<(), Box<dyn Error>> {
1936        let temp = tempfile::tempdir()?;
1937        let source_path = temp.path().join("source.rs");
1938        fs::write(&source_path, "fn main() {}\n")?;
1939        let mut options = ScanOptions::default();
1940        options
1941            .language_overrides
1942            .insert(".rs".to_string(), "missing-language".to_string());
1943
1944        for _ in 0..2 {
1945            let result = scan_path_controlled(
1946                temp.path(),
1947                &source_path,
1948                &options,
1949                ScanLimits::new(8, 0, 1),
1950                &IndexWorkControl::new(IndexCancellation::new(), None),
1951            );
1952            match result {
1953                Err(FsError::Io { source, .. }) => {
1954                    require(
1955                        source.kind() == io::ErrorKind::InvalidInput,
1956                        "invalid override did not return InvalidInput",
1957                    )?;
1958                    require(
1959                        source.to_string()
1960                            == "unknown explicit language override \"missing-language\"",
1961                        "invalid override diagnostic was not deterministic",
1962                    )?;
1963                }
1964                other => {
1965                    return Err(io::Error::other(format!("unexpected result: {other:?}")).into());
1966                }
1967            }
1968        }
1969        Ok(())
1970    }
1971
1972    #[test]
1973    fn scan_detects_bounded_shebang_from_the_existing_hash_read() -> Result<(), Box<dyn Error>> {
1974        let temp = tempfile::tempdir()?;
1975        fs::write(
1976            temp.path().join("tool"),
1977            "#!/usr/bin/env python\nprint('atlas')\n",
1978        )?;
1979
1980        let nodes = scan_repo(temp.path(), &ScanOptions::default())?;
1981        let tool = nodes
1982            .iter()
1983            .find(|node| node.path == "tool")
1984            .ok_or_else(|| io::Error::other("extensionless tool was not scanned"))?;
1985        require(
1986            tool.language.as_deref() == Some("python"),
1987            "extensionless shebang was not detected from the retained prefix",
1988        )?;
1989        Ok(())
1990    }
1991
1992    #[test]
1993    fn default_scan_keeps_optional_catalog_recognition_inactive() -> Result<(), Box<dyn Error>> {
1994        let temp = tempfile::tempdir()?;
1995        let optional_path = temp.path().join("report.awk");
1996        fs::write(&optional_path, "{ print $1 }\n")?;
1997        fs::write(temp.path().join("main.rs"), "fn main() {}\n")?;
1998
1999        let recognized =
2000            detect_language_request(LanguageDetectionRequest::new("report.awk", Some(".awk")))?;
2001        require(
2002            recognized.map(|detected| detected.language) == Some("awk"),
2003            "optional AWK recognition was removed from the core catalog",
2004        )?;
2005
2006        let nodes = scan_repo(temp.path(), &ScanOptions::default())?;
2007        let optional = nodes
2008            .iter()
2009            .find(|node| node.path == "report.awk")
2010            .ok_or_else(|| io::Error::other("optional source was not scanned"))?;
2011        require(
2012            optional.language.is_none(),
2013            "default scan admitted an inactive optional language",
2014        )?;
2015        let built_in = nodes
2016            .iter()
2017            .find(|node| node.path == "main.rs")
2018            .ok_or_else(|| io::Error::other("built-in Rust source was not scanned"))?;
2019        require(
2020            built_in.language.as_deref() == Some("rust"),
2021            "optional-language admission changed built-in recognition",
2022        )?;
2023
2024        let refreshed = scan_path(temp.path(), &optional_path, &ScanOptions::default())?
2025            .ok_or_else(|| io::Error::other("optional source was not refreshed"))?;
2026        require(
2027            refreshed.language.is_none(),
2028            "single-path refresh admitted an inactive optional language",
2029        )?;
2030        Ok(())
2031    }
2032
2033    #[test]
2034    fn enabled_scan_admits_optional_language_for_full_and_single_path_scans()
2035    -> Result<(), Box<dyn Error>> {
2036        let temp = tempfile::tempdir()?;
2037        let optional_path = temp.path().join("report.awk");
2038        fs::write(&optional_path, "{ print $1 }\n")?;
2039        let options = ScanOptions {
2040            admit_optional_languages: true,
2041            ..ScanOptions::default()
2042        };
2043
2044        let nodes = scan_repo(temp.path(), &options)?;
2045        let optional = nodes
2046            .iter()
2047            .find(|node| node.path == "report.awk")
2048            .ok_or_else(|| io::Error::other("optional source was not scanned"))?;
2049        require(
2050            optional.language.as_deref() == Some("awk"),
2051            "enabled scan did not admit the optional AWK language",
2052        )?;
2053
2054        let refreshed = scan_path(temp.path(), &optional_path, &options)?
2055            .ok_or_else(|| io::Error::other("optional source was not refreshed"))?;
2056        require(
2057            refreshed.language.as_deref() == Some("awk"),
2058            "enabled single-path refresh did not admit the optional AWK language",
2059        )?;
2060        Ok(())
2061    }
2062
2063    #[test]
2064    fn optional_override_respects_admission_and_rejected_extension_uses_content_fallback()
2065    -> Result<(), Box<dyn Error>> {
2066        let temp = tempfile::tempdir()?;
2067        fs::write(temp.path().join("report.txt"), "{ print $1 }\n")?;
2068        fs::write(
2069            temp.path().join("tool.awk"),
2070            "#!/usr/bin/env python\nprint('atlas')\n",
2071        )?;
2072        let mut options = ScanOptions::default();
2073        options
2074            .language_overrides
2075            .insert(".txt".to_string(), "awk".to_string());
2076
2077        let nodes = scan_repo(temp.path(), &options)?;
2078        let overridden = nodes
2079            .iter()
2080            .find(|node| node.path == "report.txt")
2081            .ok_or_else(|| io::Error::other("overridden source was not scanned"))?;
2082        require(
2083            overridden.language.is_none(),
2084            "explicit override bypassed optional-language admission",
2085        )?;
2086        let content_detected = nodes
2087            .iter()
2088            .find(|node| node.path == "tool.awk")
2089            .ok_or_else(|| io::Error::other("content-detected source was not scanned"))?;
2090        require(
2091            content_detected.language.as_deref() == Some("python"),
2092            "rejected optional extension did not fall through to built-in content detection",
2093        )?;
2094
2095        options.admit_optional_languages = true;
2096        let nodes = scan_repo(temp.path(), &options)?;
2097        let overridden = nodes
2098            .iter()
2099            .find(|node| node.path == "report.txt")
2100            .ok_or_else(|| io::Error::other("enabled overridden source was not scanned"))?;
2101        require(
2102            overridden.language.as_deref() == Some("awk"),
2103            "enabled scan did not admit an explicit optional-language override",
2104        )?;
2105        Ok(())
2106    }
2107
2108    #[test]
2109    fn default_scan_uses_gitignore_for_local_state() -> Result<(), Box<dyn Error>> {
2110        let temp = tempfile::tempdir()?;
2111        let repo = temp.path().join("repo");
2112        fs::create_dir_all(repo.join("local-agent-state").join("rules").join("memory"))?;
2113        fs::create_dir(repo.join("src"))?;
2114        fs::write(
2115            repo.join("local-agent-state")
2116                .join("rules")
2117                .join("memory")
2118                .join("activeContext.md"),
2119            "private local agent state\n",
2120        )?;
2121        fs::write(repo.join("src").join("main.rs"), "fn main() {}\n")?;
2122        fs::write(repo.join(".gitignore"), "local-agent-state/\n")?;
2123
2124        let nodes = scan_repo(&repo, &ScanOptions::default())?;
2125        reject_path(&nodes, "local-agent-state")?;
2126        reject_path(&nodes, "local-agent-state/rules/memory/activeContext.md")?;
2127        require_path(&nodes, "src/main.rs")?;
2128        Ok(())
2129    }
2130
2131    #[test]
2132    fn scans_repo_under_excluded_named_parent() -> Result<(), Box<dyn Error>> {
2133        let temp = tempfile::tempdir()?;
2134        let repo = temp.path().join("target").join("repo");
2135        let src = repo.join("src");
2136        fs::create_dir_all(&src)?;
2137        fs::write(src.join("main.rs"), "fn main() {}\n")?;
2138
2139        let nodes = scan_repo(&repo, &ScanOptions::default())?;
2140        require_path(&nodes, ".")?;
2141        require_path(&nodes, "src")?;
2142        require_path(&nodes, "src/main.rs")?;
2143        Ok(())
2144    }
2145
2146    #[test]
2147    fn excludes_configured_path_prefix_without_hiding_same_named_source()
2148    -> Result<(), Box<dyn Error>> {
2149        let temp = tempfile::tempdir()?;
2150        let repo = temp.path().join("repo");
2151        fs::create_dir_all(repo.join("docs").join("api"))?;
2152        fs::create_dir_all(repo.join("src").join("api"))?;
2153        fs::write(
2154            repo.join("docs").join("api").join("generated.rs"),
2155            "fn generated() {}\n",
2156        )?;
2157        fs::write(
2158            repo.join("src").join("api").join("live.rs"),
2159            "fn live() {}\n",
2160        )?;
2161        let options = ScanOptions {
2162            exclude_path_prefixes: vec!["docs\\api".to_string()],
2163            ..ScanOptions::default()
2164        };
2165
2166        let nodes = scan_repo(&repo, &options)?;
2167        reject_path(&nodes, "docs/api")?;
2168        reject_path(&nodes, "docs/api/generated.rs")?;
2169        require_path(&nodes, "docs")?;
2170        require_path(&nodes, "src/api")?;
2171        require_path(&nodes, "src/api/live.rs")?;
2172        Ok(())
2173    }
2174
2175    #[test]
2176    fn excludes_configured_directory_suffixes_for_full_and_single_path_scans()
2177    -> Result<(), Box<dyn Error>> {
2178        let temp = tempfile::tempdir()?;
2179        let repo = temp.path().join("repo");
2180        fs::create_dir_all(repo.join("vendor.egg-info"))?;
2181        fs::create_dir_all(repo.join("src").join("live"))?;
2182        fs::write(repo.join("vendor.egg-info").join("PKG-INFO"), "metadata\n")?;
2183        fs::write(
2184            repo.join("src").join("live").join("main.rs"),
2185            "fn main() {}\n",
2186        )?;
2187        let options = ScanOptions {
2188            exclude_dir_suffixes: vec![".egg-info".to_string()],
2189            ..ScanOptions::default()
2190        };
2191
2192        let nodes = scan_repo(&repo, &options)?;
2193        reject_path(&nodes, "vendor.egg-info")?;
2194        reject_path(&nodes, "vendor.egg-info/PKG-INFO")?;
2195        require_path(&nodes, "src/live/main.rs")?;
2196
2197        let single = scan_path(
2198            &repo,
2199            &repo.join("vendor.egg-info").join("PKG-INFO"),
2200            &options,
2201        )?;
2202        if single.is_some() {
2203            return Err(
2204                io::Error::other("single-path refresh indexed suffix-excluded file").into(),
2205            );
2206        }
2207        Ok(())
2208    }
2209
2210    #[test]
2211    fn default_scan_indexes_durable_projectatlas_inputs_only() -> Result<(), Box<dyn Error>> {
2212        let temp = tempfile::tempdir()?;
2213        let repo = temp.path().join("repo");
2214        let projectatlas = repo.join(".projectatlas");
2215        fs::create_dir_all(&projectatlas)?;
2216        fs::write(
2217            projectatlas.join("config.toml"),
2218            "[project]\nroot = \".\"\n",
2219        )?;
2220        fs::write(
2221            projectatlas.join("projectatlas-nonsource-files.toon"),
2222            "nonsource_files[]:\n",
2223        )?;
2224        fs::write(
2225            projectatlas.join("projectatlas-purpose-review.json"),
2226            "{\"items\":[]}\n",
2227        )?;
2228        fs::write(projectatlas.join("projectatlas.db"), b"sqlite bytes")?;
2229        fs::write(projectatlas.join("projectatlas.toon"), "generated map\n")?;
2230        fs::write(projectatlas.join("projectatlas.mcp.json"), "{}\n")?;
2231
2232        let nodes = scan_repo(&repo, &ScanOptions::default())?;
2233        require_path(&nodes, ".projectatlas")?;
2234        require_path(&nodes, ".projectatlas/config.toml")?;
2235        require_path(&nodes, ".projectatlas/projectatlas-nonsource-files.toon")?;
2236        require_path(&nodes, ".projectatlas/projectatlas-purpose-review.json")?;
2237        reject_path(&nodes, ".projectatlas/projectatlas.db")?;
2238        reject_path(&nodes, ".projectatlas/projectatlas.toon")?;
2239        reject_path(&nodes, ".projectatlas/projectatlas.mcp.json")?;
2240        Ok(())
2241    }
2242
2243    #[test]
2244    fn scan_inherits_gitignore_for_ignored_directories() -> Result<(), Box<dyn Error>> {
2245        let temp = tempfile::tempdir()?;
2246        let repo = temp.path().join("repo");
2247        fs::create_dir_all(repo.join("local-state").join("memory"))?;
2248        fs::create_dir(repo.join("src"))?;
2249        fs::write(repo.join(".gitignore"), "local-state/\n")?;
2250        fs::write(
2251            repo.join("local-state").join("memory").join("notes.md"),
2252            "local ignored notes\n",
2253        )?;
2254        fs::write(repo.join("src").join("main.rs"), "fn main() {}\n")?;
2255
2256        let nodes = scan_repo(&repo, &ScanOptions::default())?;
2257        reject_path(&nodes, "local-state")?;
2258        reject_path(&nodes, "local-state/memory/notes.md")?;
2259        require_path(&nodes, "src/main.rs")?;
2260        Ok(())
2261    }
2262
2263    #[test]
2264    fn scan_path_inherits_gitignore_for_single_path_refresh() -> Result<(), Box<dyn Error>> {
2265        let temp = tempfile::tempdir()?;
2266        let repo = temp.path().join("repo");
2267        fs::create_dir_all(repo.join("local-state").join("memory"))?;
2268        fs::create_dir(repo.join("src"))?;
2269        fs::write(repo.join(".gitignore"), "local-state/\n")?;
2270        fs::write(
2271            repo.join("local-state").join("memory").join("notes.md"),
2272            "local ignored notes\n",
2273        )?;
2274        fs::write(repo.join("src").join("main.rs"), "fn main() {}\n")?;
2275
2276        let ignored = scan_path(
2277            &repo,
2278            &repo.join("local-state").join("memory").join("notes.md"),
2279            &ScanOptions::default(),
2280        )?;
2281        let indexed = scan_path(
2282            &repo,
2283            &repo.join("src").join("main.rs"),
2284            &ScanOptions::default(),
2285        )?;
2286        if ignored.is_some() {
2287            return Err(io::Error::other("single-path refresh indexed ignored state").into());
2288        }
2289        if indexed.is_none() {
2290            return Err(io::Error::other("single-path refresh skipped indexed source").into());
2291        }
2292        Ok(())
2293    }
2294
2295    #[test]
2296    fn root_scan_policy_classifies_absent_standard_and_atlas_ignores() -> Result<(), Box<dyn Error>>
2297    {
2298        let temp = tempfile::tempdir()?;
2299        let repo = temp.path().join("repo");
2300        fs::create_dir_all(repo.join(".git").join("info"))?;
2301        fs::write(repo.join(".gitignore"), "generated/\n")?;
2302        fs::write(repo.join(".ignore"), "drafts/\n")?;
2303        fs::write(
2304            repo.join(".git").join("info").join("exclude"),
2305            "private.txt\n",
2306        )?;
2307        let control = IndexWorkControl::new(IndexCancellation::new(), None);
2308        let policy = RootScanPolicy::discover(&repo, &ScanOptions::default(), &control)?;
2309
2310        require(
2311            policy.excludes_path(&repo.join("generated").join("missing.md"))?,
2312            "absent .gitignore target was not excluded",
2313        )?;
2314        require(
2315            policy.excludes_path(&repo.join("drafts").join("missing.md"))?,
2316            "absent .ignore target was not excluded",
2317        )?;
2318        require(
2319            policy.excludes_path(&repo.join("private.txt"))?,
2320            "absent Git info/exclude target was not excluded",
2321        )?;
2322        require(
2323            policy.excludes_path(&repo.join(".projectatlas").join("state.db"))?,
2324            "Atlas-specific excluded prefix was not retained",
2325        )?;
2326        Ok(())
2327    }
2328
2329    #[test]
2330    fn scan_path_skips_symlinked_files_before_canonicalizing() -> Result<(), Box<dyn Error>> {
2331        let temp = tempfile::tempdir()?;
2332        let repo = temp.path().join("repo");
2333        fs::create_dir(&repo)?;
2334        let outside = temp.path().join("outside.txt");
2335        let link = repo.join("linked.txt");
2336        fs::write(&outside, "outside secret\n")?;
2337        if !create_file_symlink(&outside, &link)? {
2338            return Ok(());
2339        }
2340
2341        let indexed = scan_path(&repo, &link, &ScanOptions::default())?;
2342        if indexed.is_some() {
2343            return Err(io::Error::other("single-path refresh indexed a symlink").into());
2344        }
2345        Ok(())
2346    }
2347
2348    #[test]
2349    fn scan_path_skips_symlinked_ancestor_before_canonicalizing() -> Result<(), Box<dyn Error>> {
2350        let temp = tempfile::tempdir()?;
2351        let repo = temp.path().join("repo");
2352        let outside = temp.path().join("outside");
2353        fs::create_dir(&repo)?;
2354        fs::create_dir(&outside)?;
2355        fs::write(outside.join("secret.rs"), "fn secret() {}\n")?;
2356        let link = repo.join("linked");
2357        if !create_dir_symlink(&outside, &link)? {
2358            return Ok(());
2359        }
2360
2361        let indexed = scan_path(&repo, &link.join("secret.rs"), &ScanOptions::default())?;
2362        if indexed.is_some() {
2363            return Err(
2364                io::Error::other("single-path refresh indexed through a symlinked folder").into(),
2365            );
2366        }
2367        Ok(())
2368    }
2369
2370    #[cfg(unix)]
2371    #[test]
2372    fn skips_symlinked_files() -> Result<(), Box<dyn Error>> {
2373        use std::os::unix::fs::symlink;
2374
2375        let temp = tempfile::tempdir()?;
2376        let repo = temp.path().join("repo");
2377        fs::create_dir(&repo)?;
2378        let outside = temp.path().join("outside.txt");
2379        fs::write(&outside, "outside secret\n")?;
2380        symlink(&outside, repo.join("linked.txt"))?;
2381
2382        let nodes = scan_repo(&repo, &ScanOptions::default())?;
2383        reject_path(&nodes, "linked.txt")?;
2384        Ok(())
2385    }
2386
2387    /// Create a file symlink for tests, returning false when the host forbids it.
2388    #[cfg(unix)]
2389    fn create_file_symlink(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2390        std::os::unix::fs::symlink(target, link)?;
2391        Ok(true)
2392    }
2393
2394    /// Create a file symlink for tests, returning false when the host forbids it.
2395    #[cfg(windows)]
2396    fn create_file_symlink(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2397        match std::os::windows::fs::symlink_file(target, link) {
2398            Ok(()) => Ok(true),
2399            Err(error)
2400                if error.kind() == io::ErrorKind::PermissionDenied
2401                    || error.raw_os_error() == Some(1314) =>
2402            {
2403                Ok(false)
2404            }
2405            Err(error) => Err(error.into()),
2406        }
2407    }
2408
2409    /// Create a directory symlink for tests, returning false when the host forbids it.
2410    #[cfg(unix)]
2411    fn create_dir_symlink(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2412        std::os::unix::fs::symlink(target, link)?;
2413        Ok(true)
2414    }
2415
2416    /// Create a directory symlink for tests, returning false when the host forbids it.
2417    #[cfg(windows)]
2418    fn create_dir_symlink(target: &Path, link: &Path) -> Result<bool, Box<dyn Error>> {
2419        match std::os::windows::fs::symlink_dir(target, link) {
2420            Ok(()) => Ok(true),
2421            Err(error)
2422                if error.kind() == io::ErrorKind::PermissionDenied
2423                    || error.raw_os_error() == Some(1314) =>
2424            {
2425                Ok(false)
2426            }
2427            Err(error) => Err(error.into()),
2428        }
2429    }
2430
2431    /// Require a scanned node path to exist.
2432    fn require_path(nodes: &[Node], expected: &str) -> Result<(), Box<dyn Error>> {
2433        if nodes.iter().any(|node| node.path == expected) {
2434            Ok(())
2435        } else {
2436            Err(io::Error::other(format!("missing scanned path {expected}")).into())
2437        }
2438    }
2439
2440    /// Require a scanned node path not to exist.
2441    fn reject_path(nodes: &[Node], rejected: &str) -> Result<(), Box<dyn Error>> {
2442        if nodes.iter().any(|node| node.path == rejected) {
2443            Err(io::Error::other(format!("unexpected scanned path {rejected}")).into())
2444        } else {
2445            Ok(())
2446        }
2447    }
2448
2449    /// Require a test condition without panicking from a fallible test.
2450    fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
2451        if condition {
2452            Ok(())
2453        } else {
2454            Err(io::Error::other(message).into())
2455        }
2456    }
2457}