projectatlas_fs/
lib.rs

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