Skip to main content

projectatlas/
runtime.rs

1//! Purpose: Coordinate shared `ProjectAtlas` CLI and MCP runtime workflows.
2//! Shared runtime orchestration for the `ProjectAtlas` CLI and MCP adapters.
3
4mod graph_projection;
5mod module_resolution;
6#[cfg(feature = "optional-parser-supervisor")]
7mod optional_parser_runtime;
8mod source_observation;
9
10pub(crate) use source_observation::{
11    SourceObservationRegistry, SourceReadAdmission, VerifiedReadOutcome, VerifiedReadStamp,
12};
13
14use crate::atlas_map::{
15    self, init_project_with_config, load_atlas_config, load_atlas_config_for_root,
16    load_atlas_config_from_text,
17};
18use crate::structural::{
19    document_summary_from_facts, is_scanner_fallback_summary, is_structural_summary_candidate,
20    markdown_summary_from_facts, structural_summary_for_path,
21};
22use crate::{
23    CliError, OutputFormat, WATCH_MODE_NOTIFY, WATCH_MODE_ONCE, WATCH_MODE_POLLING, truthy_env,
24};
25use blake3::Hasher;
26use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
27#[cfg(feature = "optional-parser-supervisor")]
28use projectatlas_cli::optional_parser_lifecycle::{
29    OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH, OptionalParserPackLifecycle,
30    OptionalParserPackLifecycleReport, OptionalParserPackProjectSelection,
31};
32use projectatlas_core::graph::{ExtendedRelationKind, GraphRelationKind, ProjectInstanceId};
33use projectatlas_core::health::{
34    CATEGORY_DUPLICATE_PURPOSE, CATEGORY_MISSING_PURPOSE, CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
35    CATEGORY_REPEATED_TEMPORARY_FOLDER, CATEGORY_STALE_PURPOSE, CATEGORY_SUGGESTED_PURPOSE_REVIEW,
36    HealthFinding, Severity,
37};
38use projectatlas_core::language::{
39    ACCEPTED_LANGUAGE_CAPABILITY_SET_VERSION, ContentClassification, ContentSelection,
40    LANGUAGE_CAPABILITY_REGISTRY_VERSION, LanguageRegistryReport, SymbolParserOwner,
41    accepted_language_capability_digest, content_classification, detect_language_for_path,
42    language_capability, language_registry_digest, language_registry_report,
43    normalized_language_extension,
44};
45#[cfg(all(test, feature = "optional-parser-supervisor"))]
46use projectatlas_core::optional_parser_pack::OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION;
47use projectatlas_core::outline::estimate_tokens;
48use projectatlas_core::relation_capabilities::{
49    RelationFamilyInventoryReport, relation_family_inventory_report,
50};
51use projectatlas_core::symbols::{
52    ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
53};
54use projectatlas_core::telemetry::{
55    TOKEN_BASELINE_DIRECTORY_WALK, TOKEN_BASELINE_SELECTED_CANDIDATES,
56    TOKEN_BUCKET_NAVIGATION_AVOIDANCE, TOKEN_CONFIDENCE_INFERRED, TOKEN_CONFIDENCE_POLICY_ESTIMATE,
57    UsageInstanceId, UsageInstanceOwner, usage_from_estimates_with_context, usage_from_text,
58};
59use projectatlas_core::toon::{encode_agent_payload, render_ranked_node_rows, render_symbol_rows};
60use projectatlas_core::{
61    CanonicalProjectRoot, IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure,
62    IndexWorkResource, IndexWorkStage, Node, NodeKind, Overview, PurposeSource, PurposeStatus,
63    normalize_native_path_display, normalize_native_path_display_str, normalize_repo_path,
64    purpose_review_signal, repo_path_to_native, validated_repo_file_key, validated_repo_node_key,
65};
66use projectatlas_db::{
67    AtlasStore, ClassifiedSymbol, DatabasePublicationContractState, DatabasePublicationReport,
68    DatabaseSchemaCompatibility, DatabaseSettingsReport, DbResult, FileContentClassification,
69    HealthFindingsPage, HealthQuery, HealthScope, IndexPublication, IndexPublicationGuard,
70    IndexPublicationState, IndexedFileText, MAX_FILE_CONTENT_CLASSIFICATION_PATHS,
71    MAX_PURPOSE_CURATION_BATCH_ROWS, PurposeConditionalApplyRequest, PurposeConditionalApplyState,
72    TelemetryRetentionState, WorktreeRegistration, WorktreeUsageSnapshot, database_settings_report,
73    preflight_project_binding_read_only, read_legacy_project_root_candidate_read_only,
74    read_project_root_identity_read_only, validate_database_location,
75};
76use projectatlas_fs::worktree::{
77    GitManagerSourceSelection, GitRepositorySelection, GitWorktreeState, RepositoryStructure,
78    git_administrative_identity, git_worktree_lifecycle_matches,
79};
80use projectatlas_fs::{
81    FsError, RootScanPolicy, ScanLimits, ScanOptions, gitignore_excludes_path,
82    scan_path_with_policy_controlled, scan_repo, scan_repo_controlled,
83    scan_repo_controlled_with_work,
84};
85use projectatlas_service::{
86    ClassifiedRankedNode, CoverageDiscoveryReport, FederatedInputWork, FederatedStore,
87    FilePathMatcher, MAX_FEDERATED_DATABASE_BYTES, MAX_FEDERATED_INPUT_BYTES, NextStepReport,
88    TokenReport, TokenReportRequest,
89    build_next_report_with_selection as build_next_report_with_selection_service,
90    load_agent_efficiency_comparison,
91    load_classified_ranked_file_nodes_with_reasons as load_classified_ranked_file_nodes_with_reasons_service,
92    load_ranked_file_nodes_with_reasons, load_ranked_folder_nodes_with_reasons,
93    validate_federated_root_count,
94};
95use projectatlas_symbols::{
96    DocumentExtractionError, DocumentLimit, MAX_DOCUMENT_COMPRESSED_BYTES, MarkdownFacts,
97    SourceSemanticFacts, document_format_for_path, extract_document_symbol_facts_controlled,
98    extract_document_text_controlled, extract_markdown_facts_controlled,
99    extract_symbol_graph_with_semantics_controlled, semantic_resolution_contract_digest,
100};
101use rayon::ThreadPoolBuilder;
102use rayon::prelude::*;
103use serde::{Deserialize, Serialize};
104use serde_json::{Value, json};
105use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
106use std::fmt;
107use std::fmt::Write as _;
108use std::fs;
109use std::io::{self, Read};
110use std::path::{Path, PathBuf};
111use std::sync::Arc;
112use std::sync::atomic::{AtomicBool, Ordering};
113use std::sync::mpsc::{self, RecvTimeoutError, TrySendError};
114use std::thread;
115use std::time::{Duration, Instant};
116
117/// Maximum file size parsed for symbols by default.
118pub(crate) const MAX_SYMBOL_FILE_BYTES: u64 = 2_000_000;
119/// Default health rows returned when the caller does not request a page size.
120pub(crate) const DEFAULT_HEALTH_LIMIT: usize = 50;
121/// Maximum health rows returned in one payload.
122pub(crate) const MAX_HEALTH_LIMIT: usize = 200;
123/// Maximum JSON bytes read for one CLI purpose-review batch.
124pub(crate) const MAX_PURPOSE_REVIEW_INPUT_FILE_BYTES: u64 = 2 * 1_024 * 1_024;
125/// Stable serialized recommendation for host-owned purpose curator selection.
126pub(crate) const PURPOSE_CURATOR_RECOMMENDED_REASONING: &str = "lowest_reliable_host_supported";
127/// Default whole-operation deadline when no narrower parser limit is supplied.
128const DEFAULT_INDEX_WORK_TIMEOUT: Duration = Duration::from_mins(30);
129/// Maximum UTF-8 source bytes retained while one publication is staged.
130const MAX_STAGED_TEXT_BYTES: u64 = 512 * 1024 * 1024;
131/// Maximum aggregate retained string bytes across one in-memory publication batch.
132const MAX_PUBLICATION_STAGING_BYTES: u64 = 2 * 1024 * 1024 * 1024;
133/// Maximum scan-node mutations applied between publication cancellation checks.
134const PUBLICATION_NODE_BATCH_SIZE: usize = 1_024;
135/// Maximum deleted repository paths applied between publication cancellation checks.
136const PUBLICATION_PATH_BATCH_SIZE: usize = 128;
137/// Maximum persisted source texts applied between publication cancellation checks.
138const PUBLICATION_TEXT_BATCH_SIZE: usize = 32;
139/// Maximum symbol parse results retained before sequential persistence.
140#[cfg(not(feature = "optional-parser-supervisor"))]
141const SYMBOL_PARSE_BATCH_SIZE: usize = 64;
142/// Maximum symbol candidates accepted by one publication.
143const MAX_SYMBOL_PARSE_JOBS: usize = 100_000;
144/// Maximum event paths accepted by one incremental watcher publication.
145const MAX_INCREMENTAL_CHANGED_PATHS: usize = 100_000;
146/// Maximum source bytes accepted by one incremental watcher publication.
147const MAX_INCREMENTAL_SOURCE_BYTES: u64 = 1024 * 1024 * 1024;
148/// Maximum native watcher events buffered before continuity becomes uncertain.
149const WATCH_EVENT_QUEUE_CAPACITY: usize = 1_024;
150/// Maximum indexing workers regardless of a larger caller request.
151pub(crate) const INDEX_WORKER_SAFE_CEILING: usize = 32;
152/// Chunk size used by cancellation-aware bounded source reads.
153const CONTROLLED_SOURCE_READ_BUFFER_BYTES: usize = 8_192;
154/// Maximum aggregate authored-purpose bytes inspected by one publication.
155const MAX_PURPOSE_IMPORT_BYTES: u64 = 512 * 1_024 * 1_024;
156/// Maximum complete config, map, or non-source purpose input size.
157const MAX_PURPOSE_INPUT_FILE_BYTES: u64 = 16 * 1_024 * 1_024;
158/// Maximum bytes in one repository path supplied to purpose review.
159const MAX_PURPOSE_REVIEW_PATH_BYTES: usize = 4 * 1_024;
160/// Maximum bytes in one non-path purpose-review string field.
161const MAX_PURPOSE_REVIEW_FIELD_BYTES: usize = 64 * 1_024;
162/// Purpose-review report field name for an item error.
163const PURPOSE_REVIEW_REPORT_ERROR_FIELD: &str = "error";
164/// Maximum aggregate string bytes admitted to one purpose-review batch.
165const MAX_PURPOSE_REVIEW_INPUT_BYTES: usize = 512 * 1_024;
166/// Maximum retained item/output bytes for one purpose-review report.
167const MAX_PURPOSE_REVIEW_REPORT_BYTES: usize = 4 * 1_024 * 1_024;
168/// Maximum source prefix inspected for a legacy purpose header.
169const MAX_PURPOSE_HEADER_BYTES: u64 = 256 * 1_024;
170/// Maximum normalized legacy purpose rows admitted by one publication.
171const MAX_PURPOSE_IMPORT_RECORDS: u64 = 1_000_000;
172
173/// Built-in purposes for reserved project-local `ProjectAtlas` metadata inputs.
174const BUILTIN_PROJECTATLAS_PURPOSES: &[(&str, &str)] = &[
175    (
176        ".projectatlas",
177        "Store project-local ProjectAtlas metadata, configuration, and runtime state.",
178    ),
179    (
180        ".projectatlas/config.toml",
181        "Configure project-local ProjectAtlas scan, lint, purpose, and output policy.",
182    ),
183    (
184        ".projectatlas/projectatlas-nonsource-files.toon",
185        "Declare project-local non-source file purposes for ProjectAtlas map compatibility.",
186    ),
187    (
188        ".projectatlas/projectatlas-purpose-review.json",
189        "Replay agent-reviewed ProjectAtlas purpose records into the local SQLite index.",
190    ),
191];
192/// Core project-local files whose edits can change source-selection policy.
193const CORE_INDEX_POLICY_PATHS: &[&str] = &[".projectatlas/config.toml", "projectatlas.toml"];
194
195/// Resolved scan runtime policy shared by CLI and MCP adapters.
196pub(crate) struct ScanRuntimePlan {
197    /// Canonical project root.
198    pub(crate) root: PathBuf,
199    /// Optional `ProjectAtlas` config discovered for the root.
200    pub(crate) config: Option<atlas_map::AtlasMapConfig>,
201    /// Exact config file selected for this plan, if one exists.
202    selected_config_path: Option<PathBuf>,
203    /// Explicit config selector supplied by the caller, if any.
204    config_path_override: Option<PathBuf>,
205    /// Filesystem scanner options derived from config.
206    pub(crate) scan_options: ScanOptions,
207    /// `SQLite` text-index options derived from config and command override.
208    pub(crate) text_options: TextIndexOptions,
209    /// Explicit text-index limit supplied by the caller, if any.
210    text_index_max_bytes_override: Option<u64>,
211    /// Content-free optional parser selection bound into derived publication identity.
212    #[cfg(feature = "optional-parser-supervisor")]
213    optional_parser_selection: OptionalParserPackProjectSelection,
214}
215
216/// Deterministic purpose-import rows and the inputs that produced them.
217struct PurposeImportSnapshot {
218    /// Normalized purpose records staged by a full scan.
219    records: Vec<atlas_map::ImportedPurposeRecord>,
220    /// Digest of selected configuration, external inputs, and normalized rows.
221    fingerprint: String,
222}
223
224/// Hard authored-purpose input limits for one publication attempt.
225#[derive(Clone, Copy)]
226struct PurposeImportLimits {
227    /// Aggregate bytes read across all purpose inputs.
228    total_bytes: u64,
229    /// Maximum bytes in a complete config, map, or non-source input.
230    complete_file_bytes: u64,
231    /// Maximum prefix bytes inspected from one source file.
232    header_bytes: u64,
233    /// Maximum normalized records admitted after parsing.
234    records: u64,
235}
236
237impl Default for PurposeImportLimits {
238    fn default() -> Self {
239        Self {
240            total_bytes: MAX_PURPOSE_IMPORT_BYTES,
241            complete_file_bytes: MAX_PURPOSE_INPUT_FILE_BYTES,
242            header_bytes: MAX_PURPOSE_HEADER_BYTES,
243            records: MAX_PURPOSE_IMPORT_RECORDS,
244        }
245    }
246}
247
248/// Operation-owned reader for authored purpose and publication inputs.
249struct PurposeInputReader<'a> {
250    /// Shared cancellation and deadline boundary for the publication.
251    control: &'a IndexWorkControl,
252    /// Byte and record limits for authored purpose inputs.
253    limits: PurposeImportLimits,
254    /// Inputs that must be consumed completely rather than as header prefixes.
255    complete_paths: BTreeSet<PathBuf>,
256    /// Configured legacy folder-purpose filename.
257    purpose_filename: String,
258    /// Exact digests retained only for complete publication-contract inputs.
259    complete_digests: BTreeMap<PathBuf, String>,
260}
261
262/// Maximum changed paths included in a freshness failure payload.
263const INDEX_FRESHNESS_SAMPLE_LIMIT: usize = 8;
264/// Maximum affected paths a normal read may reconcile before answering.
265const NORMAL_READ_REFRESH_MAX_PATHS: usize = 64;
266/// Maximum current source bytes a normal read may reconcile before answering.
267const NORMAL_READ_REFRESH_MAX_BYTES: u64 = 8 * 1024 * 1024;
268/// Maximum current source bytes one navigation read may allocate and inspect.
269const MAX_INDEXED_NAVIGATION_SOURCE_BYTES: u64 = 16 * 1024 * 1024;
270/// Explicit version of the built-in derived-index projection contract.
271const INDEX_DERIVATION_CONTRACT_VERSION: &str = "24";
272
273/// Closed state returned when an index-backed read cannot proceed safely.
274#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
275#[serde(rename_all = "snake_case")]
276pub(crate) enum IndexReadStatus {
277    /// The selected project has not been initialized.
278    InitRequired,
279    /// A bare/common Git directory was selected instead of a source worktree.
280    WorktreeRequired,
281    /// Current saved local source differs from the durable index.
282    RefreshRequired,
283    /// Current saved local source could not be inspected completely.
284    VerificationIncomplete,
285    /// The opened index belongs to a different project root.
286    ProjectMismatch,
287}
288
289/// Closed reason for refusing an index-backed read.
290#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
291#[serde(rename_all = "snake_case")]
292pub(crate) enum IndexRefreshReason {
293    /// Existing indexed source bytes or structural metadata changed.
294    SourceChanged,
295    /// Paths were added, removed, renamed, ignored, or unignored.
296    PathsChanged,
297    /// Parser, source-selection, or indexing policy drifted.
298    PolicyDrift,
299    /// Dependency-aware incremental refresh exceeded its aggregate safe closure.
300    DependencyClosureLimit,
301}
302
303/// Scope required to recover a stale index safely.
304#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
305#[serde(rename_all = "snake_case")]
306pub(crate) enum IndexRefreshScope {
307    /// A bounded affected-path publication can restore current results.
308    Incremental,
309    /// Current publication safety requires a complete one-shot refresh.
310    Full,
311}
312
313/// Current local-source delta detected before a normal indexed read.
314struct IndexFreshnessDelta {
315    /// Typed public report when the delta cannot be reconciled automatically.
316    report: IndexRefreshRequired,
317    /// Complete native paths when retained within the incremental work bound.
318    paths: HashSet<PathBuf>,
319    /// Whether every changed path fits the explicit incremental-work bound.
320    paths_complete: bool,
321    /// Whether structural or policy changes require complete source discovery.
322    requires_full_scan: bool,
323}
324
325/// Measured work performed by one exact source-freshness verification.
326#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
327pub(crate) struct SourceVerificationWork {
328    /// Repository entries inspected by the exact filesystem scan.
329    pub(crate) filesystem_entries: u64,
330    /// Current source bytes hashed by the exact filesystem scan.
331    pub(crate) filesystem_bytes: u64,
332    /// `SQLite` read statements owned directly by freshness verification.
333    pub(crate) sqlite_read_statements: u64,
334    /// Indexed nodes decoded for exact current-versus-durable comparison.
335    pub(crate) decoded_nodes: u64,
336}
337
338/// Exact freshness assessment plus its measured source/database work.
339pub(crate) struct IndexFreshnessAssessment {
340    /// Complete source delta, when current source differs from the index.
341    delta: Option<IndexFreshnessDelta>,
342    /// Work consumed to establish the assessment.
343    work: SourceVerificationWork,
344    /// Ordered discovery retained for an affected publication in this operation.
345    source: CapturedSourceState,
346}
347
348/// Exact source records tied to the plan and durable generation used for comparison.
349struct CapturedSourceState {
350    /// Canonical selected root whose saved source was discovered.
351    root: PathBuf,
352    /// Durable instance identity of the exact database used for comparison.
353    project_instance_id: ProjectInstanceId,
354    /// Parser and selection contract governing the discovery.
355    contract_fingerprint: String,
356    /// Durable generation compared with these records.
357    generation: IndexGeneration,
358    /// Complete ordered current-source inventory, without authored metadata.
359    nodes: Vec<Node>,
360}
361
362/// Current read snapshot established through exact source verification.
363pub(crate) struct ExactFreshIndexRead {
364    /// Root-bound complete `SQLite` read snapshot.
365    pub(crate) store: AtlasStore,
366    /// Work consumed before this snapshot could be called current.
367    pub(crate) work: SourceVerificationWork,
368}
369
370/// Closed reason why current local source could not be verified.
371#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
372#[serde(rename_all = "snake_case")]
373pub(crate) enum IndexVerificationReason {
374    /// Current scan or ignore policy could not be loaded safely.
375    PolicyUnavailable,
376    /// A root or source path could not be inspected completely.
377    SourceInspectionFailed,
378    /// The selected source exceeds the bounded navigation-read ceiling.
379    SourceTooLarge,
380    /// The opened index does not contain a usable project identity.
381    ProjectIdentityUnavailable,
382    /// A prior multi-projection publication did not complete.
383    PublicationIncomplete,
384    /// The completed index used a different parser or scan-policy contract.
385    PublicationContractMismatch,
386}
387
388/// Typed first-use handoff for one selected project root.
389#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
390pub(crate) struct IndexInitRequired {
391    /// Canonical selected project root that needs initialization.
392    pub(crate) project_root: Option<String>,
393    /// Project-local durable index path that initialization will create.
394    pub(crate) database: Option<String>,
395    /// Registered MCP alias when the selected root came from worktree routing.
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub(crate) worktree: Option<String>,
398    /// Stable first-use state for adapters.
399    pub(crate) status: IndexReadStatus,
400}
401
402/// Typed refusal when a bare/common Git directory is selected as source.
403#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
404pub(crate) struct ProjectWorktreeRequired {
405    /// Canonical bare/common Git directory that was selected.
406    pub(crate) project_root: Option<String>,
407    /// Stable source-selection state for adapters.
408    pub(crate) status: IndexReadStatus,
409}
410
411/// Bounded typed report returned before a stale indexed read can execute.
412#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
413pub(crate) struct IndexRefreshRequired {
414    /// Canonical selected project root for a reusable recovery call.
415    pub(crate) project_root: Option<String>,
416    /// Registered MCP alias when the selected root came from worktree routing.
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub(crate) worktree: Option<String>,
419    /// Stable freshness state for adapters.
420    pub(crate) status: IndexReadStatus,
421    /// Why current saved source differs from the index.
422    pub(crate) reason: IndexRefreshReason,
423    /// Safe recovery scope.
424    pub(crate) scope: IndexRefreshScope,
425    /// Total added, removed, or modified paths.
426    pub(crate) changed: usize,
427    /// Newly visible paths.
428    pub(crate) added: usize,
429    /// Paths no longer visible under current source and ignore policy.
430    pub(crate) removed: usize,
431    /// Existing paths whose source or structural identity changed.
432    pub(crate) modified: usize,
433    /// Deterministic bounded path sample for agent recovery.
434    pub(crate) sample_paths: Vec<String>,
435}
436
437/// Bounded typed report returned when source verification is incomplete.
438#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
439pub(crate) struct IndexVerificationIncomplete {
440    /// Selected project root whose current source could not be verified.
441    pub(crate) project_root: Option<String>,
442    /// Registered alias when one selected this project.
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub(crate) worktree: Option<String>,
445    /// Stable verification state for adapters.
446    pub(crate) status: IndexReadStatus,
447    /// Why the verification could not complete.
448    pub(crate) reason: IndexVerificationReason,
449    /// Safe recovery scope once the underlying problem is resolved.
450    pub(crate) scope: IndexRefreshScope,
451    /// Bounded diagnostic from the failed policy or source inspection.
452    pub(crate) message: String,
453}
454
455/// Typed refusal when a selected project root and durable index disagree.
456#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
457pub(crate) struct IndexProjectMismatch {
458    /// Stable project binding state for adapters.
459    pub(crate) status: IndexReadStatus,
460    /// Registered alias when one selected this project.
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub(crate) worktree: Option<String>,
463    /// Lossless UTF-8 display of the canonical root selected for this read.
464    ///
465    /// `None` means that the native identity is valid but has no UTF-8 display
466    /// projection. Adapters must not use this field as identity authority.
467    pub(crate) selected_project_root: Option<String>,
468    /// Lossless UTF-8 display of the canonical root recorded by the opened index.
469    ///
470    /// `None` means that the native identity is valid but has no UTF-8 display
471    /// projection. Adapters must not use this field as identity authority.
472    pub(crate) indexed_project_root: Option<String>,
473    /// Terminal-only diagnostic retained when a lower layer supplied lossy text.
474    #[serde(skip)]
475    diagnostic: Option<String>,
476}
477
478impl IndexProjectMismatch {
479    /// Build adapter fields from native identities without lossy conversion.
480    pub(crate) fn from_native_roots(
481        selected: &CanonicalProjectRoot,
482        indexed: &CanonicalProjectRoot,
483    ) -> Self {
484        Self {
485            status: IndexReadStatus::ProjectMismatch,
486            worktree: None,
487            selected_project_root: selected.display_string().ok(),
488            indexed_project_root: indexed.display_string().ok(),
489            diagnostic: None,
490        }
491    }
492}
493
494impl fmt::Display for IndexRefreshRequired {
495    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
496        let recovery = self.worktree.as_ref().map_or_else(
497            || "run `projectatlas watch --once` or `atlas_watch_once` before retrying".to_string(),
498            |alias| format!("call `atlas_watch_once` with `worktree: {alias}` before retrying"),
499        );
500        if self.reason == IndexRefreshReason::PolicyDrift {
501            return write!(
502                formatter,
503                "refresh_required: derived index policy differs from the current project configuration; {recovery}"
504            );
505        }
506        if self.reason == IndexRefreshReason::DependencyClosureLimit {
507            let recovery = self.worktree.as_ref().map_or_else(
508                || "run a complete `projectatlas scan` or `atlas_scan` before retrying".to_string(),
509                |alias| format!("call `atlas_scan` with `worktree: {alias}` before retrying"),
510            );
511            return write!(
512                formatter,
513                "refresh_required: the dependency-aware incremental closure exceeded its safe limit; {recovery}"
514            );
515        }
516        write!(
517            formatter,
518            "refresh_required: {} indexed path(s) differ from current local source; {recovery}",
519            self.changed,
520        )
521    }
522}
523
524impl fmt::Display for IndexInitRequired {
525    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
526        if let Some(alias) = self.worktree.as_ref() {
527            return write!(
528                formatter,
529                "init_required: ProjectAtlas index '{}' is missing for registered worktree '{}'; call `atlas_init` with `worktree: {alias}`",
530                self.database
531                    .as_deref()
532                    .unwrap_or("<native display unavailable>"),
533                alias,
534            );
535        }
536        write!(
537            formatter,
538            "init_required: ProjectAtlas index '{}' is missing for selected project root '{}'; run `projectatlas init` from that exact root or call `atlas_init` with that exact `project_path`",
539            self.database
540                .as_deref()
541                .unwrap_or("<native display unavailable>"),
542            self.project_root
543                .as_deref()
544                .unwrap_or("<native display unavailable>")
545        )
546    }
547}
548
549impl fmt::Display for ProjectWorktreeRequired {
550    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
551        write!(
552            formatter,
553            "worktree_required: '{}' is a bare/common Git directory without checked-out source; select a checked-out worktree and initialize that exact root",
554            self.project_root
555                .as_deref()
556                .unwrap_or("<native display unavailable>")
557        )
558    }
559}
560
561impl fmt::Display for IndexVerificationIncomplete {
562    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
563        write!(
564            formatter,
565            "verification_incomplete: current local source could not be verified safely: {}",
566            self.message
567        )
568    }
569}
570
571impl fmt::Display for IndexProjectMismatch {
572    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
573        if let Some(diagnostic) = self.diagnostic.as_deref() {
574            return formatter.write_str(diagnostic);
575        }
576        write!(
577            formatter,
578            "project_mismatch: selected project root '{}' does not match index root '{}'",
579            self.selected_project_root
580                .as_deref()
581                .unwrap_or("<native display unavailable>"),
582            self.indexed_project_root
583                .as_deref()
584                .unwrap_or("<native display unavailable>"),
585        )
586    }
587}
588
589/// Verify current saved local source against the durable index in focused tests.
590#[cfg(test)]
591fn verify_index_freshness(
592    store: &AtlasStore,
593    root: &Path,
594    config_path: Option<&Path>,
595) -> Result<(), CliError> {
596    let plan = ScanRuntimePlan::for_path(config_path, root, None).map_err(|source| {
597        verification_incomplete(root, IndexVerificationReason::PolicyUnavailable, &source)
598    })?;
599    match detect_index_freshness(store, &plan)? {
600        Some(delta) => Err(CliError::RefreshRequired(Box::new(delta.report))),
601        None => Ok(()),
602    }
603}
604
605/// Open a current read snapshot, reconciling one safe bounded delta when possible.
606pub(crate) fn open_fresh_atlas_store_for_project(
607    db_path: &Path,
608    root: &Path,
609    config_path: Option<&Path>,
610) -> Result<AtlasStore, CliError> {
611    let control = standalone_index_work_control();
612    open_fresh_atlas_store_for_project_controlled(db_path, root, config_path, &control)
613}
614
615/// Open a current read snapshot under one cooperative freshness boundary.
616pub(crate) fn open_fresh_atlas_store_for_project_controlled(
617    db_path: &Path,
618    root: &Path,
619    config_path: Option<&Path>,
620    control: &IndexWorkControl,
621) -> Result<AtlasStore, CliError> {
622    Ok(
623        open_exact_fresh_atlas_store_for_project_controlled(db_path, root, config_path, control)?
624            .store,
625    )
626}
627
628/// Open a current snapshot and retain exact freshness work for epoch accounting.
629pub(crate) fn open_exact_fresh_atlas_store_for_project_controlled(
630    db_path: &Path,
631    root: &Path,
632    config_path: Option<&Path>,
633    control: &IndexWorkControl,
634) -> Result<ExactFreshIndexRead, CliError> {
635    open_exact_fresh_atlas_store_for_project_with_repair(
636        db_path,
637        root,
638        config_path,
639        control,
640        true,
641        ScanLimits::default(),
642    )
643}
644
645/// Verify exact saved source against the index without publishing a repair.
646pub(crate) fn verify_saved_source_matches_index_controlled(
647    db_path: &Path,
648    root: &Path,
649    config_path: Option<&Path>,
650    control: &IndexWorkControl,
651) -> Result<(), CliError> {
652    let exact =
653        open_exact_saved_source_matches_index_controlled(db_path, root, config_path, control)?;
654    exact.store.finish_index_read_snapshot()?;
655    Ok(())
656}
657
658/// Open an exact current snapshot without publishing a source repair.
659pub(crate) fn open_exact_saved_source_matches_index_controlled(
660    db_path: &Path,
661    root: &Path,
662    config_path: Option<&Path>,
663    control: &IndexWorkControl,
664) -> Result<ExactFreshIndexRead, CliError> {
665    open_exact_fresh_atlas_store_for_project_with_repair(
666        db_path,
667        root,
668        config_path,
669        control,
670        false,
671        ScanLimits::default(),
672    )
673}
674
675/// Open a current read snapshot without repairing stale source or durable state.
676fn open_exact_fresh_atlas_store_for_project_with_repair(
677    db_path: &Path,
678    root: &Path,
679    config_path: Option<&Path>,
680    control: &IndexWorkControl,
681    repair_safe_delta: bool,
682    scan_limits: ScanLimits,
683) -> Result<ExactFreshIndexRead, CliError> {
684    let bounded_control = bounded_index_work_control(control);
685    let control = &bounded_control;
686    let (store, plan, assessment) =
687        open_exact_index_assessment(db_path, root, config_path, scan_limits, control)?;
688    let mut work = assessment.work;
689    let Some(delta) = assessment.delta else {
690        return Ok(ExactFreshIndexRead { store, work });
691    };
692    if !repair_safe_delta {
693        return Err(CliError::RefreshRequired(Box::new(delta.report)));
694    }
695    if delta.report.scope != IndexRefreshScope::Incremental {
696        return Err(CliError::RefreshRequired(Box::new(delta.report)));
697    }
698
699    let refresh_required = delta.report.clone();
700    drop(store);
701    let repair = (|| {
702        let mut writer = open_atlas_store_for_project(db_path, &plan.root)?;
703        let changes = WatchChangeSet {
704            requires_full_scan: false,
705            document_paths: delta.paths.clone(),
706            paths: delta.paths,
707        };
708        refresh_index_for_changes_from_source_controlled(
709            &mut writer,
710            &plan,
711            &changes,
712            &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
713            Some(assessment.source),
714            control,
715        )
716    })();
717    if let Err(error) = repair {
718        if automatic_refresh_write_is_unavailable(&error) {
719            return Err(CliError::RefreshRequired(Box::new(refresh_required)));
720        }
721        return Err(error);
722    }
723
724    let store = open_atlas_store_read_only_for_project(db_path, &plan.root)?;
725    verify_index_project_root(&store, &plan.root)?;
726    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
727    verify_index_publication(&store, &plan)?;
728    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
729    Ok(ExactFreshIndexRead { store, work })
730}
731
732/// Capture an exact read snapshot and its reusable source assessment without publication.
733fn open_exact_index_assessment(
734    db_path: &Path,
735    root: &Path,
736    config_path: Option<&Path>,
737    scan_limits: ScanLimits,
738    control: &IndexWorkControl,
739) -> Result<(AtlasStore, ScanRuntimePlan, IndexFreshnessAssessment), CliError> {
740    let store = open_atlas_store_read_only_for_project(db_path, root)?;
741    let plan = ScanRuntimePlan::for_path_controlled(config_path, root, None, control)
742        .map_err(|source| publication_input_error(root, source))?;
743    let assessment = match detect_index_freshness_controlled(&store, &plan, scan_limits, control) {
744        Ok(assessment) => assessment,
745        Err(CliError::VerificationIncomplete(report))
746            if report.reason == IndexVerificationReason::PublicationContractMismatch =>
747        {
748            return Err(CliError::RefreshRequired(Box::new(
749                index_policy_refresh_required(&plan.root),
750            )));
751        }
752        Err(error) => return Err(error),
753    };
754    Ok((store, plan, assessment))
755}
756
757/// Open an explicit ordered set of current project indexes without mutating any root.
758pub(crate) fn open_federated_atlas_stores_for_project(
759    selected_db: &Path,
760    selected_root: &Path,
761    selected_config: Option<&Path>,
762    roots: &[PathBuf],
763    worktrees: Option<&[String]>,
764    control: &IndexWorkControl,
765) -> Result<Vec<FederatedStore>, CliError> {
766    validate_federated_root_count(roots.len()).map_err(CliError::Service)?;
767    if worktrees.is_some_and(|worktrees| worktrees.len() != roots.len()) {
768        return Err(CliError::Service(
769            projectatlas_service::ServiceError::InvalidInput(
770                "federated worktree labels must match the ordered root count".to_string(),
771            ),
772        ));
773    }
774    let selected_root = fs::canonicalize(selected_root).map_err(|source| CliError::Io {
775        path: selected_root.to_path_buf(),
776        source,
777    })?;
778    let mut canonical_roots = Vec::with_capacity(roots.len());
779    for root in roots {
780        let root = fs::canonicalize(root).map_err(|source| CliError::Io {
781            path: root.clone(),
782            source,
783        })?;
784        if canonical_roots.contains(&root) {
785            return Err(CliError::Service(
786                projectatlas_service::ServiceError::InvalidInput(
787                    "federated roots must be unique".to_string(),
788                ),
789            ));
790        }
791        canonical_roots.push(root);
792    }
793    if canonical_roots.first() != Some(&selected_root) {
794        return Err(CliError::Service(
795            projectatlas_service::ServiceError::InvalidInput(
796                "the first federated root must be the selected project root".to_string(),
797            ),
798        ));
799    }
800
801    let databases = canonical_roots
802        .iter()
803        .enumerate()
804        .map(|(order, root)| {
805            if order == 0 {
806                selected_db.to_path_buf()
807            } else {
808                root.join(".projectatlas").join("projectatlas.db")
809            }
810        })
811        .collect::<Vec<_>>();
812    let mut database_bytes = 0_u64;
813    for database in &databases {
814        let metadata = fs::metadata(database).map_err(|source| CliError::Io {
815            path: database.clone(),
816            source,
817        })?;
818        if !metadata.is_file() {
819            return Err(CliError::Service(
820                projectatlas_service::ServiceError::InvalidInput(
821                    "federated database path is not a regular file".to_string(),
822                ),
823            ));
824        }
825        database_bytes = database_bytes.checked_add(metadata.len()).ok_or_else(|| {
826            CliError::Service(projectatlas_service::ServiceError::InvalidInput(
827                "participating database byte count overflowed".to_string(),
828            ))
829        })?;
830        if database_bytes > MAX_FEDERATED_DATABASE_BYTES {
831            return Err(CliError::Service(
832                projectatlas_service::ServiceError::InvalidInput(format!(
833                    "participating databases exceed {MAX_FEDERATED_DATABASE_BYTES} bytes"
834                )),
835            ));
836        }
837    }
838
839    let default_scan_limits = ScanLimits::default();
840    let mut remaining_input_bytes = MAX_FEDERATED_INPUT_BYTES;
841    let mut stores: Vec<FederatedStore> = Vec::with_capacity(canonical_roots.len());
842    for (order, (root, database)) in canonical_roots.into_iter().zip(databases).enumerate() {
843        let started = Instant::now();
844        let config = (order == 0).then_some(selected_config).flatten();
845        let exact = match open_exact_fresh_atlas_store_for_project_with_repair(
846            &database,
847            &root,
848            config,
849            control,
850            false,
851            ScanLimits::new(
852                default_scan_limits.max_entries(),
853                remaining_input_bytes,
854                default_scan_limits.max_workers(),
855            ),
856        ) {
857            Ok(exact) => exact,
858            Err(error) => {
859                for store in stores {
860                    drop(store.finish());
861                }
862                let error =
863                    if let Some(worktree) = worktrees.and_then(|worktrees| worktrees.get(order)) {
864                        federated_worktree_error(error, worktree)
865                    } else {
866                        error
867                    };
868                return Err(error);
869            }
870        };
871        remaining_input_bytes = remaining_input_bytes.saturating_sub(exact.work.filesystem_bytes);
872        let input_work = FederatedInputWork {
873            filesystem_entries: exact.work.filesystem_entries,
874            filesystem_bytes: exact.work.filesystem_bytes,
875            sqlite_read_statements: exact.work.sqlite_read_statements,
876            decoded_nodes: exact.work.decoded_nodes,
877            elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
878        };
879        let worktree = worktrees.map(|worktrees| worktrees[order].clone());
880        match FederatedStore::new_with_worktree(exact.store, database, root, input_work, worktree) {
881            Ok(store) => stores.push(store),
882            Err(error) => {
883                for store in stores {
884                    drop(store.finish());
885                }
886                return Err(CliError::Service(error));
887            }
888        }
889    }
890    Ok(stores)
891}
892
893/// Synchronize active registrations and read the aggregate before catalog writers resume.
894fn with_synchronized_registered_worktree_usage<T>(
895    control_db: &Path,
896    control_root: &Path,
897    expected_control_project: Option<ProjectInstanceId>,
898    read: impl FnOnce(&AtlasStore) -> DbResult<T>,
899) -> Result<T, CliError> {
900    synchronize_registered_worktree_usage_with_catalog_validation(
901        control_db,
902        control_root,
903        expected_control_project,
904        |_, _| Ok(()),
905        || Ok(()),
906        read,
907    )
908}
909
910/// Load one combined repository report while active worktree membership is stable.
911pub(crate) fn load_synchronized_repository_token_report(
912    control_db: &Path,
913    control_root: &Path,
914    expected_control_project: Option<ProjectInstanceId>,
915    request: TokenReportRequest<'_>,
916) -> Result<TokenReport, CliError> {
917    let (benchmark_results, trend_window) = match request {
918        TokenReportRequest::RepositoryOverview { benchmark_results } => (benchmark_results, None),
919        TokenReportRequest::RepositoryTrends { window } => (None, Some(window)),
920        TokenReportRequest::Overview { .. } | TokenReportRequest::Trends { .. } => {
921            return Err(CliError::InvalidInput(
922                "synchronized token reports require repository scope".to_string(),
923            ));
924        }
925    };
926    let mut report = with_synchronized_registered_worktree_usage(
927        control_db,
928        control_root,
929        expected_control_project,
930        |control| match trend_window {
931            None => Ok(TokenReport::Overview(Box::new(
932                control.repository_token_overview()?,
933            ))),
934            Some(window) => Ok(TokenReport::Trends(
935                control.repository_token_trends(window)?,
936            )),
937        },
938    )?;
939    if let (Some(benchmark_results), TokenReport::Overview(overview)) =
940        (benchmark_results, &mut report)
941    {
942        let control = open_atlas_store_read_only_for_project(control_db, control_root)?;
943        overview.set_agent_efficiency(load_agent_efficiency_comparison(
944            &control,
945            Some(benchmark_results),
946        )?);
947    }
948    Ok(report)
949}
950
951/// Synchronize active registrations without a following repository aggregate read.
952#[cfg(test)]
953pub(crate) fn synchronize_registered_worktree_usage(
954    control_db: &Path,
955    control_root: &Path,
956    expected_control_project: Option<ProjectInstanceId>,
957) -> Result<(), CliError> {
958    with_synchronized_registered_worktree_usage(
959        control_db,
960        control_root,
961        expected_control_project,
962        |_| Ok(()),
963    )
964}
965
966/// Synchronize active registrations with one injectable final catalog boundary.
967fn synchronize_registered_worktree_usage_with_catalog_validation<T>(
968    control_db: &Path,
969    control_root: &Path,
970    expected_control_project: Option<ProjectInstanceId>,
971    mut before_snapshot_revalidation: impl FnMut(&WorktreeRegistration, &Path) -> Result<(), CliError>,
972    before_catalog_validation: impl FnOnce() -> Result<(), CliError>,
973    read: impl FnOnce(&AtlasStore) -> DbResult<T>,
974) -> Result<T, CliError> {
975    let control_reader = open_atlas_store_read_only_for_project(control_db, control_root)?;
976    let control_project =
977        require_synchronization_control_identity(&control_reader, expected_control_project)?;
978    let mut registrations = control_reader.worktree_registrations(false)?;
979    drop(control_reader);
980    if registrations.is_empty() {
981        before_catalog_validation()?;
982        let control = open_atlas_store_for_project(control_db, control_root)?;
983        require_synchronization_control_identity(&control, Some(control_project))?;
984        return match control.with_matching_active_worktree_catalog(&registrations, || read(&control))?
985        {
986            Some(value) => Ok(value),
987            None => Err(CliError::InvalidInput(
988                    "registered worktree catalog changed during aggregate synchronization; retry the token report"
989                        .to_string(),
990                )),
991        };
992    }
993    let repository = match projectatlas_fs::worktree::discover_repository_structure(control_root)? {
994        RepositoryStructure::Git(repository) => repository,
995        RepositoryStructure::NonGit { selected_root } => {
996            return Err(CliError::InvalidInput(format!(
997                "registered worktree synchronization requires Git control evidence at '{}'",
998                normalize_native_path_display(selected_root)
999            )));
1000        }
1001        RepositoryStructure::InvalidGit {
1002            selected_root,
1003            issue,
1004        } => {
1005            return Err(CliError::InvalidInput(format!(
1006                "registered worktree synchronization found invalid Git evidence for '{}': {:?} at '{}'",
1007                normalize_native_path_display(selected_root),
1008                issue.kind,
1009                normalize_native_path_display(issue.path)
1010            )));
1011        }
1012    };
1013    let control = open_atlas_store_for_project(control_db, control_root)?;
1014    require_synchronization_control_identity(&control, Some(control_project))?;
1015    let common = CanonicalProjectRoot::from_path(&repository.common_directory)
1016        .map_err(|source| CliError::InvalidInput(source.to_string()))?;
1017    let active_roots = repository
1018        .worktrees
1019        .iter()
1020        .filter_map(|entry| match &entry.state {
1021            GitWorktreeState::Active { root, .. } => {
1022                CanonicalProjectRoot::from_path(&entry.administrative_directory)
1023                    .ok()
1024                    .map(|administrative_directory| (administrative_directory, root.as_path()))
1025            }
1026            GitWorktreeState::Missing { .. } | GitWorktreeState::Invalid { .. } => None,
1027        })
1028        .collect::<HashMap<_, _>>();
1029    for registration in &mut registrations {
1030        let synchronization_incomplete = || {
1031            CliError::InvalidInput(format!(
1032                "registered worktree '{}': its bound local atlas is unavailable, so aggregate token totals cannot be synchronized",
1033                registration.alias
1034            ))
1035        };
1036        if registration.git_common_directory_identity != common {
1037            if registration.project_instance_id.is_some() {
1038                return Err(synchronization_incomplete());
1039            }
1040            continue;
1041        }
1042        let root = active_roots
1043            .get(&registration.git_administrative_directory_identity)
1044            .copied()
1045            .filter(|_| {
1046                git_administrative_identity(
1047                    registration.git_administrative_directory_identity.as_path(),
1048                )
1049                .is_ok_and(|identity| identity == registration.git_administrative_identity)
1050            });
1051        let Some(root) = root else {
1052            if registration.project_instance_id.is_some() {
1053                return Err(synchronization_incomplete());
1054            }
1055            continue;
1056        };
1057        let synchronized_project = control.with_active_worktree_registration(
1058            registration.registration_id,
1059            &registration.alias,
1060            |guard| {
1061                if let Err(error) =
1062                    require_registered_worktree_lifecycle(guard.registration(), root)
1063                {
1064                    return Ok(Err(error));
1065                }
1066                let database = root.join(".projectatlas").join("projectatlas.db");
1067                match fs::symlink_metadata(&database) {
1068                    Ok(_) => {}
1069                    Err(error) if error.kind() == io::ErrorKind::NotFound => {
1070                        return Ok(if guard.registration().project_instance_id.is_some() {
1071                            Err(synchronization_incomplete())
1072                        } else {
1073                            Ok(None)
1074                        });
1075                    }
1076                    Err(error) => return Ok(Err(error.into())),
1077                }
1078                let target = match open_atlas_store_read_only_for_project(&database, root) {
1079                    Ok(target) => target,
1080                    Err(error) => return Ok(Err(error)),
1081                };
1082                let snapshot = match target.export_worktree_usage_snapshot() {
1083                    Ok(snapshot) => snapshot,
1084                    Err(error) => return Ok(Err(error.into())),
1085                };
1086                drop(target);
1087                if let Err(error) = before_snapshot_revalidation(guard.registration(), root) {
1088                    return Ok(Err(error));
1089                }
1090                if let Err(error) =
1091                    require_registered_worktree_lifecycle(guard.registration(), root)
1092                {
1093                    return Ok(Err(error));
1094                }
1095                if let Err(error) =
1096                    require_current_worktree_usage_snapshot(&database, root, &snapshot)
1097                {
1098                    return Ok(Err(error));
1099                }
1100                let project = snapshot.project_instance_id();
1101                if guard.registration().project_instance_id.is_none() {
1102                    guard.bind_project_with_usage_snapshot(root, project, &snapshot)?;
1103                } else {
1104                    guard.synchronize_usage_snapshot(&snapshot)?;
1105                }
1106                Ok(Ok(Some(project)))
1107            },
1108        )??;
1109        if let Some(project) = synchronized_project {
1110            registration.project_instance_id = Some(project);
1111        }
1112    }
1113    before_catalog_validation()?;
1114    match control.with_matching_active_worktree_catalog(&registrations, || read(&control))? {
1115        Some(value) => Ok(value),
1116        None => Err(CliError::InvalidInput(
1117                "registered worktree catalog changed during aggregate synchronization; retry the token report"
1118                    .to_string(),
1119            )),
1120    }
1121}
1122
1123/// Require the atlas currently published at this path to match a captured snapshot exactly.
1124pub(crate) fn require_current_worktree_usage_snapshot(
1125    database: &Path,
1126    root: &Path,
1127    expected: &WorktreeUsageSnapshot,
1128) -> Result<(), CliError> {
1129    let current = open_atlas_store_read_only_for_project(database, root)?;
1130    if current.export_worktree_usage_snapshot()? != *expected {
1131        return Err(CliError::InvalidInput(
1132            "worktree atlas changed after its usage snapshot was captured; retry the operation"
1133                .to_string(),
1134        ));
1135    }
1136    Ok(())
1137}
1138
1139/// Rediscover one registration immediately before a lifecycle-sensitive catalog write.
1140pub(crate) fn require_registered_worktree_lifecycle(
1141    registration: &WorktreeRegistration,
1142    expected_root: &Path,
1143) -> Result<(), CliError> {
1144    let lifecycle_changed = || {
1145        CliError::InvalidInput(format!(
1146            "registered worktree '{}': its Git administrative lifecycle changed before aggregate token synchronization",
1147            registration.alias
1148        ))
1149    };
1150    if !git_worktree_lifecycle_matches(
1151        expected_root,
1152        registration.git_common_directory_identity.as_path(),
1153        registration.git_administrative_directory_identity.as_path(),
1154        &registration.git_administrative_identity,
1155    )? {
1156        return Err(lifecycle_changed());
1157    }
1158    Ok(())
1159}
1160
1161/// Keep synchronization on the control atlas captured by an alias-routed request.
1162fn require_synchronization_control_identity(
1163    control: &AtlasStore,
1164    expected: Option<ProjectInstanceId>,
1165) -> Result<ProjectInstanceId, CliError> {
1166    let observed = control.project_instance_id()?.ok_or_else(|| {
1167        CliError::InvalidInput(
1168            "control atlas has no project identity for registered worktree synchronization"
1169                .to_string(),
1170        )
1171    })?;
1172    if expected.is_some_and(|expected| expected != observed) {
1173        return Err(CliError::InvalidInput(
1174            "control atlas identity changed before registered worktree synchronization".to_string(),
1175        ));
1176    }
1177    Ok(observed)
1178}
1179
1180/// Attach the exact alias to every typed or generic federated participant failure.
1181pub(crate) fn federated_worktree_error(mut error: CliError, worktree: &str) -> CliError {
1182    match &mut error {
1183        CliError::InitRequired(report) => report.worktree = Some(worktree.to_string()),
1184        CliError::RefreshRequired(report) => report.worktree = Some(worktree.to_string()),
1185        CliError::VerificationIncomplete(report) => report.worktree = Some(worktree.to_string()),
1186        CliError::ProjectMismatch(report) => report.worktree = Some(worktree.to_string()),
1187        _ => {
1188            return CliError::InvalidInput(format!(
1189                "federated worktree {worktree:?} failed: {error}"
1190            ));
1191        }
1192    }
1193    error
1194}
1195
1196/// Distinguish optional repair contention from database-integrity failures.
1197fn automatic_refresh_write_is_unavailable(error: &CliError) -> bool {
1198    matches!(error, CliError::Db(source) if source.is_write_unavailable())
1199}
1200
1201/// Return the typed full-refresh state for a changed derivation contract.
1202fn index_policy_refresh_required(root: &Path) -> IndexRefreshRequired {
1203    IndexRefreshRequired {
1204        project_root: lossless_project_root_display(root),
1205        worktree: None,
1206        status: IndexReadStatus::RefreshRequired,
1207        reason: IndexRefreshReason::PolicyDrift,
1208        scope: IndexRefreshScope::Full,
1209        changed: 0,
1210        added: 0,
1211        removed: 0,
1212        modified: 0,
1213        sample_paths: Vec::new(),
1214    }
1215}
1216
1217/// Detect the complete current local-source delta for one selected index.
1218#[cfg(test)]
1219fn detect_index_freshness(
1220    store: &AtlasStore,
1221    plan: &ScanRuntimePlan,
1222) -> Result<Option<IndexFreshnessDelta>, CliError> {
1223    let control = standalone_index_work_control();
1224    Ok(detect_index_freshness_controlled(store, plan, ScanLimits::default(), &control)?.delta)
1225}
1226
1227/// Detect the complete local-source delta under one cooperative work boundary.
1228fn detect_index_freshness_controlled(
1229    store: &AtlasStore,
1230    plan: &ScanRuntimePlan,
1231    scan_limits: ScanLimits,
1232    control: &IndexWorkControl,
1233) -> Result<IndexFreshnessAssessment, CliError> {
1234    let mut work = SourceVerificationWork::default();
1235    verify_index_project_root(store, &plan.root)?;
1236    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1237    let generation = verify_index_publication(store, plan)?;
1238    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1239    assess_source_against_publication(store, plan, scan_limits, control, generation, work)
1240}
1241
1242/// Compare source with the generation whose complete publication contract was verified.
1243fn assess_source_against_publication(
1244    store: &AtlasStore,
1245    plan: &ScanRuntimePlan,
1246    scan_limits: ScanLimits,
1247    control: &IndexWorkControl,
1248    generation: IndexGeneration,
1249    mut work: SourceVerificationWork,
1250) -> Result<IndexFreshnessAssessment, CliError> {
1251    let scan = scan_repo_controlled_with_work(&plan.root, &plan.scan_options, scan_limits, control)
1252        .map_err(|source| source_inspection_error(&plan.root, source))?;
1253    work.filesystem_entries = scan.work.entries;
1254    work.filesystem_bytes = scan.work.source_bytes;
1255    let current_nodes = scan.nodes;
1256    let mut current = current_nodes.iter().peekable();
1257    let mut delta = SourceDelta::default();
1258    store.visit_source_nodes_controlled(control, |indexed| {
1259        delta.compare_next(&mut current, &indexed);
1260        work.decoded_nodes = work.decoded_nodes.saturating_add(1);
1261        Ok(())
1262    })?;
1263    for node in current {
1264        delta.record(Some(node), None);
1265    }
1266    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1267    let current_generation = publication_base_generation(store)?;
1268    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1269    if current_generation != generation {
1270        return Err(projectatlas_db::DbError::PublicationBaseGenerationChanged {
1271            expected: generation,
1272            found: current_generation,
1273        }
1274        .into());
1275    }
1276    let project_instance_id = store.captured_project_binding()?.project_instance_id;
1277    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1278    Ok(IndexFreshnessAssessment {
1279        delta: delta.finish(&plan.root),
1280        work,
1281        source: CapturedSourceState {
1282            root: plan.root.clone(),
1283            project_instance_id,
1284            contract_fingerprint: plan.publication_contract_fingerprint(),
1285            generation,
1286            nodes: current_nodes,
1287        },
1288    })
1289}
1290
1291/// Compare a current exact scan with the source-derived nodes being validated.
1292fn verify_source_nodes_match(
1293    root: &Path,
1294    current_nodes: &[Node],
1295    indexed_nodes: &[Node],
1296) -> Result<(), CliError> {
1297    match source_node_delta(root, current_nodes, indexed_nodes) {
1298        Some(delta) => Err(CliError::RefreshRequired(Box::new(delta.report))),
1299        None => Ok(()),
1300    }
1301}
1302
1303/// Build one affected-path plan from source nodes, independent of input order.
1304fn source_node_delta(
1305    root: &Path,
1306    current_nodes: &[Node],
1307    indexed_nodes: &[Node],
1308) -> Option<IndexFreshnessDelta> {
1309    let mut current = current_nodes.iter().collect::<Vec<_>>();
1310    let mut indexed = indexed_nodes.iter().collect::<Vec<_>>();
1311    current.sort_unstable_by(|left, right| left.path.cmp(&right.path));
1312    indexed.sort_unstable_by(|left, right| left.path.cmp(&right.path));
1313    let mut current = current.into_iter().peekable();
1314    let mut delta = SourceDelta::default();
1315    for node in indexed {
1316        delta.compare_next(&mut current, node);
1317    }
1318    for node in current {
1319        delta.record(Some(node), None);
1320    }
1321    delta.finish(root)
1322}
1323
1324/// Changed source paths retained by an ordered comparison; unchanged rows are discarded.
1325#[derive(Default)]
1326struct SourceDelta {
1327    /// Added live paths.
1328    added: usize,
1329    /// Removed or excluded paths.
1330    removed: usize,
1331    /// Paths whose source-derived fields changed.
1332    modified: usize,
1333    /// Current bytes belonging to added or modified files.
1334    changed_bytes: u64,
1335    /// A changed folder requires complete discovery, even after it was deleted.
1336    folder_changed: bool,
1337    /// Exact changed keys, kept sorted for diagnostics and refresh admission.
1338    paths: Vec<String>,
1339}
1340
1341impl SourceDelta {
1342    /// Merge one ordered stored row with the current-source cursor.
1343    fn compare_next<'a>(
1344        &mut self,
1345        current: &mut std::iter::Peekable<impl Iterator<Item = &'a Node>>,
1346        indexed: &Node,
1347    ) {
1348        while current.peek().is_some_and(|node| node.path < indexed.path) {
1349            self.record(current.next(), None);
1350        }
1351        if current.peek().is_some_and(|node| node.path == indexed.path) {
1352            self.record(current.next(), Some(indexed));
1353        } else {
1354            self.record(None, Some(indexed));
1355        }
1356    }
1357
1358    /// Accumulate only a real addition, deletion, or source-field modification.
1359    fn record(&mut self, current: Option<&Node>, indexed: Option<&Node>) {
1360        let path = match (current, indexed) {
1361            (Some(current), Some(indexed)) => {
1362                if same_indexed_source(current, indexed) {
1363                    return;
1364                }
1365                self.modified += 1;
1366                &current.path
1367            }
1368            (Some(current), None) => {
1369                self.added += 1;
1370                &current.path
1371            }
1372            (None, Some(indexed)) => {
1373                self.removed += 1;
1374                &indexed.path
1375            }
1376            (None, None) => return,
1377        };
1378        self.changed_bytes = self
1379            .changed_bytes
1380            .saturating_add(current.and_then(|node| node.size_bytes).unwrap_or_default());
1381        self.folder_changed |= current.is_some_and(|node| node.kind == NodeKind::Folder)
1382            || indexed.is_some_and(|node| node.kind == NodeKind::Folder);
1383        if self.paths.len() < MAX_INCREMENTAL_CHANGED_PATHS {
1384            self.paths.push(path.clone());
1385        }
1386    }
1387
1388    /// Apply the existing normal-read limits to the complete compared change set.
1389    fn finish(self, root: &Path) -> Option<IndexFreshnessDelta> {
1390        let changed = self
1391            .added
1392            .saturating_add(self.removed)
1393            .saturating_add(self.modified);
1394        if changed == 0 {
1395            return None;
1396        }
1397        let paths_complete = self.paths.len() == changed;
1398        let requires_full_scan = !paths_complete
1399            || self.folder_changed
1400            || self.paths.iter().any(|path| {
1401                watch_path_requires_full_scan(root, &root.join(repo_path_to_native(path)))
1402            });
1403        let exceeds_normal_read = changed > NORMAL_READ_REFRESH_MAX_PATHS
1404            || self.changed_bytes > NORMAL_READ_REFRESH_MAX_BYTES;
1405        let sample_paths = self
1406            .paths
1407            .iter()
1408            .take(INDEX_FRESHNESS_SAMPLE_LIMIT)
1409            .cloned()
1410            .collect();
1411        Some(IndexFreshnessDelta {
1412            paths_complete,
1413            requires_full_scan,
1414            report: IndexRefreshRequired {
1415                project_root: lossless_project_root_display(root),
1416                worktree: None,
1417                status: IndexReadStatus::RefreshRequired,
1418                reason: if self.added == 0 && self.removed == 0 {
1419                    IndexRefreshReason::SourceChanged
1420                } else {
1421                    IndexRefreshReason::PathsChanged
1422                },
1423                scope: if requires_full_scan || exceeds_normal_read {
1424                    IndexRefreshScope::Full
1425                } else {
1426                    IndexRefreshScope::Incremental
1427                },
1428                changed,
1429                added: self.added,
1430                removed: self.removed,
1431                modified: self.modified,
1432                sample_paths,
1433            },
1434            paths: if paths_complete {
1435                self.paths
1436                    .into_iter()
1437                    .map(|path| root.join(repo_path_to_native(&path)))
1438                    .collect()
1439            } else {
1440                HashSet::new()
1441            },
1442        })
1443    }
1444}
1445
1446/// Verify that the opened database belongs to the selected canonical root.
1447fn verify_index_project_root(store: &AtlasStore, selected_root: &Path) -> Result<(), CliError> {
1448    let Some(indexed_root) = store.project_root_identity()? else {
1449        return Err(verification_incomplete(
1450            selected_root,
1451            IndexVerificationReason::ProjectIdentityUnavailable,
1452            &CliError::InvalidInput("index project root metadata is missing".to_string()),
1453        ));
1454    };
1455    let selected_root = CanonicalProjectRoot::from_path(selected_root).map_err(|source| {
1456        verification_incomplete(
1457            selected_root,
1458            IndexVerificationReason::SourceInspectionFailed,
1459            &CliError::InvalidInput(source.to_string()),
1460        )
1461    })?;
1462    if !store.project_root_identity_matches(&selected_root) {
1463        return Err(CliError::ProjectMismatch(Box::new(
1464            IndexProjectMismatch::from_native_roots(&selected_root, &indexed_root),
1465        )));
1466    }
1467    Ok(())
1468}
1469
1470/// Reject mixed or runtime-incompatible derived projections before source reads.
1471fn verify_index_publication(
1472    store: &AtlasStore,
1473    plan: &ScanRuntimePlan,
1474) -> Result<IndexGeneration, CliError> {
1475    let expected_fingerprint = plan.publication_contract_fingerprint();
1476    let Some(publication) = store.index_publication()? else {
1477        return Err(verification_incomplete(
1478            &plan.root,
1479            IndexVerificationReason::PublicationIncomplete,
1480            &CliError::InvalidInput(
1481                "derived index publication state is missing; run one refresh".to_string(),
1482            ),
1483        ));
1484    };
1485    if publication.state == IndexPublicationState::Updating {
1486        return Err(verification_incomplete(
1487            &plan.root,
1488            IndexVerificationReason::PublicationIncomplete,
1489            &CliError::InvalidInput(
1490                "a prior derived index publication did not complete; run one refresh".to_string(),
1491            ),
1492        ));
1493    }
1494    if publication.generation == projectatlas_core::IndexGeneration::ZERO {
1495        return Err(verification_incomplete(
1496            &plan.root,
1497            IndexVerificationReason::PublicationIncomplete,
1498            &CliError::InvalidInput(
1499                "derived index has no complete publication generation; run one refresh".to_string(),
1500            ),
1501        ));
1502    }
1503    if publication.contract_fingerprint.as_deref() != Some(expected_fingerprint.as_str()) {
1504        return Err(verification_incomplete(
1505            &plan.root,
1506            IndexVerificationReason::PublicationContractMismatch,
1507            &CliError::InvalidInput(
1508                "derived index parser or scan-policy contract changed; run one refresh".to_string(),
1509            ),
1510        ));
1511    }
1512    Ok(publication.generation)
1513}
1514
1515/// Return whether symbol reuse and incremental publication share the current derivation contract.
1516pub(crate) fn publication_contract_matches(
1517    store: &AtlasStore,
1518    plan: &ScanRuntimePlan,
1519) -> Result<bool, CliError> {
1520    let Some(publication) = store.index_publication()? else {
1521        return Ok(false);
1522    };
1523    Ok(publication.state == IndexPublicationState::Complete
1524        && publication.generation != IndexGeneration::ZERO
1525        && publication.contract_fingerprint.as_deref()
1526            == Some(plan.publication_contract_fingerprint().as_str()))
1527}
1528
1529/// Hash the parser registry and source-selection policy that own derived rows.
1530fn index_derivation_fingerprint(
1531    scan_options: &ScanOptions,
1532    text_options: TextIndexOptions,
1533    #[cfg(feature = "optional-parser-supervisor")]
1534    optional_parser_selection: &OptionalParserPackProjectSelection,
1535) -> String {
1536    index_derivation_fingerprint_for_contract(
1537        scan_options,
1538        text_options,
1539        #[cfg(feature = "optional-parser-supervisor")]
1540        optional_parser_selection,
1541        INDEX_DERIVATION_CONTRACT_VERSION,
1542        &semantic_resolution_contract_digest(),
1543    )
1544}
1545
1546/// Hash one exact parser, semantic, and source-selection contract.
1547fn index_derivation_fingerprint_for_contract(
1548    scan_options: &ScanOptions,
1549    text_options: TextIndexOptions,
1550    #[cfg(feature = "optional-parser-supervisor")]
1551    optional_parser_selection: &OptionalParserPackProjectSelection,
1552    contract_version: &str,
1553    semantic_resolution_digest: &str,
1554) -> String {
1555    let mut hasher = Hasher::new();
1556    hash_index_contract_value(&mut hasher, "contract_version", contract_version);
1557    hash_index_contract_value(
1558        &mut hasher,
1559        "language_registry_version",
1560        &LANGUAGE_CAPABILITY_REGISTRY_VERSION.to_string(),
1561    );
1562    hash_index_contract_value(
1563        &mut hasher,
1564        "accepted_language_set_version",
1565        &ACCEPTED_LANGUAGE_CAPABILITY_SET_VERSION.to_string(),
1566    );
1567    hash_index_contract_value(
1568        &mut hasher,
1569        "language_registry_digest",
1570        &language_registry_digest(),
1571    );
1572    hash_index_contract_value(
1573        &mut hasher,
1574        "accepted_language_set_digest",
1575        &accepted_language_capability_digest(),
1576    );
1577    hash_index_contract_value(
1578        &mut hasher,
1579        "semantic_resolution_contract_digest",
1580        semantic_resolution_digest,
1581    );
1582    for value in &scan_options.exclude_dir_names {
1583        hash_index_contract_value(&mut hasher, "exclude_dir_name", value);
1584    }
1585    for value in &scan_options.exclude_dir_suffixes {
1586        hash_index_contract_value(&mut hasher, "exclude_dir_suffix", value);
1587    }
1588    for value in &scan_options.exclude_path_prefixes {
1589        hash_index_contract_value(&mut hasher, "exclude_path_prefix", value);
1590    }
1591    for (selector, language) in &scan_options.language_overrides {
1592        hash_index_contract_value(&mut hasher, "language_override_selector", selector);
1593        hash_index_contract_value(&mut hasher, "language_override_target", language);
1594    }
1595    hash_index_contract_value(
1596        &mut hasher,
1597        "text_index_max_bytes",
1598        &text_options.max_bytes.to_string(),
1599    );
1600    #[cfg(feature = "optional-parser-supervisor")]
1601    hash_index_contract_value(
1602        &mut hasher,
1603        "optional_parser_selection",
1604        optional_parser_selection
1605            .selection_key()
1606            .map_or("inactive", |selection| selection.as_str()),
1607    );
1608    hasher.finalize().to_hex().to_string()
1609}
1610
1611/// Recheck current policy and source before making staged rows visible.
1612#[cfg(test)]
1613fn revalidate_index_publication_inputs_controlled(
1614    store: &AtlasStore,
1615    plan: &ScanRuntimePlan,
1616    expected_purpose_import_fingerprint: Option<&str>,
1617    control: &IndexWorkControl,
1618) -> Result<(), CliError> {
1619    revalidate_index_publication_inputs_controlled_with_limits(
1620        store,
1621        plan,
1622        expected_purpose_import_fingerprint,
1623        control,
1624        PurposeImportLimits::default(),
1625    )
1626}
1627
1628/// Recheck publication inputs under explicit purpose limits used by focused tests.
1629#[cfg(test)]
1630fn revalidate_index_publication_inputs_controlled_with_limits(
1631    store: &AtlasStore,
1632    plan: &ScanRuntimePlan,
1633    expected_purpose_import_fingerprint: Option<&str>,
1634    control: &IndexWorkControl,
1635    purpose_limits: PurposeImportLimits,
1636) -> Result<(), CliError> {
1637    let staged_nodes = store
1638        .load_nodes()?
1639        .into_iter()
1640        .map(|indexed| indexed.node)
1641        .collect::<Vec<_>>();
1642    revalidate_staged_publication_inputs_controlled_with_limits(
1643        plan,
1644        &staged_nodes,
1645        expected_purpose_import_fingerprint,
1646        None,
1647        control,
1648        purpose_limits,
1649    )
1650}
1651
1652/// Recheck policy and exact source against one off-writer publication batch.
1653fn revalidate_staged_publication_inputs_controlled(
1654    plan: &ScanRuntimePlan,
1655    staged_nodes: &[Node],
1656    expected_purpose_import_fingerprint: Option<&str>,
1657    control: &IndexWorkControl,
1658) -> Result<(), CliError> {
1659    revalidate_staged_publication_inputs_controlled_with_limits(
1660        plan,
1661        staged_nodes,
1662        expected_purpose_import_fingerprint,
1663        None,
1664        control,
1665        PurposeImportLimits::default(),
1666    )
1667}
1668
1669/// Recheck a full scan while reusing purpose rows from exact unchanged source nodes.
1670fn revalidate_staged_publication_inputs_with_purpose_snapshot(
1671    plan: &ScanRuntimePlan,
1672    staged_nodes: &[Node],
1673    purpose_import: Option<&PurposeImportSnapshot>,
1674    control: &IndexWorkControl,
1675) -> Result<(), CliError> {
1676    revalidate_staged_publication_inputs_controlled_with_limits(
1677        plan,
1678        staged_nodes,
1679        purpose_import.map(|snapshot| snapshot.fingerprint.as_str()),
1680        purpose_import.map(|snapshot| snapshot.records.as_slice()),
1681        control,
1682        PurposeImportLimits::default(),
1683    )
1684}
1685
1686/// Recheck a staged batch under explicit purpose-input limits used by tests.
1687fn revalidate_staged_publication_inputs_controlled_with_limits(
1688    plan: &ScanRuntimePlan,
1689    staged_nodes: &[Node],
1690    expected_purpose_import_fingerprint: Option<&str>,
1691    reusable_purpose_records: Option<&[atlas_map::ImportedPurposeRecord]>,
1692    control: &IndexWorkControl,
1693    purpose_limits: PurposeImportLimits,
1694) -> Result<(), CliError> {
1695    control.check(IndexWorkStage::Publication)?;
1696    let current_plan = plan
1697        .reload_controlled_with_limits(control, purpose_limits)
1698        .map_err(|source| publication_input_error(&plan.root, source))?;
1699    control.check(IndexWorkStage::Publication)?;
1700    let staged_fingerprint = plan.publication_contract_fingerprint();
1701    let current_fingerprint = current_plan.publication_contract_fingerprint();
1702    if staged_fingerprint != current_fingerprint {
1703        return Err(verification_incomplete(
1704            &plan.root,
1705            IndexVerificationReason::PublicationContractMismatch,
1706            &CliError::InvalidInput(
1707                "derived index policy changed while publication was being built; retry the refresh"
1708                    .to_string(),
1709            ),
1710        ));
1711    }
1712    let current_nodes = scan_repo_controlled(
1713        &current_plan.root,
1714        &current_plan.scan_options,
1715        ScanLimits::default(),
1716        control,
1717    )
1718    .map_err(|source| source_inspection_error(&current_plan.root, source))?;
1719    verify_source_nodes_match(&current_plan.root, &current_nodes, staged_nodes)?;
1720    if let Some(expected_fingerprint) = expected_purpose_import_fingerprint {
1721        let current_fingerprint = if let Some(records) = reusable_purpose_records {
1722            current_plan
1723                .purpose_import_fingerprint_for_records_controlled_with_limits(
1724                    records,
1725                    control,
1726                    purpose_limits,
1727                )
1728                .map_err(|source| publication_input_error(&plan.root, source))?
1729        } else {
1730            current_plan
1731                .purpose_import_snapshot_controlled_with_limits(
1732                    &current_nodes,
1733                    control,
1734                    purpose_limits,
1735                )
1736                .map_err(|source| publication_input_error(&plan.root, source))?
1737                .fingerprint
1738        };
1739        if current_fingerprint != expected_fingerprint {
1740            return Err(verification_incomplete(
1741                &plan.root,
1742                IndexVerificationReason::PublicationContractMismatch,
1743                &CliError::InvalidInput(
1744                    "purpose-import inputs changed while publication was being built; retry the refresh"
1745                        .to_string(),
1746                ),
1747            ));
1748        }
1749    }
1750    control.check(IndexWorkStage::Publication)?;
1751    Ok(())
1752}
1753
1754/// Preserve typed work failures while adapting authored-input uncertainty.
1755fn publication_input_error(root: &Path, source: CliError) -> CliError {
1756    match source {
1757        source @ CliError::IndexWork(_) => source,
1758        other => verification_incomplete(root, IndexVerificationReason::PolicyUnavailable, &other),
1759    }
1760}
1761
1762/// Preserve typed work failures while adapting ordinary scan uncertainty.
1763fn source_inspection_error(root: &Path, source: FsError) -> CliError {
1764    match source {
1765        FsError::IndexWork(failure) => failure.into(),
1766        FsError::RepositoryBoundary { .. } => {
1767            CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1768                project_root: lossless_project_root_display(root),
1769                worktree: None,
1770                status: IndexReadStatus::VerificationIncomplete,
1771                reason: IndexVerificationReason::PolicyUnavailable,
1772                scope: IndexRefreshScope::Full,
1773                message: source.to_string(),
1774            }))
1775        }
1776        other => CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1777            project_root: lossless_project_root_display(root),
1778            worktree: None,
1779            status: IndexReadStatus::VerificationIncomplete,
1780            reason: IndexVerificationReason::SourceInspectionFailed,
1781            scope: IndexRefreshScope::Full,
1782            message: other.to_string(),
1783        })),
1784    }
1785}
1786
1787/// Commit only after the shared work boundary still permits publication.
1788fn complete_index_publication(
1789    publication: IndexPublicationGuard<'_>,
1790    control: &IndexWorkControl,
1791) -> Result<(), CliError> {
1792    control.check(IndexWorkStage::Publication)?;
1793    publication.complete()?;
1794    Ok(())
1795}
1796
1797/// Add one unambiguous field/value pair to a derived-index fingerprint.
1798fn hash_index_contract_value(hasher: &mut Hasher, field: &str, value: &str) {
1799    hasher.update(field.as_bytes());
1800    hasher.update(&[0]);
1801    hasher.update(value.as_bytes());
1802    hasher.update(&[0xff]);
1803}
1804
1805/// Add one native path to a fingerprint without a lossy text projection.
1806fn hash_index_contract_native_path(hasher: &mut Hasher, field: &str, path: &Path) {
1807    hasher.update(field.as_bytes());
1808    hasher.update(&[0]);
1809    hasher.update(path.as_os_str().as_encoded_bytes());
1810    hasher.update(&[0xff]);
1811}
1812
1813/// Convert a policy/root preflight failure into a non-destructive read refusal.
1814fn verification_incomplete(
1815    root: &Path,
1816    reason: IndexVerificationReason,
1817    source: &CliError,
1818) -> CliError {
1819    CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1820        project_root: lossless_project_root_display(root),
1821        worktree: None,
1822        status: IndexReadStatus::VerificationIncomplete,
1823        reason,
1824        scope: IndexRefreshScope::Full,
1825        message: source.to_string(),
1826    }))
1827}
1828
1829/// Compare source-derived node identity while ignoring non-semantic mtimes.
1830fn same_indexed_source(current: &Node, indexed: &Node) -> bool {
1831    current.path == indexed.path
1832        && current.kind == indexed.kind
1833        && current.parent_path == indexed.parent_path
1834        && current.extension == indexed.extension
1835        && current.language == indexed.language
1836        && current.size_bytes == indexed.size_bytes
1837        && current.content_hash == indexed.content_hash
1838}
1839
1840/// Refuse publication when source bytes no longer match their staged node.
1841fn source_changed_during_derivation(root: &Path, path: &str) -> CliError {
1842    CliError::RefreshRequired(Box::new(IndexRefreshRequired {
1843        project_root: lossless_project_root_display(root),
1844        worktree: None,
1845        status: IndexReadStatus::RefreshRequired,
1846        reason: IndexRefreshReason::SourceChanged,
1847        scope: IndexRefreshScope::Full,
1848        changed: 1,
1849        added: 0,
1850        removed: 0,
1851        modified: 1,
1852        sample_paths: vec![path.to_string()],
1853    }))
1854}
1855
1856impl<'a> PurposeInputReader<'a> {
1857    /// Create one reader whose cancellation and limits belong to the scan operation.
1858    fn new(
1859        plan: &ScanRuntimePlan,
1860        control: &'a IndexWorkControl,
1861        limits: PurposeImportLimits,
1862    ) -> Self {
1863        let mut complete_paths = BTreeSet::new();
1864        if let Some(path) = &plan.selected_config_path {
1865            complete_paths.insert(path.clone());
1866        }
1867        if let Some(config) = &plan.config {
1868            complete_paths.insert(config.map_path.clone());
1869            complete_paths.insert(config.nonsource_files_path.clone());
1870        }
1871        Self::for_complete_paths(
1872            control,
1873            limits,
1874            complete_paths,
1875            plan.config.as_ref().map_or_else(
1876                || ".purpose".to_string(),
1877                |config| config.purpose_filename().to_string(),
1878            ),
1879        )
1880    }
1881
1882    /// Create a bounded reader before a complete runtime plan is available.
1883    fn for_complete_paths(
1884        control: &'a IndexWorkControl,
1885        limits: PurposeImportLimits,
1886        complete_paths: BTreeSet<PathBuf>,
1887        purpose_filename: String,
1888    ) -> Self {
1889        Self {
1890            control,
1891            limits,
1892            complete_paths,
1893            purpose_filename,
1894            complete_digests: BTreeMap::new(),
1895        }
1896    }
1897
1898    /// Read one UTF-8 input, treating non-UTF-8 source headers as purpose-free.
1899    fn read_text(&mut self, path: &Path) -> Result<String, CliError> {
1900        self.control.check(IndexWorkStage::Publication)?;
1901        let is_complete = self.complete_paths.contains(path)
1902            || path
1903                .file_name()
1904                .is_some_and(|name| name == self.purpose_filename.as_str());
1905        let file_limit = if is_complete {
1906            self.limits.complete_file_bytes
1907        } else {
1908            self.limits.header_bytes
1909        };
1910        let mut file = fs::File::open(path).map_err(|source| CliError::Io {
1911            path: path.to_path_buf(),
1912            source,
1913        })?;
1914        let bytes = self.read_bytes(path, &mut file, file_limit, is_complete)?;
1915        if is_complete {
1916            self.complete_digests.insert(
1917                path.to_path_buf(),
1918                blake3::hash(&bytes).to_hex().to_string(),
1919            );
1920        }
1921        match String::from_utf8(bytes) {
1922            Ok(content) => Ok(content),
1923            Err(source) if !is_complete && source.utf8_error().error_len().is_some() => {
1924                Ok(String::new())
1925            }
1926            Err(source) if !is_complete && source.utf8_error().error_len().is_none() => {
1927                let valid_up_to = source.utf8_error().valid_up_to();
1928                String::from_utf8(source.into_bytes()[..valid_up_to].to_vec()).map_err(|source| {
1929                    CliError::InvalidInput(format!(
1930                        "purpose header input is not valid UTF-8 for {}: {source}",
1931                        normalize_native_path_display(path)
1932                    ))
1933                })
1934            }
1935            Err(source) => Err(CliError::InvalidInput(format!(
1936                "purpose input is not valid UTF-8 for {}: {source}",
1937                normalize_native_path_display(path)
1938            ))),
1939        }
1940    }
1941
1942    /// Read bytes with inter-chunk cancellation and aggregate accounting.
1943    fn read_bytes<R: Read>(
1944        &mut self,
1945        path: &Path,
1946        reader: &mut R,
1947        file_limit: u64,
1948        require_complete: bool,
1949    ) -> Result<Vec<u8>, CliError> {
1950        let initial_capacity = usize::try_from(file_limit)
1951            .unwrap_or(usize::MAX)
1952            .min(CONTROLLED_SOURCE_READ_BUFFER_BYTES);
1953        let mut bytes = Vec::with_capacity(initial_capacity);
1954        let mut buffer = [0_u8; CONTROLLED_SOURCE_READ_BUFFER_BYTES];
1955        loop {
1956            self.control.check(IndexWorkStage::Publication)?;
1957            let file_bytes = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
1958            let remaining = file_limit.saturating_sub(file_bytes);
1959            if remaining == 0 {
1960                if !require_complete {
1961                    break;
1962                }
1963                let read = reader
1964                    .read(&mut buffer[..1])
1965                    .map_err(|source| CliError::Io {
1966                        path: path.to_path_buf(),
1967                        source,
1968                    })?;
1969                if read == 0 {
1970                    break;
1971                }
1972                return Err(IndexWorkFailure::resource_limit(
1973                    IndexWorkStage::Publication,
1974                    IndexWorkResource::PurposeBytes,
1975                    file_limit,
1976                    file_limit.saturating_add(1),
1977                )
1978                .into());
1979            }
1980            let read_limit = usize::try_from(remaining)
1981                .unwrap_or(usize::MAX)
1982                .min(buffer.len());
1983            let read = reader
1984                .read(&mut buffer[..read_limit])
1985                .map_err(|source| CliError::Io {
1986                    path: path.to_path_buf(),
1987                    source,
1988                })?;
1989            if read == 0 {
1990                break;
1991            }
1992            self.control.consume_purpose_bytes(
1993                self.limits.total_bytes,
1994                u64::try_from(read).unwrap_or(u64::MAX),
1995            )?;
1996            bytes.extend_from_slice(&buffer[..read]);
1997        }
1998        self.control.check(IndexWorkStage::Publication)?;
1999        Ok(bytes)
2000    }
2001
2002    /// Return the digest of one complete input already read through this boundary.
2003    fn complete_digest(&self, path: &Path) -> Option<&str> {
2004        self.complete_digests.get(path).map(String::as_str)
2005    }
2006}
2007
2008impl ScanRuntimePlan {
2009    /// Resolve scan policy for one project path.
2010    pub(crate) fn for_path(
2011        config_path: Option<&Path>,
2012        path: &Path,
2013        text_index_max_bytes: Option<u64>,
2014    ) -> Result<Self, CliError> {
2015        let control = standalone_index_work_control();
2016        Self::for_path_controlled(config_path, path, text_index_max_bytes, &control)
2017    }
2018
2019    /// Resolve scan policy through the operation-owned bounded config reader.
2020    pub(crate) fn for_path_controlled(
2021        config_path: Option<&Path>,
2022        path: &Path,
2023        text_index_max_bytes: Option<u64>,
2024        control: &IndexWorkControl,
2025    ) -> Result<Self, CliError> {
2026        Self::for_path_controlled_with_limits(
2027            config_path,
2028            path,
2029            text_index_max_bytes,
2030            control,
2031            PurposeImportLimits::default(),
2032        )
2033    }
2034
2035    /// Resolve scan policy under explicit authored-input limits used by focused tests.
2036    fn for_path_controlled_with_limits(
2037        config_path: Option<&Path>,
2038        path: &Path,
2039        text_index_max_bytes: Option<u64>,
2040        control: &IndexWorkControl,
2041        purpose_limits: PurposeImportLimits,
2042    ) -> Result<Self, CliError> {
2043        control.check(IndexWorkStage::Publication)?;
2044        let root = canonical_source_project_root(path)?;
2045        let selected_config_path = selected_scan_import_config_path(config_path, &root)?;
2046        let config = if let Some(path) = selected_config_path.as_deref() {
2047            let mut complete_paths = BTreeSet::new();
2048            complete_paths.insert(path.to_path_buf());
2049            let mut reader = PurposeInputReader::for_complete_paths(
2050                control,
2051                purpose_limits,
2052                complete_paths,
2053                ".purpose".to_string(),
2054            );
2055            let text = reader.read_text(path)?;
2056            let config = load_atlas_config_from_text(path, &text)?;
2057            let config_root = canonical_project_root(&config.root)?;
2058            if config_root != root {
2059                return Err(config_root_mismatch_error(path, &config_root, &root));
2060            }
2061            Some(config)
2062        } else {
2063            None
2064        };
2065        control.check(IndexWorkStage::Publication)?;
2066        let scan_options = config.as_ref().map_or_else(
2067            ScanOptions::default,
2068            atlas_map::AtlasMapConfig::scan_options,
2069        );
2070        let text_options = text_index_options(config.as_ref(), text_index_max_bytes);
2071        #[cfg(feature = "optional-parser-supervisor")]
2072        let optional_parser_selection =
2073            OptionalParserPackLifecycle::new(&root, None)?.derive_project_selection()?;
2074        #[cfg(feature = "optional-parser-supervisor")]
2075        let scan_options = {
2076            let mut scan_options = scan_options;
2077            scan_options.admit_optional_languages =
2078                optional_parser_selection.selection_key().is_some();
2079            scan_options
2080        };
2081        Ok(Self {
2082            root,
2083            config,
2084            selected_config_path,
2085            config_path_override: config_path.map(Path::to_path_buf),
2086            scan_options,
2087            text_options,
2088            text_index_max_bytes_override: text_index_max_bytes,
2089            #[cfg(feature = "optional-parser-supervisor")]
2090            optional_parser_selection,
2091        })
2092    }
2093
2094    /// Reload the effective filesystem and text policy from current local state.
2095    fn reload(&self) -> Result<Self, CliError> {
2096        Self::for_path(
2097            self.config_path_override.as_deref(),
2098            &self.root,
2099            self.text_index_max_bytes_override,
2100        )
2101    }
2102
2103    /// Reload effective policy through the operation-owned bounded input reader.
2104    fn reload_controlled(&self, control: &IndexWorkControl) -> Result<Self, CliError> {
2105        self.reload_controlled_with_limits(control, PurposeImportLimits::default())
2106    }
2107
2108    /// Reload effective policy under explicit authored-input limits used by focused tests.
2109    fn reload_controlled_with_limits(
2110        &self,
2111        control: &IndexWorkControl,
2112        purpose_limits: PurposeImportLimits,
2113    ) -> Result<Self, CliError> {
2114        Self::for_path_controlled_with_limits(
2115            self.config_path_override.as_deref(),
2116            &self.root,
2117            self.text_index_max_bytes_override,
2118            control,
2119            purpose_limits,
2120        )
2121    }
2122
2123    /// Hash the durable parser and configured source/index policy contract.
2124    ///
2125    /// Request-scoped text limits control one scan or watcher operation but do
2126    /// not become project compatibility state that later reads must repeat.
2127    fn publication_contract_fingerprint(&self) -> String {
2128        let configured_text_options = text_index_options(self.config.as_ref(), None);
2129        index_derivation_fingerprint(
2130            &self.scan_options,
2131            configured_text_options,
2132            #[cfg(feature = "optional-parser-supervisor")]
2133            &self.optional_parser_selection,
2134        )
2135    }
2136
2137    /// Capture every purpose input from one existing controlled repository scan.
2138    fn purpose_import_snapshot_controlled(
2139        &self,
2140        nodes: &[Node],
2141        control: &IndexWorkControl,
2142    ) -> Result<PurposeImportSnapshot, CliError> {
2143        self.purpose_import_snapshot_controlled_with_limits(
2144            nodes,
2145            control,
2146            PurposeImportLimits::default(),
2147        )
2148    }
2149
2150    /// Capture purpose inputs under explicit limits used by focused tests.
2151    fn purpose_import_snapshot_controlled_with_limits(
2152        &self,
2153        nodes: &[Node],
2154        control: &IndexWorkControl,
2155        limits: PurposeImportLimits,
2156    ) -> Result<PurposeImportSnapshot, CliError> {
2157        control.check(IndexWorkStage::Publication)?;
2158        let mut hasher = Hasher::new();
2159        hash_index_contract_value(&mut hasher, "purpose_import_version", "2");
2160        let Some(config) = self.config.as_ref() else {
2161            hash_index_contract_value(&mut hasher, "selected_config", "absent");
2162            return Ok(PurposeImportSnapshot {
2163                records: Vec::new(),
2164                fingerprint: hasher.finalize().to_hex().to_string(),
2165            });
2166        };
2167        let selected_config_path = self.selected_config_path.as_deref().ok_or_else(|| {
2168            CliError::InvalidInput(
2169                "purpose import has normalized configuration without a selected config path"
2170                    .to_string(),
2171            )
2172        })?;
2173        if u64::try_from(nodes.len()).unwrap_or(u64::MAX) > limits.records {
2174            return Err(IndexWorkFailure::resource_limit(
2175                IndexWorkStage::Publication,
2176                IndexWorkResource::PurposeRecords,
2177                limits.records,
2178                u64::try_from(nodes.len()).unwrap_or(u64::MAX),
2179            )
2180            .into());
2181        }
2182        let mut reader = PurposeInputReader::new(self, control, limits);
2183        hash_publication_input_file_controlled(
2184            &mut hasher,
2185            "selected_config",
2186            selected_config_path,
2187            &mut reader,
2188        )?;
2189        let records = atlas_map::imported_purpose_records_from_nodes(config, nodes, &mut |path| {
2190            reader.read_text(path)
2191        })?;
2192        let fingerprint = finish_purpose_import_fingerprint_controlled(
2193            &mut hasher,
2194            config,
2195            &records,
2196            &mut reader,
2197            control,
2198            limits,
2199        )?;
2200        Ok(PurposeImportSnapshot {
2201            records,
2202            fingerprint,
2203        })
2204    }
2205
2206    /// Recheck external purpose inputs while reusing records from exact unchanged source nodes.
2207    fn purpose_import_fingerprint_for_records_controlled_with_limits(
2208        &self,
2209        records: &[atlas_map::ImportedPurposeRecord],
2210        control: &IndexWorkControl,
2211        limits: PurposeImportLimits,
2212    ) -> Result<String, CliError> {
2213        control.check(IndexWorkStage::Publication)?;
2214        let mut hasher = Hasher::new();
2215        hash_index_contract_value(&mut hasher, "purpose_import_version", "2");
2216        let Some(config) = self.config.as_ref() else {
2217            hash_index_contract_value(&mut hasher, "selected_config", "absent");
2218            return Ok(hasher.finalize().to_hex().to_string());
2219        };
2220        let selected_config_path = self.selected_config_path.as_deref().ok_or_else(|| {
2221            CliError::InvalidInput(
2222                "purpose import has normalized configuration without a selected config path"
2223                    .to_string(),
2224            )
2225        })?;
2226        let mut reader = PurposeInputReader::new(self, control, limits);
2227        hash_publication_input_file_controlled(
2228            &mut hasher,
2229            "selected_config",
2230            selected_config_path,
2231            &mut reader,
2232        )?;
2233        finish_purpose_import_fingerprint_controlled(
2234            &mut hasher,
2235            config,
2236            records,
2237            &mut reader,
2238            control,
2239            limits,
2240        )
2241    }
2242}
2243
2244/// Finish one purpose-import fingerprint from already normalized source records.
2245fn finish_purpose_import_fingerprint_controlled(
2246    hasher: &mut Hasher,
2247    config: &atlas_map::AtlasMapConfig,
2248    records: &[atlas_map::ImportedPurposeRecord],
2249    reader: &mut PurposeInputReader<'_>,
2250    control: &IndexWorkControl,
2251    limits: PurposeImportLimits,
2252) -> Result<String, CliError> {
2253    let record_count = u64::try_from(records.len()).unwrap_or(u64::MAX);
2254    if record_count > limits.records {
2255        return Err(IndexWorkFailure::resource_limit(
2256            IndexWorkStage::Publication,
2257            IndexWorkResource::PurposeRecords,
2258            limits.records,
2259            record_count,
2260        )
2261        .into());
2262    }
2263    hash_publication_input_file_controlled(hasher, "legacy_map", &config.map_path, reader)?;
2264    hash_publication_input_file_controlled(
2265        hasher,
2266        "nonsource_purposes",
2267        &config.nonsource_files_path,
2268        reader,
2269    )?;
2270    for record in records {
2271        control.check(IndexWorkStage::Publication)?;
2272        hash_index_contract_value(hasher, "purpose_path", &record.path);
2273        hash_index_contract_value(hasher, "purpose_summary", &record.summary);
2274    }
2275    Ok(hasher.finalize().to_hex().to_string())
2276}
2277
2278/// Resolve the exact config file selected for a scan plan without loading it.
2279fn selected_scan_import_config_path(
2280    config_path: Option<&Path>,
2281    root: &Path,
2282) -> Result<Option<PathBuf>, CliError> {
2283    if let Some(config_path) = config_path {
2284        return absolute_path(config_path).map(Some);
2285    }
2286    Ok(config_candidates_for_root(root)
2287        .into_iter()
2288        .find(|candidate| candidate.exists()))
2289}
2290
2291/// Bind one optional file's identity and exact bytes to publication input state.
2292fn hash_publication_input_file_controlled(
2293    hasher: &mut Hasher,
2294    role: &str,
2295    path: &Path,
2296    reader: &mut PurposeInputReader<'_>,
2297) -> Result<(), CliError> {
2298    hash_index_contract_native_path(hasher, role, path);
2299    if !path.exists() {
2300        hash_index_contract_value(hasher, "input_state", "missing");
2301        return Ok(());
2302    }
2303    if reader.complete_digest(path).is_none() {
2304        let _ = reader.read_text(path)?;
2305    }
2306    let digest = reader.complete_digest(path).ok_or_else(|| {
2307        CliError::InvalidInput(format!(
2308            "purpose publication input was not read completely: {}",
2309            normalize_native_path_display(path)
2310        ))
2311    })?;
2312    hash_index_contract_value(hasher, "input_state", "present");
2313    hash_index_contract_value(hasher, "input_digest", digest);
2314    Ok(())
2315}
2316
2317/// Scan command report shared by CLI and MCP adapters.
2318#[derive(Debug, Serialize)]
2319pub(crate) struct ScanReport {
2320    /// Repository overview after scan.
2321    pub(crate) overview: Overview,
2322    /// Legacy purpose records imported into the current index.
2323    pub(crate) purpose_import: PurposeImportReport,
2324    /// Persisted text search index report.
2325    pub(crate) text_index: TextIndexReport,
2326    /// Structural summaries refreshed for declaration-light files.
2327    pub(crate) structural_summaries: StructuralSummaryReport,
2328    /// Symbol graph build report.
2329    pub(crate) symbols: SymbolBuildReport,
2330}
2331
2332/// Legacy purpose import counts from a scan.
2333#[derive(Debug, Default, Serialize)]
2334pub(crate) struct PurposeImportReport {
2335    /// Purpose records imported into indexed nodes.
2336    pub(crate) imported: usize,
2337    /// Legacy purpose records skipped because the path is no longer indexed.
2338    pub(crate) skipped_stale: usize,
2339    /// Legacy purpose records skipped because a curated purpose already exists.
2340    pub(crate) skipped_existing: usize,
2341}
2342
2343/// Options for the first-run initialization bootstrap.
2344pub(crate) struct InitBootstrapOptions {
2345    /// Skip the scan/index phase.
2346    pub(crate) no_scan: bool,
2347    /// Force a scan even when future freshness checks would skip it.
2348    pub(crate) force_rescan: bool,
2349    /// Optional text index byte limit override.
2350    pub(crate) text_index_max_bytes: Option<u64>,
2351}
2352
2353/// Project initialization phase status.
2354#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
2355#[serde(rename_all = "snake_case")]
2356pub(crate) enum InitPhaseStatus {
2357    /// Resource was created during this run.
2358    Created,
2359    /// Resource already existed before this run.
2360    Exists,
2361    /// Resource was verified or phase completed.
2362    Verified,
2363    /// Phase was explicitly skipped.
2364    Skipped,
2365    /// Phase failed before the report could finish.
2366    Failed,
2367}
2368
2369/// First-run init report shared by CLI and MCP adapters.
2370#[derive(Debug, Serialize)]
2371pub(crate) struct InitSetupReport {
2372    /// Whether every init phase completed successfully.
2373    pub(crate) ok: bool,
2374    /// Canonical project root initialized by this command.
2375    pub(crate) root: Option<String>,
2376    /// Project-local directory status.
2377    pub(crate) project_dir: InitPathStatus,
2378    /// Project-local config status.
2379    pub(crate) config: InitPathStatus,
2380    /// Project-local non-source registry status.
2381    pub(crate) nonsource_files: InitPathStatus,
2382    /// Durable `SQLite` DB status.
2383    pub(crate) db: InitPathStatus,
2384    /// Generated host MCP config files.
2385    pub(crate) host_configs: Vec<InitHostConfigStatus>,
2386    /// Scan/index phase result.
2387    pub(crate) scan: InitScanPhase,
2388    /// Registered-worktree hydration result when alias init selected one.
2389    #[serde(skip_serializing_if = "Option::is_none")]
2390    pub(crate) hydration: Option<InitHydrationPhase>,
2391    /// Agent harness purpose curation handoff.
2392    pub(crate) purpose_handoff: PurposeCuratorHandoff,
2393    /// Human/agent next steps.
2394    pub(crate) next_steps: Vec<String>,
2395}
2396
2397/// Status for one path managed by init.
2398#[derive(Debug, Serialize)]
2399pub(crate) struct InitPathStatus {
2400    /// Path status.
2401    pub(crate) status: InitPhaseStatus,
2402    /// Lossless UTF-8 path, when one is available.
2403    pub(crate) path: Option<String>,
2404}
2405
2406/// Status for one generated host integration config.
2407#[derive(Debug, Serialize)]
2408pub(crate) struct InitHostConfigStatus {
2409    /// Harness/config shape name.
2410    pub(crate) harness: &'static str,
2411    /// File status.
2412    pub(crate) status: InitPhaseStatus,
2413    /// Lossless UTF-8 path, when one is available.
2414    pub(crate) path: Option<String>,
2415    /// Error text when this host config could not be generated.
2416    #[serde(skip_serializing_if = "Option::is_none")]
2417    pub(crate) error: Option<String>,
2418}
2419
2420/// Scan/index phase result for init.
2421#[derive(Debug, Serialize)]
2422pub(crate) struct InitScanPhase {
2423    /// Scan phase status.
2424    pub(crate) status: InitPhaseStatus,
2425    /// Whether scan was requested by this run.
2426    pub(crate) requested: bool,
2427    /// Whether force-rescan was requested.
2428    pub(crate) force_rescan: bool,
2429    /// Scan report when the scan ran.
2430    pub(crate) report: Option<ScanReport>,
2431    /// Error text when the scan/index phase failed.
2432    #[serde(skip_serializing_if = "Option::is_none")]
2433    pub(crate) error: Option<String>,
2434}
2435
2436/// Registered-worktree initialization source state.
2437#[derive(Debug, Serialize)]
2438pub(crate) struct InitHydrationPhase {
2439    /// Whether a reusable control baseline was activated, skipped, or rejected.
2440    pub(crate) status: InitHydrationStatus,
2441    /// Canonical control root evaluated as the optional source.
2442    #[serde(skip_serializing_if = "Option::is_none")]
2443    pub(crate) source_root: Option<String>,
2444    /// Source control-atlas identity when hydration succeeded.
2445    #[serde(skip_serializing_if = "Option::is_none")]
2446    pub(crate) source_project_instance_id: Option<String>,
2447    /// New independently writable target-atlas identity when hydration succeeded.
2448    #[serde(skip_serializing_if = "Option::is_none")]
2449    pub(crate) target_project_instance_id: Option<String>,
2450    /// Detached baseline generation when hydration succeeded.
2451    #[serde(skip_serializing_if = "Option::is_none")]
2452    pub(crate) baseline_generation: Option<u64>,
2453    /// Complete target generation activated after reconciliation.
2454    #[serde(skip_serializing_if = "Option::is_none")]
2455    pub(crate) reconciled_generation: Option<u64>,
2456    /// Visible reason ordinary initialization was used instead.
2457    #[serde(skip_serializing_if = "Option::is_none")]
2458    pub(crate) fallback_reason: Option<String>,
2459}
2460
2461/// Stable worktree hydration outcomes exposed by init.
2462#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
2463#[serde(rename_all = "snake_case")]
2464pub(crate) enum InitHydrationStatus {
2465    /// The existing valid worktree atlas was preserved.
2466    Existing,
2467    /// A control-atlas baseline was reconciled and activated.
2468    Hydrated,
2469    /// Ordinary init completed after hydration was unavailable or unsafe.
2470    Fallback,
2471}
2472
2473/// Purpose curation handoff for agent/plugin harnesses.
2474#[derive(Debug, Serialize)]
2475#[allow(
2476    clippy::struct_excessive_bools,
2477    reason = "serialized host policy exposes independent capabilities and guarantees"
2478)]
2479pub(crate) struct PurposeCuratorHandoff {
2480    /// Whether this report is intended for an agent harness.
2481    pub(crate) agent_harness_expected: bool,
2482    /// Curator execution is owned by the agent host, never the Rust server.
2483    pub(crate) execution_owner: &'static str,
2484    /// Recommended host-relative reliable subagent tier selection.
2485    pub(crate) recommended_subagent_reasoning: &'static str,
2486    /// Whether the current main agent may process the same bounded batch.
2487    pub(crate) main_agent_fallback: bool,
2488    /// Explicitly records that `ProjectAtlas` did not spawn a host agent.
2489    pub(crate) server_started_curator: bool,
2490    /// Successful maintenance should not add ordinary conversation output.
2491    pub(crate) silent_on_success: bool,
2492    /// Purpose queue page for initial curation.
2493    pub(crate) queue: PurposeCurationPage,
2494    /// Handoff instructions for plugin/agent harnesses.
2495    pub(crate) instructions: Vec<String>,
2496}
2497
2498/// Return a canonical absolute project root.
2499pub(crate) fn canonical_project_root(root: &Path) -> Result<PathBuf, CliError> {
2500    root.canonicalize().map_err(|source| CliError::Io {
2501        path: root.to_path_buf(),
2502        source,
2503    })
2504}
2505
2506/// Return a lossless UTF-8 display for one existing canonical project root.
2507pub(crate) fn lossless_project_root_display(root: &Path) -> Option<String> {
2508    CanonicalProjectRoot::from_path(root)
2509        .ok()
2510        .and_then(|root| root.display_string().ok())
2511}
2512
2513/// Return a lossless UTF-8 display for one native path.
2514pub(crate) fn lossless_native_path_display(path: &Path) -> Option<String> {
2515    projectatlas_core::lossless_native_path_display(path).ok()
2516}
2517
2518/// Return one exact source root from structural Git or non-Git evidence.
2519pub(crate) fn canonical_source_project_root(root: &Path) -> Result<PathBuf, CliError> {
2520    match projectatlas_fs::worktree::discover_repository_structure(root)? {
2521        RepositoryStructure::NonGit { selected_root } => Ok(selected_root),
2522        RepositoryStructure::Git(repository) => match repository.selection {
2523            GitRepositorySelection::Worktree { root, .. }
2524            | GitRepositorySelection::CommonManager {
2525                source_selection: GitManagerSourceSelection::Unambiguous { root },
2526            } => Ok(root),
2527            GitRepositorySelection::CommonManager { .. } => Err(CliError::WorktreeRequired(
2528                Box::new(ProjectWorktreeRequired {
2529                    project_root: lossless_project_root_display(&repository.common_directory),
2530                    status: IndexReadStatus::WorktreeRequired,
2531                }),
2532            )),
2533        },
2534        RepositoryStructure::InvalidGit {
2535            selected_root,
2536            issue,
2537        } => Err(CliError::InvalidInput(format!(
2538            "invalid structural Git evidence for '{}': {:?} at '{}'",
2539            normalize_native_path_display(selected_root),
2540            issue.kind,
2541            normalize_native_path_display(issue.path)
2542        ))),
2543    }
2544}
2545
2546/// Return a typed, non-mutating first-use handoff for one selected root.
2547pub(crate) fn index_init_required(root: &Path, database: &Path) -> CliError {
2548    CliError::InitRequired(Box::new(IndexInitRequired {
2549        project_root: lossless_project_root_display(root),
2550        database: lossless_native_path_display(database),
2551        worktree: None,
2552        status: IndexReadStatus::InitRequired,
2553    }))
2554}
2555
2556/// Load map configuration for purpose import during scan.
2557pub(crate) fn load_scan_import_config(
2558    config_path: Option<&Path>,
2559    scan_path: &Path,
2560) -> Result<Option<atlas_map::AtlasMapConfig>, CliError> {
2561    if let Some(config_path) = config_path {
2562        return Ok(Some(load_atlas_config(Some(config_path))?));
2563    }
2564    let project_config = scan_path.join(".projectatlas").join("config.toml");
2565    if project_config.exists() {
2566        return Ok(Some(load_atlas_config(Some(&project_config))?));
2567    }
2568    let flat_config = scan_path.join("projectatlas.toml");
2569    if flat_config.exists() {
2570        return Ok(Some(load_atlas_config(Some(&flat_config))?));
2571    }
2572    Ok(None)
2573}
2574
2575/// Admit automatic database files before any SQLite access.
2576///
2577/// SQLite read-only opens can initialize SHM, so admission precedes every open.
2578pub(crate) fn preflight_managed_database(
2579    path: &Path,
2580    root: &Path,
2581) -> Result<Option<crate::project_state::ProjectStateGuard>, CliError> {
2582    crate::project_state::ProjectStateGuard::preflight_database_path(path, root).map_err(|source| {
2583        CliError::Io {
2584            path: path.to_path_buf(),
2585            source,
2586        }
2587    })
2588}
2589
2590/// Use the admitted SQLite cohort while preserving unrelated explicit bindings.
2591pub(crate) fn admitted_database_path<'a>(
2592    path: &'a Path,
2593    admission: Option<&'a crate::project_state::ProjectStateGuard>,
2594) -> &'a Path {
2595    admission
2596        .and_then(crate::project_state::ProjectStateGuard::sqlite_path)
2597        .unwrap_or(path)
2598}
2599
2600/// Admit reset's namespace without opening a possibly corrupt database.
2601pub(crate) fn preflight_managed_reset(
2602    path: &Path,
2603    root: &Path,
2604) -> Result<Option<crate::project_state::ProjectStateGuard>, CliError> {
2605    crate::project_state::ProjectStateGuard::preflight_database_reset_path(path, root).map_err(
2606        |source| CliError::Io {
2607            path: path.to_path_buf(),
2608            source,
2609        },
2610    )
2611}
2612
2613/// Open or create a durable index bound to one selected project root.
2614pub(crate) fn open_atlas_store_for_project(
2615    path: &Path,
2616    root: &Path,
2617) -> Result<AtlasStore, CliError> {
2618    open_atlas_store_for_project_with_location_validator(path, root, validate_database_location)
2619}
2620
2621/// Open initialized storage without permitting absent or empty schema creation.
2622pub(crate) fn open_existing_atlas_store_for_project(
2623    path: &Path,
2624    root: &Path,
2625) -> Result<AtlasStore, CliError> {
2626    let admission = preflight_managed_database(path, root)?;
2627    let path = admitted_database_path(path, admission.as_ref());
2628    AtlasStore::open_existing_for_project(path, root).map_err(|source| {
2629        if matches!(source, projectatlas_db::DbError::InitializationRequired) {
2630            index_init_required(root, path)
2631        } else {
2632            project_store_error(source)
2633        }
2634    })
2635}
2636
2637/// Validate storage before creating the database parent and opening the store.
2638fn open_atlas_store_for_project_with_location_validator<F>(
2639    path: &Path,
2640    root: &Path,
2641    validate_location: F,
2642) -> Result<AtlasStore, CliError>
2643where
2644    F: FnOnce(&Path) -> projectatlas_db::DbResult<()>,
2645{
2646    let admission = preflight_managed_database(path, root)?;
2647    let path = admitted_database_path(path, admission.as_ref());
2648    validate_location(path).map_err(project_store_error)?;
2649    ensure_parent_dir(path)?;
2650    AtlasStore::open_for_project(path, root).map_err(project_store_error)
2651}
2652
2653/// Open a current durable index read snapshot bound to one selected root.
2654pub(crate) fn open_atlas_store_read_only_for_project(
2655    path: &Path,
2656    root: &Path,
2657) -> Result<AtlasStore, CliError> {
2658    let admission = preflight_managed_database(path, root)?;
2659    let path = admitted_database_path(path, admission.as_ref());
2660    AtlasStore::open_read_only_for_project(path, root).map_err(|source| {
2661        if matches!(source, projectatlas_db::DbError::InitializationRequired) {
2662            index_init_required(root, path)
2663        } else {
2664            project_store_error(source)
2665        }
2666    })
2667}
2668
2669/// Preserve typed selected-root mismatch diagnostics across store adapters.
2670pub(crate) fn project_store_error(source: projectatlas_db::DbError) -> CliError {
2671    match source {
2672        projectatlas_db::DbError::ProjectRootMismatch {
2673            expected,
2674            found,
2675            identities,
2676        } => CliError::ProjectMismatch(Box::new(IndexProjectMismatch {
2677            status: IndexReadStatus::ProjectMismatch,
2678            worktree: None,
2679            selected_project_root: identities
2680                .as_ref()
2681                .and_then(|identities| identities.expected.display_string().ok()),
2682            indexed_project_root: identities
2683                .as_ref()
2684                .and_then(|identities| identities.found.display_string().ok()),
2685            diagnostic: Some(format!(
2686                "database project root {found:?} does not match selected root {expected:?}"
2687            )),
2688        })),
2689        other => CliError::Db(other),
2690    }
2691}
2692
2693/// Return the config path init should preserve or create for a project root.
2694pub(crate) fn init_config_path(root: &Path, explicit: Option<&Path>) -> PathBuf {
2695    if let Some(config_path) = explicit {
2696        return if config_path.is_absolute() {
2697            config_path.to_path_buf()
2698        } else {
2699            root.join(config_path)
2700        };
2701    }
2702    let nested_config = root.join(".projectatlas").join("config.toml");
2703    if nested_config.exists() {
2704        return nested_config;
2705    }
2706    let flat_config = root.join("projectatlas.toml");
2707    if flat_config.exists() {
2708        return flat_config;
2709    }
2710    nested_config
2711}
2712
2713/// Admit automatic initialization outputs before any database mutation.
2714pub(crate) fn preflight_generated_state(
2715    root: &Path,
2716    db_path: &Path,
2717    config_path: &Path,
2718) -> Result<crate::project_state::ProjectStateGuard, CliError> {
2719    crate::project_state::ProjectStateGuard::preflight(root, Some(db_path), Some(config_path))
2720        .map_err(|source| CliError::Io {
2721            path: root.join(".projectatlas"),
2722            source,
2723        })
2724}
2725
2726/// Run the one-call first-run init bootstrap.
2727///
2728/// Automatic destinations are admitted before legacy identity repair can write.
2729pub(crate) fn run_init_bootstrap(
2730    root: &Path,
2731    db_path: &Path,
2732    config_path: Option<&Path>,
2733    options: &InitBootstrapOptions,
2734) -> Result<InitSetupReport, CliError> {
2735    let root = canonical_source_project_root(root)?;
2736    let config_file = init_config_path(&root, config_path);
2737    let state_guard = preflight_generated_state(&root, db_path, &config_file)?;
2738    let db_path = state_guard.sqlite_path().unwrap_or(db_path);
2739    if db_path.is_file() {
2740        AtlasStore::repair_missing_project_root_identity(db_path, &root)
2741            .map_err(project_store_error)?;
2742    }
2743    preflight_existing_project_binding(db_path, &root)?;
2744    let project_dir = root.join(".projectatlas");
2745    let nonsource_file = project_dir.join("projectatlas-nonsource-files.toon");
2746    let project_dir_existed = project_dir.exists();
2747    let config_existed = config_file.exists();
2748    let nonsource_existed = nonsource_file.exists();
2749    let db_existed = db_path.exists();
2750
2751    init_project_with_config(&root, Some(&config_file))?;
2752    state_guard.verify().map_err(|source| CliError::Io {
2753        path: root.join(".projectatlas"),
2754        source,
2755    })?;
2756    let mut store = open_atlas_store_for_project(db_path, &root)?;
2757
2758    let mut ok = true;
2759    let (scan_status, scan_report, scan_error) = if options.no_scan {
2760        (InitPhaseStatus::Skipped, None, None)
2761    } else {
2762        let symbol_options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None);
2763        let control = index_work_control(&symbol_options);
2764        match ScanRuntimePlan::for_path_controlled(
2765            config_path,
2766            &root,
2767            options.text_index_max_bytes,
2768            &control,
2769        )
2770        .and_then(|plan| run_scan_pipeline_controlled(&mut store, &plan, &symbol_options, &control))
2771        {
2772            Ok(report) => (InitPhaseStatus::Verified, Some(report), None),
2773            Err(error) => {
2774                ok = false;
2775                (InitPhaseStatus::Failed, None, Some(error.to_string()))
2776            }
2777        }
2778    };
2779
2780    let purpose_query = HealthQuery {
2781        start_index: 0,
2782        limit: DEFAULT_HEALTH_LIMIT,
2783        category: None,
2784        severity: None,
2785        path_prefix: None,
2786        summary_only: false,
2787        scope: HealthScope::purpose_default(),
2788    };
2789    let purpose_queue = purpose_curation_page(&store, &purpose_query, "project-init")?;
2790    let next_steps = init_next_steps(options.no_scan, scan_error.is_some(), purpose_queue.total);
2791
2792    Ok(InitSetupReport {
2793        ok,
2794        root: lossless_project_root_display(&root),
2795        project_dir: InitPathStatus {
2796            status: init_path_status(project_dir_existed),
2797            path: lossless_native_path_display(&project_dir),
2798        },
2799        config: InitPathStatus {
2800            status: init_path_status(config_existed),
2801            path: lossless_native_path_display(&config_file),
2802        },
2803        nonsource_files: InitPathStatus {
2804            status: init_path_status(nonsource_existed),
2805            path: lossless_native_path_display(&nonsource_file),
2806        },
2807        db: InitPathStatus {
2808            status: init_path_status(db_existed),
2809            path: lossless_native_path_display(db_path),
2810        },
2811        host_configs: Vec::new(),
2812        scan: InitScanPhase {
2813            status: scan_status,
2814            requested: !options.no_scan,
2815            force_rescan: options.force_rescan,
2816            report: scan_report,
2817            error: scan_error,
2818        },
2819        hydration: None,
2820        purpose_handoff: purpose_curator_handoff(purpose_queue),
2821        next_steps,
2822    })
2823}
2824
2825/// Refuse an existing database binding before init writes.
2826///
2827/// The storage owner performs one read-only admission for both current native
2828/// identities and supported predecessor metadata. Fresh databases remain
2829/// available to the initializer, while the later writer still revalidates under
2830/// its transaction for races.
2831pub(crate) fn preflight_existing_project_binding(
2832    db_path: &Path,
2833    root: &Path,
2834) -> Result<(), CliError> {
2835    let admission = preflight_managed_database(db_path, root)?;
2836    let db_path = admitted_database_path(db_path, admission.as_ref());
2837    preflight_project_binding_read_only(db_path, root).map_err(project_store_error)
2838}
2839
2840/// Return created/existing status for a path.
2841pub(crate) fn init_path_status(existed: bool) -> InitPhaseStatus {
2842    if existed {
2843        InitPhaseStatus::Exists
2844    } else {
2845        InitPhaseStatus::Created
2846    }
2847}
2848
2849/// Return stable purpose handoff instructions for agent harnesses.
2850fn purpose_handoff_instructions() -> Vec<String> {
2851    vec![
2852        "If the host supports isolated subagents, delegate this actionable low-scope batch at the lowest reliable reasoning and cost tier the host supports; otherwise let the main agent process the same bounded rows without blocking navigation.".to_string(),
2853        "Inspect only bounded current summary, graph, outline, or exact-slice context, then copy task, work_key, and state_token into atlas_purpose_review or projectatlas purpose review --apply; never edit SQLite directly.".to_string(),
2854        "Skip accepted purposes unless an agent or user explicitly assigns a correction; use atlas_purpose_set or projectatlas purpose set for that deliberate correction path.".to_string(),
2855        "Keep successful curator maintenance out of normal conversation; ProjectAtlas reports a handoff and never claims that the Rust server spawned an agent.".to_string(),
2856    ]
2857}
2858
2859/// Build one host-owned purpose-curator handoff shared by init and session brief.
2860pub(crate) fn purpose_curator_handoff(queue: PurposeCurationPage) -> PurposeCuratorHandoff {
2861    PurposeCuratorHandoff {
2862        agent_harness_expected: true,
2863        execution_owner: "agent_host",
2864        recommended_subagent_reasoning: PURPOSE_CURATOR_RECOMMENDED_REASONING,
2865        main_agent_fallback: true,
2866        server_started_curator: false,
2867        silent_on_success: true,
2868        queue,
2869        instructions: purpose_handoff_instructions(),
2870    }
2871}
2872
2873/// Return concise next steps for humans and agents.
2874pub(crate) fn init_next_steps(
2875    scan_skipped: bool,
2876    scan_failed: bool,
2877    purpose_queue_total: usize,
2878) -> Vec<String> {
2879    let mut steps = Vec::new();
2880    if scan_skipped {
2881        steps.push("Run projectatlas scan when you are ready to build the deep index.".to_string());
2882    } else if scan_failed {
2883        steps.push(
2884            "Fix the scan/index error and rerun projectatlas init or projectatlas scan."
2885                .to_string(),
2886        );
2887    }
2888    if purpose_queue_total > 0 {
2889        steps.push(
2890            "Use the purpose_handoff queue to delegate purpose creation/correction at the lowest reliable reasoning and cost tier the host supports."
2891                .to_string(),
2892        );
2893    } else {
2894        steps.push("Purpose queue is empty for the default high-impact scope.".to_string());
2895    }
2896    steps.push("Run projectatlas overview to confirm repository orientation.".to_string());
2897    steps
2898}
2899
2900/// Create the parent directory for a path when it has one.
2901pub(crate) fn ensure_parent_dir(path: &Path) -> Result<(), CliError> {
2902    let Some(parent) = path.parent() else {
2903        return Ok(());
2904    };
2905    if parent.as_os_str().is_empty() {
2906        return Ok(());
2907    }
2908    fs::create_dir_all(parent).map_err(|source| CliError::Io {
2909        path: parent.to_path_buf(),
2910        source,
2911    })
2912}
2913
2914/// Build the standard config/root mismatch error.
2915pub(crate) fn config_root_mismatch_error(
2916    config_path: &Path,
2917    config_root: &Path,
2918    selected_root: &Path,
2919) -> CliError {
2920    CliError::InvalidInput(format!(
2921        "ProjectAtlas config '{}' resolves project root '{}' outside selected project root '{}'",
2922        config_path.display(),
2923        config_root.display(),
2924        selected_root.display()
2925    ))
2926}
2927
2928/// Resolve a predecessor root only as a read-only discovery candidate.
2929///
2930/// Legacy metadata is a display-only recovery hint. The database-owned
2931/// candidate reader rejects projections without native authority before this
2932/// function constructs a path or probes any candidate configuration location.
2933fn legacy_project_root_candidate(
2934    db: &Path,
2935    _admission: Option<&crate::project_state::ProjectStateGuard>,
2936) -> Result<Option<PathBuf>, CliError> {
2937    if !db.exists() {
2938        return Ok(None);
2939    }
2940    let Some(project_root) = read_legacy_project_root_candidate_read_only(db)? else {
2941        return Ok(None);
2942    };
2943    Ok(Some(canonical_source_project_root(Path::new(
2944        &project_root,
2945    ))?))
2946}
2947
2948/// Resolve the default MCP project root without trusting the process cwd.
2949pub(crate) fn default_mcp_project_root(
2950    db: &Path,
2951    config_path: Option<&Path>,
2952) -> Result<PathBuf, CliError> {
2953    resolve_database_project_root(db, config_path, true)
2954}
2955
2956/// Admit root discovery using selection available before SQLite metadata reads.
2957///
2958/// Explicit startup storage outside a known selected namespace is an isolated
2959/// operator binding. Its basename alone does not grant automatic ownership.
2960pub(crate) fn preflight_database_discovery(
2961    db: &Path,
2962    config_path: Option<&Path>,
2963    isolated_binding: bool,
2964) -> Result<Option<crate::project_state::ProjectStateGuard>, CliError> {
2965    let root = selected_database_admission_root(db, config_path, isolated_binding)?;
2966    preflight_managed_database(db, &root)
2967}
2968
2969/// Select known authority from configuration/layout/cwd without SQLite metadata.
2970pub(crate) fn selected_database_admission_root(
2971    db: &Path,
2972    config_path: Option<&Path>,
2973    isolated_binding: bool,
2974) -> Result<PathBuf, CliError> {
2975    let root = if let Some(config_path) = config_path {
2976        load_atlas_config(Some(config_path))?.root
2977    } else if !isolated_binding && let Some(root) = project_root_from_db_path(db) {
2978        root
2979    } else {
2980        std::env::current_dir().map_err(|source| CliError::Io {
2981            path: PathBuf::from("."),
2982            source,
2983        })?
2984    };
2985    Ok(root)
2986}
2987
2988/// Resolve metadata only after the implicit or explicit binding has been admitted.
2989fn resolve_database_project_root(
2990    db: &Path,
2991    config_path: Option<&Path>,
2992    isolated_binding: bool,
2993) -> Result<PathBuf, CliError> {
2994    let admission = preflight_database_discovery(db, config_path, isolated_binding)?;
2995    let db = admitted_database_path(db, admission.as_ref());
2996    let legacy_root = legacy_project_root_candidate(db, admission.as_ref())?;
2997    if let Some(config_path) = config_path {
2998        let config = load_atlas_config(Some(config_path))?;
2999        let config_root = canonical_source_project_root(&config.root)?;
3000        if let Some(db_root) = project_root_from_db_path(db) {
3001            let db_root = canonical_source_project_root(&db_root)?;
3002            if config_root != db_root {
3003                return Err(config_root_mismatch_error(
3004                    config_path,
3005                    &config_root,
3006                    &db_root,
3007                ));
3008            }
3009        }
3010        return Ok(config_root);
3011    }
3012    if db.exists()
3013        && let Some(project_root) = read_project_root_identity_read_only(db)?
3014    {
3015        return canonical_source_project_root(project_root.as_path());
3016    }
3017    if let Some(project_root) = legacy_root {
3018        return Ok(project_root);
3019    }
3020    if let Some(project_root) = project_root_from_db_path(db) {
3021        return canonical_source_project_root(&project_root);
3022    }
3023    let current_dir = std::env::current_dir().map_err(|source| CliError::Io {
3024        path: PathBuf::from("."),
3025        source,
3026    })?;
3027    canonical_source_project_root(&current_dir)
3028}
3029
3030/// Resolve the default CLI project root before opening an implicit database.
3031pub(crate) fn default_cli_project_root(
3032    db: &Path,
3033    config_path: Option<&Path>,
3034    database_path_is_explicit: bool,
3035) -> Result<PathBuf, CliError> {
3036    if !database_path_is_explicit
3037        && config_path.is_none()
3038        && !db.exists()
3039        && let Some(project_root) = project_root_from_db_path(db)
3040    {
3041        return canonical_source_project_root(&project_root);
3042    }
3043    resolve_database_project_root(db, config_path, database_path_is_explicit)
3044}
3045
3046/// Resolve a CLI repository-root argument, using indexed state for the default `.`.
3047pub(crate) fn defaultable_cli_project_root(
3048    path: &Path,
3049    db: &Path,
3050    config_path: Option<&Path>,
3051    database_path_is_explicit: bool,
3052) -> Result<PathBuf, CliError> {
3053    if path == Path::new(".") {
3054        return default_cli_project_root(db, config_path, database_path_is_explicit);
3055    }
3056    Ok(path.to_path_buf())
3057}
3058
3059/// Infer a project root from a default `.projectatlas/projectatlas.db` path.
3060fn project_root_from_db_path(db: &Path) -> Option<PathBuf> {
3061    let parent = db.parent()?;
3062    let cache_dir_name = parent.file_name()?;
3063    if cache_dir_name != ".projectatlas" {
3064        return None;
3065    }
3066    parent
3067        .parent()
3068        .filter(|root| !root.as_os_str().is_empty())
3069        .map_or_else(|| Some(PathBuf::from(".")), |root| Some(root.to_path_buf()))
3070}
3071
3072/// Load scan options for a project root from `ProjectAtlas` config when present.
3073pub(crate) fn scan_options_for_root(
3074    config_path: Option<&Path>,
3075    root: &Path,
3076) -> Result<ScanOptions, CliError> {
3077    Ok(load_scan_import_config(config_path, root)?
3078        .as_ref()
3079        .map_or_else(
3080            ScanOptions::default,
3081            atlas_map::AtlasMapConfig::scan_options,
3082        ))
3083}
3084
3085/// Resolve text-index persistence options from command override and config.
3086pub(crate) fn text_index_options(
3087    config: Option<&atlas_map::AtlasMapConfig>,
3088    max_bytes_override: Option<u64>,
3089) -> TextIndexOptions {
3090    let max_bytes = max_bytes_override
3091        .filter(|value| *value > 0)
3092        .or_else(|| config.map(atlas_map::AtlasMapConfig::text_index_max_bytes))
3093        .unwrap_or(atlas_map::DEFAULT_TEXT_INDEX_MAX_BYTES);
3094    TextIndexOptions::new(max_bytes)
3095}
3096
3097/// Capture the last complete generation used as a publication compare-and-swap anchor.
3098fn publication_base_generation(store: &AtlasStore) -> Result<IndexGeneration, CliError> {
3099    Ok(store
3100        .index_publication()?
3101        .map_or(IndexGeneration::ZERO, |publication| publication.generation))
3102}
3103
3104/// Prepare a complete source/index batch without acquiring the `SQLite` writer.
3105fn stage_full_index_publication(
3106    store: &AtlasStore,
3107    plan: &ScanRuntimePlan,
3108    symbol_options: &SymbolBuildOptions,
3109    reuse_unchanged_symbols: bool,
3110    import_legacy_purposes: bool,
3111    control: &IndexWorkControl,
3112) -> Result<IndexPublicationBatch, CliError> {
3113    stage_full_index_publication_from_source(
3114        store,
3115        plan,
3116        symbol_options,
3117        reuse_unchanged_symbols,
3118        import_legacy_purposes,
3119        None,
3120        control,
3121    )
3122}
3123
3124/// Consume operation-owned discovery when full reconciliation follows an exact assessment.
3125fn stage_full_index_publication_from_source(
3126    store: &AtlasStore,
3127    plan: &ScanRuntimePlan,
3128    symbol_options: &SymbolBuildOptions,
3129    reuse_unchanged_symbols: bool,
3130    import_legacy_purposes: bool,
3131    captured: Option<CapturedSourceState>,
3132    control: &IndexWorkControl,
3133) -> Result<IndexPublicationBatch, CliError> {
3134    if let Some(source) = &captured {
3135        verify_captured_source_identity(store, plan, source)?;
3136    }
3137    let base_generation = publication_base_generation(store)?;
3138    if let Some(source) = &captured
3139        && source.generation != base_generation
3140    {
3141        return Err(projectatlas_db::DbError::PublicationBaseGenerationChanged {
3142            expected: source.generation,
3143            found: base_generation,
3144        }
3145        .into());
3146    }
3147    let contract_fingerprint = plan.publication_contract_fingerprint();
3148    let previous_hashes = reuse_unchanged_symbols
3149        .then(|| indexed_file_hashes(store, control))
3150        .transpose()?;
3151    let nodes = match captured {
3152        Some(source) => source.nodes,
3153        None => scan_repo_controlled(
3154            &plan.root,
3155            &plan.scan_options,
3156            ScanLimits::default(),
3157            control,
3158        )
3159        .map_err(|source| source_inspection_error(&plan.root, source))?,
3160    };
3161    control.check(IndexWorkStage::Publication)?;
3162    let purpose_import = import_legacy_purposes
3163        .then(|| plan.purpose_import_snapshot_controlled(&nodes, control))
3164        .transpose()?;
3165    let protected_purpose_paths = protected_purpose_paths(&nodes, purpose_import.as_ref());
3166    let text_paths = nodes
3167        .iter()
3168        .filter(|node| node.kind == NodeKind::File)
3169        .map(|node| node.path.clone())
3170        .collect::<Vec<_>>();
3171    let text = stage_text_index_for_changed_paths_controlled(
3172        &plan.root,
3173        &nodes,
3174        plan.text_options,
3175        control,
3176    )?;
3177    let content_classifications = stage_file_content_classifications(&nodes, &text.rows);
3178    let retained_before_symbols =
3179        staged_publication_identity_bytes(&plan.root, &contract_fingerprint)
3180            .saturating_add(staged_string_bytes(&text_paths))
3181            .saturating_add(staged_node_bytes(&nodes))
3182            .saturating_add(staged_text_bytes(&text))
3183            .saturating_add(staged_file_content_classification_bytes(
3184                &content_classifications,
3185            ))
3186            .saturating_add(staged_purpose_bytes(purpose_import.as_ref()));
3187    let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
3188    let mut symbols = stage_symbols_for_nodes_with_limits(
3189        store,
3190        &plan.root,
3191        #[cfg(feature = "optional-parser-supervisor")]
3192        &plan.optional_parser_selection,
3193        &nodes,
3194        symbol_options,
3195        previous_hashes.as_ref(),
3196        None,
3197        &text.rows,
3198        &protected_purpose_paths,
3199        control,
3200        symbol_limits,
3201    )?;
3202    let scan_policy = RootScanPolicy::discover(&plan.root, &plan.scan_options, control)
3203        .map_err(|source| source_inspection_error(&plan.root, source))?;
3204    let graph = graph_projection::stage_full_repository_graph_with_limit(
3205        store,
3206        &plan.root,
3207        base_generation,
3208        &nodes,
3209        &scan_policy,
3210        &symbols,
3211        control,
3212        MAX_PUBLICATION_STAGING_BYTES.saturating_sub(retained_before_symbols),
3213    )?;
3214    graph.account_source_recovery(&mut symbols.report)?;
3215    let structural_summaries = stage_structural_summaries_for_nodes_controlled(
3216        store,
3217        &nodes,
3218        &text.rows,
3219        Some(&symbols),
3220        &protected_purpose_paths,
3221        symbol_options.effective_workers(),
3222        control,
3223    )?;
3224    enforce_publication_staging_budget(
3225        retained_before_symbols
3226            .saturating_add(symbols.retained_bytes)
3227            .saturating_add(graph.retained_bytes())
3228            .saturating_add(structural_summaries.retained_bytes),
3229    )?;
3230    Ok(IndexPublicationBatch {
3231        base_generation,
3232        contract_fingerprint,
3233        root: plan.root.clone(),
3234        nodes: NodePublicationBatch::Full { nodes },
3235        purpose_import,
3236        text_paths,
3237        text,
3238        content_classifications,
3239        symbols,
3240        graph,
3241        structural_summaries,
3242    })
3243}
3244
3245/// Return paths whose reviewed or built-in purpose must suppress generated suggestions.
3246fn protected_purpose_paths(
3247    nodes: &[Node],
3248    purpose_import: Option<&PurposeImportSnapshot>,
3249) -> HashSet<String> {
3250    let indexed_paths = nodes
3251        .iter()
3252        .map(|node| node.path.as_str())
3253        .collect::<HashSet<_>>();
3254    let mut protected = BUILTIN_PROJECTATLAS_PURPOSES
3255        .iter()
3256        .filter(|(path, _purpose)| indexed_paths.contains(*path))
3257        .map(|(path, _purpose)| (*path).to_string())
3258        .collect::<HashSet<_>>();
3259    if let Some(snapshot) = purpose_import {
3260        protected.extend(
3261            snapshot
3262                .records
3263                .iter()
3264                .filter(|record| indexed_paths.contains(record.path.as_str()))
3265                .map(|record| record.path.clone()),
3266        );
3267    }
3268    protected
3269}
3270
3271/// Count retained node string bytes for one bounded in-memory publication batch.
3272fn staged_node_bytes(nodes: &[Node]) -> u64 {
3273    nodes.iter().fold(0_u64, |bytes, node| {
3274        bytes
3275            .saturating_add(node.path.len() as u64)
3276            .saturating_add(
3277                node.parent_path
3278                    .as_ref()
3279                    .map_or(0, |value| value.len() as u64),
3280            )
3281            .saturating_add(
3282                node.extension
3283                    .as_ref()
3284                    .map_or(0, |value| value.len() as u64),
3285            )
3286            .saturating_add(node.language.as_ref().map_or(0, |value| value.len() as u64))
3287            .saturating_add(
3288                node.content_hash
3289                    .as_ref()
3290                    .map_or(0, |value| value.len() as u64),
3291            )
3292    })
3293}
3294
3295/// Count retained persisted-text strings for one staged batch.
3296fn staged_text_bytes(text: &TextIndexRefresh) -> u64 {
3297    text.rows.iter().fold(0_u64, |bytes, row| {
3298        let bytes = bytes.saturating_add(row.path.len() as u64);
3299        row.text.as_ref().map_or(bytes, |text| {
3300            bytes
3301                .saturating_add(text.path.len() as u64)
3302                .saturating_add(
3303                    text.content_hash
3304                        .as_ref()
3305                        .map_or(0, |value| value.len() as u64),
3306                )
3307                .saturating_add(text.content.len() as u64)
3308        })
3309    })
3310}
3311
3312/// Derive one registry-owned or bounded-text fallback role per staged file.
3313fn stage_file_content_classifications(
3314    nodes: &[Node],
3315    text_rows: &[TextIndexRow],
3316) -> Vec<FileContentClassification> {
3317    let languages = nodes
3318        .iter()
3319        .filter(|node| node.kind == NodeKind::File)
3320        .map(|node| (node.path.as_str(), node.language.as_deref()))
3321        .collect::<HashMap<_, _>>();
3322    text_rows
3323        .iter()
3324        .map(|row| FileContentClassification {
3325            path: row.path.clone(),
3326            classification: content_classification(
3327                languages.get(row.path.as_str()).copied().flatten(),
3328                row.reason == TextIndexSkipReason::Indexed,
3329            ),
3330        })
3331        .collect()
3332}
3333
3334/// Count retained classification paths and stable enum spellings.
3335fn staged_file_content_classification_bytes(rows: &[FileContentClassification]) -> u64 {
3336    rows.iter().fold(0_u64, |bytes, row| {
3337        bytes
3338            .saturating_add(row.path.len() as u64)
3339            .saturating_add(row.classification.as_str().len() as u64)
3340    })
3341}
3342
3343/// Count retained legacy-purpose strings for one staged batch.
3344fn staged_purpose_bytes(purpose_import: Option<&PurposeImportSnapshot>) -> u64 {
3345    purpose_import.map_or(0, |snapshot| {
3346        snapshot
3347            .records
3348            .iter()
3349            .fold(snapshot.fingerprint.len() as u64, |bytes, record| {
3350                bytes
3351                    .saturating_add(record.path.len() as u64)
3352                    .saturating_add(record.summary.len() as u64)
3353            })
3354    })
3355}
3356
3357/// Count retained strings duplicated into one publication batch.
3358fn staged_string_bytes(values: &[String]) -> u64 {
3359    values.iter().fold(0_u64, |bytes, value| {
3360        bytes.saturating_add(value.len() as u64)
3361    })
3362}
3363
3364/// Count the selected root and derivation identity retained by one batch.
3365fn staged_publication_identity_bytes(root: &Path, contract_fingerprint: &str) -> u64 {
3366    (root.as_os_str().as_encoded_bytes().len() as u64)
3367        .saturating_add(contract_fingerprint.len() as u64)
3368}
3369
3370/// Restrict parser output to the remaining aggregate publication-staging budget.
3371fn symbol_limits_with_remaining_staging_bytes(
3372    retained_bytes: u64,
3373) -> Result<SymbolPublicationLimits, CliError> {
3374    enforce_publication_staging_budget(retained_bytes)?;
3375    Ok(SymbolPublicationLimits {
3376        output_bytes: SymbolPublicationLimits::STANDARD
3377            .output_bytes
3378            .min(MAX_PUBLICATION_STAGING_BYTES.saturating_sub(retained_bytes)),
3379        ..SymbolPublicationLimits::STANDARD
3380    })
3381}
3382
3383/// Fail before writer acquisition when retained publication state exceeds its budget.
3384fn enforce_publication_staging_budget(retained_bytes: u64) -> Result<(), CliError> {
3385    if retained_bytes > MAX_PUBLICATION_STAGING_BYTES {
3386        return Err(IndexWorkFailure::resource_limit(
3387            IndexWorkStage::Publication,
3388            IndexWorkResource::OutputBytes,
3389            MAX_PUBLICATION_STAGING_BYTES,
3390            retained_bytes,
3391        )
3392        .into());
3393    }
3394    Ok(())
3395}
3396
3397/// Build the complete expected source state after one normalized incremental delta.
3398fn expected_nodes_after_incremental(
3399    baseline_nodes: Vec<Node>,
3400    changed_nodes: &[Node],
3401    absent_paths: &[String],
3402) -> Vec<Node> {
3403    let absent_paths = absent_paths
3404        .iter()
3405        .map(String::as_str)
3406        .filter(|path| !matches!(*path, "" | "."))
3407        .collect::<HashSet<_>>();
3408    let mut expected = baseline_nodes
3409        .into_iter()
3410        .filter(|node| !repository_path_is_absent(&node.path, &absent_paths))
3411        .map(|node| (node.path.clone(), node))
3412        .collect::<BTreeMap<_, _>>();
3413    for node in changed_nodes {
3414        expected.insert(node.path.clone(), node.clone());
3415    }
3416    expected.into_values().collect()
3417}
3418
3419/// Match an exact absent repository key or one of its slash-delimited ancestors.
3420fn repository_path_is_absent(path: &str, absent_paths: &HashSet<&str>) -> bool {
3421    absent_paths.contains(path)
3422        || path
3423            .match_indices('/')
3424            .any(|(separator, _)| absent_paths.contains(&path[..separator]))
3425}
3426
3427/// Apply one fully prepared index batch in a short generation-checked transaction.
3428fn publish_index_batch(
3429    store: &mut AtlasStore,
3430    batch: IndexPublicationBatch,
3431    control: &IndexWorkControl,
3432) -> Result<IndexPublicationOutcome, CliError> {
3433    control.check(IndexWorkStage::Publication)?;
3434    let IndexPublicationBatch {
3435        base_generation,
3436        contract_fingerprint,
3437        root,
3438        nodes,
3439        purpose_import,
3440        text_paths,
3441        text,
3442        content_classifications,
3443        mut symbols,
3444        graph,
3445        structural_summaries,
3446    } = batch;
3447    graph.revalidate_document_targets(&root)?;
3448    let mut publication =
3449        store.begin_index_publication_from(&contract_fingerprint, base_generation)?;
3450    publication.set_project_root(&root)?;
3451    let indexed_nodes = match nodes {
3452        NodePublicationBatch::Full { nodes } => {
3453            publication.begin_scan_replacement()?;
3454            for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
3455                control.check(IndexWorkStage::Publication)?;
3456                publication.upsert_scan_node_batch(batch)?;
3457            }
3458            apply_file_content_classification_stage(
3459                &mut publication,
3460                &content_classifications,
3461                control,
3462            )?;
3463            control.check(IndexWorkStage::Publication)?;
3464            publication.finish_scan_replacement()?;
3465            nodes
3466        }
3467        NodePublicationBatch::Incremental {
3468            nodes,
3469            absent_paths,
3470            expected_nodes: _,
3471        } => {
3472            for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
3473                control.check(IndexWorkStage::Publication)?;
3474                publication.upsert_scan_node_batch(batch)?;
3475            }
3476            for batch in absent_paths.chunks(PUBLICATION_PATH_BATCH_SIZE) {
3477                control.check(IndexWorkStage::Publication)?;
3478                publication.mark_paths_absent(batch)?;
3479            }
3480            apply_file_content_classification_stage(
3481                &mut publication,
3482                &content_classifications,
3483                control,
3484            )?;
3485            nodes
3486        }
3487    };
3488    seed_builtin_projectatlas_purposes(&publication, &indexed_nodes)?;
3489    apply_text_index_stage(&mut publication, &text_paths, &text, control)?;
3490    let purpose_import = purpose_import.map_or_else(
3491        || Ok(PurposeImportReport::default()),
3492        |snapshot| apply_purpose_import_snapshot(&publication, &indexed_nodes, &snapshot, control),
3493    )?;
3494    apply_symbol_build_stage(&mut publication, &mut symbols, control)?;
3495    graph.apply(&mut publication, control)?;
3496    apply_structural_summary_stage(&mut publication, &structural_summaries, control)?;
3497    complete_index_publication(publication, control)?;
3498    Ok(IndexPublicationOutcome {
3499        purpose_import,
3500        text_index: text.report,
3501        structural_summaries: structural_summaries.report,
3502        symbols: symbols.report,
3503    })
3504}
3505
3506/// Apply staged file roles inside the parent generation transaction.
3507fn apply_file_content_classification_stage(
3508    publication: &mut IndexPublicationGuard<'_>,
3509    rows: &[FileContentClassification],
3510    control: &IndexWorkControl,
3511) -> Result<(), CliError> {
3512    for batch in rows.chunks(MAX_FILE_CONTENT_CLASSIFICATION_PATHS) {
3513        control.check(IndexWorkStage::Publication)?;
3514        publication.upsert_file_content_classification_batch(batch)?;
3515    }
3516    Ok(())
3517}
3518
3519/// Apply staged legacy-purpose rows without overwriting current reviewed intent.
3520fn apply_purpose_import_snapshot(
3521    store: &AtlasStore,
3522    nodes: &[Node],
3523    snapshot: &PurposeImportSnapshot,
3524    control: &IndexWorkControl,
3525) -> Result<PurposeImportReport, CliError> {
3526    let indexed_paths = nodes
3527        .iter()
3528        .map(|node| node.path.as_str())
3529        .collect::<HashSet<_>>();
3530    let mut report = PurposeImportReport::default();
3531    for record in &snapshot.records {
3532        control.check(IndexWorkStage::Publication)?;
3533        if !indexed_paths.contains(record.path.as_str()) {
3534            report.skipped_stale += 1;
3535            continue;
3536        }
3537        let Some(indexed) = store.load_node_by_path(&record.path)? else {
3538            report.skipped_stale += 1;
3539            continue;
3540        };
3541        if matches!(
3542            indexed.purpose.status,
3543            PurposeStatus::Approved | PurposeStatus::Stale
3544        ) {
3545            report.skipped_existing += 1;
3546            continue;
3547        }
3548        store.set_purpose(&record.path, &record.summary, PurposeSource::Imported)?;
3549        report.imported += 1;
3550    }
3551    Ok(report)
3552}
3553
3554/// Execute the full scan/index/symbol pipeline for a resolved project plan.
3555#[cfg(test)]
3556pub(crate) fn run_scan_pipeline(
3557    store: &mut AtlasStore,
3558    plan: &ScanRuntimePlan,
3559    symbol_options: &SymbolBuildOptions,
3560) -> Result<ScanReport, CliError> {
3561    let control = index_work_control(symbol_options);
3562    run_scan_pipeline_controlled(store, plan, symbol_options, &control)
3563}
3564
3565/// Execute the full pipeline under one cancellation and resource boundary.
3566pub(crate) fn run_scan_pipeline_controlled(
3567    store: &mut AtlasStore,
3568    plan: &ScanRuntimePlan,
3569    symbol_options: &SymbolBuildOptions,
3570    control: &IndexWorkControl,
3571) -> Result<ScanReport, CliError> {
3572    let bounded_control = bounded_index_work_control(control);
3573    let control = &bounded_control;
3574    control.check(IndexWorkStage::Publication)?;
3575    store.probe_index_publication_writer()?;
3576    let batch = stage_full_index_publication(store, plan, symbol_options, false, true, control)?;
3577    revalidate_staged_publication_inputs_with_purpose_snapshot(
3578        plan,
3579        batch.nodes.expected_nodes(),
3580        batch.purpose_import.as_ref(),
3581        control,
3582    )?;
3583    let outcome = publish_index_batch(store, batch, control)?;
3584    let overview = store.overview()?;
3585    Ok(ScanReport {
3586        overview,
3587        purpose_import: outcome.purpose_import,
3588        text_index: outcome.text_index,
3589        structural_summaries: outcome.structural_summaries,
3590        symbols: outcome.symbols,
3591    })
3592}
3593
3594/// Reconcile a copied worktree baseline through exact no-op, incremental, or full refresh.
3595pub(crate) fn reconcile_hydrated_index_controlled(
3596    store: &mut AtlasStore,
3597    plan: &ScanRuntimePlan,
3598    symbol_options: &SymbolBuildOptions,
3599    control: &IndexWorkControl,
3600) -> Result<(ScanReport, bool), CliError> {
3601    if !publication_contract_matches(store, plan)? {
3602        return run_scan_pipeline_controlled(store, plan, symbol_options, control)
3603            .map(|report| (report, false));
3604    }
3605    let assessment =
3606        detect_index_freshness_controlled(store, plan, ScanLimits::default(), control)?;
3607    let (refresh, source_unchanged) = match assessment.delta {
3608        None => (empty_index_refresh_report(plan.text_options), true),
3609        Some(delta) if delta.report.scope == IndexRefreshScope::Incremental => {
3610            let changes = WatchChangeSet {
3611                requires_full_scan: false,
3612                document_paths: delta.paths.clone(),
3613                paths: delta.paths,
3614            };
3615            let refresh = match refresh_index_for_changes_from_source_controlled(
3616                store,
3617                plan,
3618                &changes,
3619                symbol_options,
3620                Some(assessment.source),
3621                control,
3622            ) {
3623                Ok(refresh) => refresh,
3624                Err(CliError::RefreshRequired(report))
3625                    if report.reason == IndexRefreshReason::DependencyClosureLimit =>
3626                {
3627                    refresh_index_controlled(store, plan, symbol_options, control)?
3628                }
3629                Err(error) => return Err(error),
3630            };
3631            (refresh, false)
3632        }
3633        Some(_delta) => (
3634            refresh_index_controlled(store, plan, symbol_options, control)?,
3635            false,
3636        ),
3637    };
3638    Ok((
3639        ScanReport {
3640            overview: store.overview()?,
3641            purpose_import: PurposeImportReport::default(),
3642            text_index: refresh.text_index,
3643            structural_summaries: refresh.structural_summaries,
3644            symbols: refresh.symbols,
3645        },
3646        source_unchanged,
3647    ))
3648}
3649
3650/// Rebuild symbol projections while keeping incomplete work non-queryable.
3651#[cfg(test)]
3652pub(crate) fn run_symbol_build_pipeline(
3653    store: &mut AtlasStore,
3654    plan: &ScanRuntimePlan,
3655    symbol_options: &SymbolBuildOptions,
3656    previous_hashes: Option<&HashMap<String, String>>,
3657) -> Result<SymbolBuildReport, CliError> {
3658    let control = index_work_control(symbol_options);
3659    run_symbol_build_pipeline_controlled(store, plan, symbol_options, previous_hashes, &control)
3660}
3661
3662/// Rebuild symbol projections under one cancellation and publication boundary.
3663pub(crate) fn run_symbol_build_pipeline_controlled(
3664    store: &mut AtlasStore,
3665    plan: &ScanRuntimePlan,
3666    symbol_options: &SymbolBuildOptions,
3667    previous_hashes: Option<&HashMap<String, String>>,
3668    control: &IndexWorkControl,
3669) -> Result<SymbolBuildReport, CliError> {
3670    let bounded_control = bounded_index_work_control(control);
3671    let control = &bounded_control;
3672    control.check(IndexWorkStage::SymbolParsing)?;
3673    verify_index_project_root(store, &plan.root)?;
3674    verify_index_publication(store, plan)?;
3675    control.check(IndexWorkStage::Publication)?;
3676    store.probe_index_publication_writer()?;
3677    let base_generation = publication_base_generation(store)?;
3678    let mut nodes = Vec::new();
3679    store.visit_source_nodes_controlled(control, |node| {
3680        nodes.push(node);
3681        Ok(())
3682    })?;
3683    let contract_fingerprint = plan.publication_contract_fingerprint();
3684    let retained_before_symbols =
3685        staged_publication_identity_bytes(&plan.root, &contract_fingerprint)
3686            .saturating_add(staged_node_bytes(&nodes));
3687    let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
3688    let mut staged = stage_symbols_for_nodes_with_limits(
3689        store,
3690        &plan.root,
3691        #[cfg(feature = "optional-parser-supervisor")]
3692        &plan.optional_parser_selection,
3693        &nodes,
3694        symbol_options,
3695        previous_hashes,
3696        None,
3697        &[],
3698        &HashSet::new(),
3699        control,
3700        symbol_limits,
3701    )?;
3702    let scan_policy = RootScanPolicy::discover(&plan.root, &plan.scan_options, control)
3703        .map_err(|source| source_inspection_error(&plan.root, source))?;
3704    let graph = graph_projection::stage_full_repository_graph_with_limit(
3705        store,
3706        &plan.root,
3707        base_generation,
3708        &nodes,
3709        &scan_policy,
3710        &staged,
3711        control,
3712        MAX_PUBLICATION_STAGING_BYTES.saturating_sub(retained_before_symbols),
3713    )?;
3714    graph.account_source_recovery(&mut staged.report)?;
3715    enforce_publication_staging_budget(
3716        retained_before_symbols
3717            .saturating_add(staged.retained_bytes)
3718            .saturating_add(graph.retained_bytes()),
3719    )?;
3720    revalidate_staged_publication_inputs_controlled(plan, &nodes, None, control)?;
3721    control.check(IndexWorkStage::Publication)?;
3722    let mut publication =
3723        store.begin_index_projection_refresh_from(&contract_fingerprint, base_generation)?;
3724    apply_symbol_build_stage(&mut publication, &mut staged, control)?;
3725    graph.apply(&mut publication, control)?;
3726    complete_index_publication(publication, control)?;
3727    Ok(staged.report)
3728}
3729
3730/// One optional telemetry identity owned by a CLI invocation or MCP process.
3731#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3732pub(crate) struct UsageRuntimeInstance {
3733    /// Opaque runtime-owned identity persisted with usage events.
3734    id: UsageInstanceId,
3735    /// Adapter lifecycle that owns sealing this identity.
3736    owner: UsageInstanceOwner,
3737}
3738
3739impl UsageRuntimeInstance {
3740    /// Create an opaque runtime identity when operating-system entropy is available.
3741    #[must_use]
3742    pub(crate) fn new(owner: UsageInstanceOwner) -> Option<Self> {
3743        let mut bytes = [0u8; 16];
3744        getrandom::fill(&mut bytes).ok()?;
3745        UsageInstanceId::from_bytes(bytes)
3746            .ok()
3747            .map(|id| Self { id, owner })
3748    }
3749
3750    /// Record one event using the lifecycle implied by this runtime owner.
3751    fn record(
3752        self,
3753        store: &AtlasStore,
3754        event: &projectatlas_core::telemetry::UsageEvent,
3755    ) -> Result<(), CliError> {
3756        store.record_usage_for_instance(
3757            self.id,
3758            self.owner,
3759            event,
3760            matches!(self.owner, UsageInstanceOwner::CliInvocation),
3761        )?;
3762        Ok(())
3763    }
3764
3765    /// Record one event under an exact worktree origin.
3766    pub(crate) fn record_for_worktree(
3767        self,
3768        store: &AtlasStore,
3769        registration_id: i64,
3770        event: &projectatlas_core::telemetry::UsageEvent,
3771    ) -> Result<(), CliError> {
3772        store.record_usage_for_worktree_instance(
3773            self.id,
3774            self.owner,
3775            registration_id,
3776            event,
3777            false,
3778        )?;
3779        Ok(())
3780    }
3781
3782    /// Seal this runtime instance in one selected project database.
3783    pub(crate) fn seal(self, store: &AtlasStore) -> Result<(), CliError> {
3784        store.seal_usage_instance(self.id)?;
3785        Ok(())
3786    }
3787}
3788
3789/// Record a usage event from a fast baseline estimate and actual atlas payload.
3790pub(crate) fn record_usage_estimate(
3791    store: &AtlasStore,
3792    usage_instance: Option<UsageRuntimeInstance>,
3793    session: &str,
3794    command: &str,
3795    path: Option<String>,
3796    query: Option<String>,
3797    estimated_without_projectatlas: usize,
3798    projectatlas_text: &str,
3799) -> Result<(), CliError> {
3800    record_usage_estimate_with_context(
3801        store,
3802        usage_instance,
3803        session,
3804        command,
3805        path,
3806        query,
3807        estimated_without_projectatlas,
3808        projectatlas_text,
3809        TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
3810        TOKEN_BASELINE_SELECTED_CANDIDATES,
3811        TOKEN_CONFIDENCE_INFERRED,
3812    )
3813}
3814
3815/// Record a usage event from a fast baseline estimate and explicit baseline semantics.
3816#[allow(clippy::too_many_arguments)]
3817pub(crate) fn record_usage_estimate_with_context(
3818    store: &AtlasStore,
3819    usage_instance: Option<UsageRuntimeInstance>,
3820    session: &str,
3821    command: &str,
3822    path: Option<String>,
3823    query: Option<String>,
3824    estimated_without_projectatlas: usize,
3825    projectatlas_text: &str,
3826    token_savings_bucket: &str,
3827    baseline_kind: &str,
3828    confidence: &str,
3829) -> Result<(), CliError> {
3830    let Some(usage_instance) = usage_instance.filter(|_| !telemetry_disabled()) else {
3831        return Ok(());
3832    };
3833    store.finish_index_read_snapshot()?;
3834    usage_instance.record(
3835        store,
3836        &usage_from_estimates_with_context(
3837            session,
3838            command,
3839            path,
3840            query,
3841            estimated_without_projectatlas,
3842            estimate_tokens(projectatlas_text),
3843            token_savings_bucket,
3844            baseline_kind,
3845            confidence,
3846        ),
3847    )?;
3848    Ok(())
3849}
3850
3851/// Record a broad directory-walk avoidance estimate.
3852pub(crate) fn record_directory_walk_usage_estimate(
3853    store: &AtlasStore,
3854    usage_instance: Option<UsageRuntimeInstance>,
3855    session: &str,
3856    command: &str,
3857    path: Option<String>,
3858    query: Option<String>,
3859    estimated_without_projectatlas: usize,
3860    projectatlas_text: &str,
3861) -> Result<(), CliError> {
3862    record_usage_estimate_with_context(
3863        store,
3864        usage_instance,
3865        session,
3866        command,
3867        path,
3868        query,
3869        estimated_without_projectatlas,
3870        projectatlas_text,
3871        TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
3872        TOKEN_BASELINE_DIRECTORY_WALK,
3873        TOKEN_CONFIDENCE_POLICY_ESTIMATE,
3874    )
3875}
3876
3877/// Record a usage event from baseline and emitted text unless telemetry is disabled.
3878pub(crate) fn record_usage_text(
3879    store: &AtlasStore,
3880    usage_instance: Option<UsageRuntimeInstance>,
3881    session: &str,
3882    command: &str,
3883    path: Option<String>,
3884    query: Option<String>,
3885    baseline_text: &str,
3886    projectatlas_text: &str,
3887) -> Result<(), CliError> {
3888    let Some(usage_instance) = usage_instance.filter(|_| !telemetry_disabled()) else {
3889        return Ok(());
3890    };
3891    store.finish_index_read_snapshot()?;
3892    usage_instance.record(
3893        store,
3894        &usage_from_text(
3895            session,
3896            command,
3897            path,
3898            query,
3899            baseline_text,
3900            projectatlas_text,
3901        ),
3902    )?;
3903    Ok(())
3904}
3905
3906/// Return whether telemetry writes are disabled for read-only review contexts.
3907pub(crate) fn telemetry_disabled() -> bool {
3908    truthy_env("PROJECTATLAS_NO_TELEMETRY")
3909}
3910
3911/// Estimate broad source tokens represented by indexed files with SQL aggregates.
3912pub(crate) fn estimated_source_tokens_for_indexed_files(
3913    store: &AtlasStore,
3914    folder: Option<&str>,
3915    file_pattern: Option<&str>,
3916) -> Result<usize, CliError> {
3917    let matcher = FilePathMatcher::new(file_pattern)?;
3918    let mut total = 0usize;
3919    store.visit_file_token_estimates(folder, |path, size_bytes| {
3920        if matcher.is_match(&path) {
3921            total =
3922                total.saturating_add(estimated_source_tokens_for_file_metadata(&path, size_bytes));
3923        }
3924        Ok(true)
3925    })?;
3926    Ok(total)
3927}
3928
3929/// Estimate source tokens for one indexed file without reading it.
3930pub(crate) fn estimated_source_tokens_for_file_node(node: &Node) -> usize {
3931    estimated_source_tokens_for_file_metadata(&node.path, node.size_bytes)
3932}
3933
3934/// Estimate source tokens for persisted file metadata.
3935pub(crate) fn estimated_source_tokens_for_file_metadata(
3936    path: &str,
3937    size_bytes: Option<u64>,
3938) -> usize {
3939    size_bytes.map_or_else(|| estimate_tokens(path), byte_size_to_tokens)
3940}
3941
3942/// Estimate source tokens from a byte count with the shared token heuristic.
3943pub(crate) fn byte_size_to_tokens(bytes: u64) -> usize {
3944    let token_estimate = bytes.div_ceil(4);
3945    usize::try_from(token_estimate).unwrap_or(usize::MAX)
3946}
3947
3948/// Estimate source tokens from a searched byte count.
3949pub(crate) fn byte_count_to_tokens(bytes: usize) -> usize {
3950    if bytes == 0 { 0 } else { bytes.div_ceil(4) }
3951}
3952
3953/// Load ranked folder nodes with concise reasons.
3954pub(crate) fn ranked_folder_nodes_with_reasons(
3955    store: &AtlasStore,
3956    query: &str,
3957    limit: usize,
3958) -> Result<Vec<projectatlas_core::RankedNode>, CliError> {
3959    Ok(load_ranked_folder_nodes_with_reasons(store, query, limit)?)
3960}
3961
3962/// Load ranked file nodes with concise reasons.
3963pub(crate) fn ranked_file_nodes_with_reasons(
3964    store: &AtlasStore,
3965    query: &str,
3966    folder: Option<&str>,
3967    file_pattern: Option<&str>,
3968    limit: usize,
3969    include_content: bool,
3970) -> Result<Vec<projectatlas_core::RankedNode>, CliError> {
3971    Ok(load_ranked_file_nodes_with_reasons(
3972        store,
3973        query,
3974        folder,
3975        file_pattern,
3976        limit,
3977        include_content,
3978    )?)
3979}
3980
3981/// Load ranked files with persisted classification and optional explicit selection.
3982pub(crate) fn classified_ranked_file_nodes_with_reasons(
3983    store: &AtlasStore,
3984    query: &str,
3985    folder: Option<&str>,
3986    file_pattern: Option<&str>,
3987    limit: usize,
3988    include_content: bool,
3989    content_selection: ContentSelection,
3990) -> Result<Vec<ClassifiedRankedNode>, CliError> {
3991    Ok(load_classified_ranked_file_nodes_with_reasons_service(
3992        store,
3993        query,
3994        folder,
3995        file_pattern,
3996        limit,
3997        include_content,
3998        content_selection,
3999    )?)
4000}
4001
4002/// Build a next-step report under one optional classified-content selection.
4003pub(crate) fn next_step_report_with_selection(
4004    store: &AtlasStore,
4005    query: &str,
4006    limit: Option<usize>,
4007    content_selection: ContentSelection,
4008) -> Result<NextStepReport, CliError> {
4009    Ok(build_next_report_with_selection_service(
4010        store,
4011        query,
4012        limit,
4013        content_selection,
4014    )?)
4015}
4016
4017/// Build the flattened agent-facing next-step payload.
4018pub(crate) fn next_step_report_payload(report: &NextStepReport) -> Value {
4019    json!({
4020        "query": &report.query,
4021        "folders": render_ranked_node_rows("folders", &report.folders),
4022        "files": render_classified_ranked_file_rows(&report.files),
4023        "suggestions": &report.suggestions,
4024    })
4025}
4026
4027/// Preserve existing ranked file rows while adding their persisted classification.
4028pub(crate) fn render_classified_ranked_file_rows(files: &[ClassifiedRankedNode]) -> Vec<Value> {
4029    files
4030        .iter()
4031        .map(|file| {
4032            let mut row = render_ranked_node_rows("files", std::slice::from_ref(&file.ranked))
4033                .into_iter()
4034                .next()
4035                .unwrap_or_else(|| json!({}));
4036            if let Some(object) = row.as_object_mut() {
4037                object.insert("classification".to_string(), json!(file.classification));
4038            }
4039            row
4040        })
4041        .collect()
4042}
4043
4044/// Preserve existing symbol rows while adding their owning file classification.
4045pub(crate) fn render_classified_symbol_rows(symbols: &[ClassifiedSymbol]) -> Vec<Value> {
4046    symbols
4047        .iter()
4048        .map(|classified| {
4049            let mut row = render_symbol_rows(std::slice::from_ref(&classified.symbol))
4050                .into_iter()
4051                .next()
4052                .unwrap_or_else(|| json!({}));
4053            if let Some(object) = row.as_object_mut() {
4054                object.insert(
4055                    "classification".to_string(),
4056                    json!(classified.classification),
4057                );
4058            }
4059            row
4060        })
4061        .collect()
4062}
4063
4064/// Agent-facing purpose curation queue with bounded health metadata.
4065#[derive(Debug, Serialize)]
4066#[allow(
4067    clippy::struct_excessive_bools,
4068    reason = "serialized queue paging and scope fields are independent wire facts"
4069)]
4070pub(crate) struct PurposeCurationPage {
4071    /// Selected project identity bound into every work key.
4072    pub(crate) project_instance_id: String,
4073    /// Active generation bound into every work key and state token.
4074    pub(crate) active_generation: u64,
4075    /// Host-owned task label for this bounded batch.
4076    pub(crate) task: String,
4077    /// Deterministic identity for the complete returned batch.
4078    pub(crate) work_key: String,
4079    /// Whether this page contains work a host or main agent can process.
4080    pub(crate) actionable: bool,
4081    /// Purpose policy scope; automatic handoffs are always low scope.
4082    pub(crate) curation_scope: &'static str,
4083    /// Findings after filters are applied.
4084    pub(crate) total: usize,
4085    /// Findings before filters are applied, after resolved findings are removed.
4086    pub(crate) unfiltered_total: usize,
4087    /// Findings returned in this page.
4088    pub(crate) returned: usize,
4089    /// Pagination start index used for this page.
4090    pub(crate) start_index: usize,
4091    /// Maximum findings requested for this page.
4092    pub(crate) limit: usize,
4093    /// Maximum allowed page size.
4094    pub(crate) max_limit: usize,
4095    /// Next start index when more rows are available.
4096    pub(crate) next_start_index: Option<usize>,
4097    /// Whether more rows are available.
4098    pub(crate) truncated: bool,
4099    /// Whether the queue is restricted to source-relevant paths.
4100    pub(crate) source_only: bool,
4101    /// Folder scope included in the queue.
4102    pub(crate) folder_scope: String,
4103    /// File scope included in the queue.
4104    pub(crate) file_scope: String,
4105    /// Applied category filter.
4106    pub(crate) category: String,
4107    /// Applied severity filter.
4108    pub(crate) severity: String,
4109    /// Applied path-prefix filter.
4110    pub(crate) path_prefix: String,
4111    /// Whether rows were intentionally omitted.
4112    pub(crate) summary_only: bool,
4113    /// Queue items that need agent inspection or approval.
4114    pub(crate) items: Vec<PurposeCurationItem>,
4115}
4116
4117/// One path that needs purpose curation.
4118#[derive(Debug, Serialize)]
4119pub(crate) struct PurposeCurationItem {
4120    /// Deterministic project/generation/task/path identity for duplicate coalescing.
4121    pub(crate) work_key: String,
4122    /// Opaque current-row token required for stale-safe conditional review.
4123    pub(crate) state_token: String,
4124    /// Finding severity.
4125    pub(crate) severity: String,
4126    /// Stable health finding id.
4127    pub(crate) id: String,
4128    /// Health finding category.
4129    pub(crate) category: String,
4130    /// Indexed repository-relative path.
4131    pub(crate) path: String,
4132    /// Related path for structural findings.
4133    pub(crate) related_path: String,
4134    /// Node kind when the path is still indexed.
4135    pub(crate) kind: String,
4136    /// Detected language for source files.
4137    pub(crate) language: String,
4138    /// Registry-owned content role when this item is a file; folders serialize `null` so TOON rows stay tabular.
4139    pub(crate) classification: Option<ContentClassification>,
4140    /// Current approved or suggested purpose text.
4141    pub(crate) purpose: String,
4142    /// Purpose lifecycle status.
4143    pub(crate) purpose_status: String,
4144    /// Purpose source.
4145    pub(crate) purpose_source: String,
4146    /// Whether an agent explicitly reviewed or set this purpose.
4147    pub(crate) purpose_agent_reviewed: bool,
4148    /// Priority for agent curation.
4149    pub(crate) review_priority: String,
4150    /// Stable reason explaining the priority.
4151    pub(crate) review_reason: String,
4152    /// Current deterministic content summary.
4153    pub(crate) content_summary: String,
4154    /// Recommended agent action.
4155    pub(crate) recommendation: String,
4156}
4157
4158/// One agent-reviewed purpose update requested by a batch review.
4159#[derive(Clone, Debug, Deserialize, Serialize)]
4160pub(crate) struct PurposeReviewRequest {
4161    /// Indexed repository-relative path.
4162    pub(crate) path: String,
4163    /// Agent-reviewed purpose one-liner. Required for generated suggestions.
4164    #[serde(default)]
4165    pub(crate) purpose: Option<String>,
4166    /// Confirm the currently stored non-generated purpose after inspection.
4167    #[serde(default)]
4168    pub(crate) confirm_existing: bool,
4169    /// Queue task copied from the selected purpose-curation batch.
4170    #[serde(default)]
4171    pub(crate) task: Option<String>,
4172    /// Queue item work key copied without modification.
4173    #[serde(default)]
4174    pub(crate) work_key: Option<String>,
4175    /// Queue item state token copied without modification.
4176    #[serde(default)]
4177    pub(crate) state_token: Option<String>,
4178}
4179
4180/// Batch purpose review result.
4181#[derive(Debug, Serialize)]
4182pub(crate) struct PurposeReviewReport {
4183    /// Whether the review changed the database.
4184    pub(crate) applied: bool,
4185    /// Number of requested review rows.
4186    pub(crate) total: usize,
4187    /// Number of rows changed when applied or that would change in dry-run.
4188    pub(crate) changed: usize,
4189    /// Number of rows skipped because they were already agent-reviewed with the same purpose.
4190    pub(crate) skipped: usize,
4191    /// Number of accepted, stale, or unavailable rows left unchanged.
4192    pub(crate) conflicts: usize,
4193    /// Number of rows that could not be reviewed.
4194    pub(crate) failed: usize,
4195    /// Per-path review details.
4196    pub(crate) items: Vec<PurposeReviewItem>,
4197}
4198
4199/// Per-path batch review result.
4200#[derive(Debug, Serialize)]
4201pub(crate) struct PurposeReviewItem {
4202    /// Indexed repository-relative path.
4203    pub(crate) path: String,
4204    /// Registry-owned content role when this item is a file; folders serialize `null` so TOON rows stay tabular.
4205    pub(crate) classification: Option<ContentClassification>,
4206    /// Action selected for this path.
4207    pub(crate) action: PurposeReviewAction,
4208    /// Current purpose lifecycle status.
4209    pub(crate) current_status: String,
4210    /// Current purpose source.
4211    pub(crate) current_source: String,
4212    /// Purpose that will be or was written.
4213    pub(crate) purpose: String,
4214    /// Validation or persistence error.
4215    pub(crate) error: String,
4216}
4217
4218/// Stable purpose-review action values.
4219#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
4220#[serde(rename_all = "kebab-case")]
4221pub(crate) enum PurposeReviewAction {
4222    /// The item failed validation.
4223    Error,
4224    /// The existing reviewed purpose already matches.
4225    Skip,
4226    /// The reviewed purpose was applied.
4227    Review,
4228    /// The reviewed purpose would be applied in preview mode.
4229    WouldReview,
4230    /// Project, generation, task, path, or row state changed after queue selection.
4231    Stale,
4232    /// The path now carries accepted authored intent and was not overwritten.
4233    Accepted,
4234    /// The selected path is no longer active in the index.
4235    Unavailable,
4236}
4237
4238/// Validate and optionally apply a batch of agent-reviewed purpose records.
4239pub(crate) fn review_purposes(
4240    store: &AtlasStore,
4241    requests: &[PurposeReviewRequest],
4242    apply: bool,
4243) -> Result<PurposeReviewReport, CliError> {
4244    validate_purpose_review_admission(requests)?;
4245    let has_conditional_fields = requests.iter().any(has_conditional_purpose_review_field);
4246    if apply
4247        && has_conditional_fields
4248        && !requests.iter().all(is_complete_conditional_purpose_review)
4249    {
4250        return Err(CliError::InvalidInput(
4251            "an applied purpose review batch must be entirely conditional or entirely explicit correction; conditional rows require task, work_key, state_token, and a reviewed purpose"
4252                .to_string(),
4253        ));
4254    }
4255
4256    // Explicit correction remains item-oriented. Preflight every row and the
4257    // complete report before the first write so an admission failure cannot
4258    // partially apply an otherwise valid batch.
4259    if apply && !has_conditional_fields {
4260        let mut preview = collect_purpose_reviews(store, requests, false)?;
4261        hydrate_purpose_review_classifications(store, &mut preview)?;
4262        validate_purpose_review_report(&preview)?;
4263    }
4264
4265    let mut report = if apply && has_conditional_fields {
4266        apply_conditional_purpose_reviews(store, requests)?
4267    } else {
4268        collect_purpose_reviews(store, requests, apply)?
4269    };
4270    hydrate_purpose_review_classifications(store, &mut report)?;
4271    validate_purpose_review_report(&report)?;
4272    Ok(report)
4273}
4274
4275/// Add registry-owned file roles without allowing purpose writes to alter them.
4276fn hydrate_purpose_review_classifications(
4277    store: &AtlasStore,
4278    report: &mut PurposeReviewReport,
4279) -> Result<(), CliError> {
4280    let paths = report
4281        .items
4282        .iter()
4283        .map(|item| item.path.clone())
4284        .collect::<Vec<_>>();
4285    let file_paths = store
4286        .load_nodes_by_paths(&paths)?
4287        .into_iter()
4288        .filter(|node| node.node.kind == NodeKind::File)
4289        .map(|node| node.node.path)
4290        .collect::<Vec<_>>();
4291    let classifications = store
4292        .file_content_classifications_for_paths(&file_paths)?
4293        .into_iter()
4294        .map(|row| (row.path, row.classification))
4295        .collect::<HashMap<_, _>>();
4296    for item in &mut report.items {
4297        item.classification = classifications.get(&item.path).copied();
4298    }
4299    Ok(())
4300}
4301
4302/// Enforce shared CLI/MCP purpose-review request limits before database work.
4303pub(crate) fn validate_purpose_review_admission(
4304    requests: &[PurposeReviewRequest],
4305) -> Result<(), CliError> {
4306    if requests.is_empty() {
4307        return Err(CliError::InvalidInput(
4308            "purpose review input must contain at least one item".to_string(),
4309        ));
4310    }
4311    if requests.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
4312        return Err(CliError::InvalidInput(format!(
4313            "purpose review input contains {} items; maximum is {}",
4314            requests.len(),
4315            MAX_PURPOSE_CURATION_BATCH_ROWS
4316        )));
4317    }
4318
4319    let mut aggregate_bytes = 0usize;
4320    for (index, request) in requests.iter().enumerate() {
4321        validate_purpose_review_field(index, "path", &request.path, MAX_PURPOSE_REVIEW_PATH_BYTES)?;
4322        aggregate_bytes = aggregate_bytes
4323            .checked_add(request.path.len())
4324            .ok_or_else(|| purpose_review_input_too_large(usize::MAX))?;
4325        for (name, value) in [
4326            ("purpose", request.purpose.as_deref()),
4327            ("task", request.task.as_deref()),
4328            ("work_key", request.work_key.as_deref()),
4329            ("state_token", request.state_token.as_deref()),
4330        ] {
4331            if let Some(value) = value {
4332                validate_purpose_review_field(index, name, value, MAX_PURPOSE_REVIEW_FIELD_BYTES)?;
4333                aggregate_bytes = aggregate_bytes
4334                    .checked_add(value.len())
4335                    .ok_or_else(|| purpose_review_input_too_large(usize::MAX))?;
4336            }
4337        }
4338        if aggregate_bytes > MAX_PURPOSE_REVIEW_INPUT_BYTES {
4339            return Err(purpose_review_input_too_large(aggregate_bytes));
4340        }
4341    }
4342    Ok(())
4343}
4344
4345/// Validate one caller-controlled purpose-review string before retaining output.
4346fn validate_purpose_review_field(
4347    index: usize,
4348    name: &str,
4349    value: &str,
4350    maximum: usize,
4351) -> Result<(), CliError> {
4352    if value.len() > maximum {
4353        return Err(CliError::InvalidInput(format!(
4354            "purpose review item {index} field {name} contains {} bytes; maximum is {maximum}",
4355            value.len()
4356        )));
4357    }
4358    Ok(())
4359}
4360
4361/// Build the stable aggregate-byte admission failure.
4362fn purpose_review_input_too_large(actual: usize) -> CliError {
4363    CliError::InvalidInput(format!(
4364        "purpose review input contains {actual} aggregate string bytes; maximum is {MAX_PURPOSE_REVIEW_INPUT_BYTES}"
4365    ))
4366}
4367
4368/// Review one admitted item-oriented batch while bounding retained report data.
4369fn collect_purpose_reviews(
4370    store: &AtlasStore,
4371    requests: &[PurposeReviewRequest],
4372    apply: bool,
4373) -> Result<PurposeReviewReport, CliError> {
4374    let mut items = Vec::with_capacity(requests.len());
4375    let mut retained_bytes = 0usize;
4376    for request in requests {
4377        let item = review_purpose_request(store, request, apply)?;
4378        retained_bytes = retained_bytes
4379            .checked_add(purpose_review_item_bytes(&item)?)
4380            .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4381        if retained_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
4382            return Err(purpose_review_report_too_large(retained_bytes));
4383        }
4384        items.push(item);
4385    }
4386    Ok(summarize_purpose_review(requests.len(), apply, items))
4387}
4388
4389/// Return retained string bytes for one report row after per-field admission.
4390fn purpose_review_item_bytes(item: &PurposeReviewItem) -> Result<usize, CliError> {
4391    let mut total = 0usize;
4392    for (name, value, maximum) in [
4393        ("path", item.path.as_str(), MAX_PURPOSE_REVIEW_PATH_BYTES),
4394        (
4395            "current_status",
4396            item.current_status.as_str(),
4397            MAX_PURPOSE_REVIEW_FIELD_BYTES,
4398        ),
4399        (
4400            "current_source",
4401            item.current_source.as_str(),
4402            MAX_PURPOSE_REVIEW_FIELD_BYTES,
4403        ),
4404        (
4405            "purpose",
4406            item.purpose.as_str(),
4407            MAX_PURPOSE_REVIEW_FIELD_BYTES,
4408        ),
4409        (
4410            PURPOSE_REVIEW_REPORT_ERROR_FIELD,
4411            item.error.as_str(),
4412            MAX_PURPOSE_REVIEW_FIELD_BYTES,
4413        ),
4414    ] {
4415        if value.len() > maximum {
4416            return Err(CliError::InvalidInput(format!(
4417                "purpose review report field {name} contains {} bytes; maximum is {maximum}",
4418                value.len()
4419            )));
4420        }
4421        total = total
4422            .checked_add(value.len())
4423            .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4424    }
4425    total = total
4426        .checked_add(item.classification.map_or(0, |value| value.as_str().len()))
4427        .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4428    Ok(total)
4429}
4430
4431/// Enforce exact supported adapter output caps for one completed report.
4432fn validate_purpose_review_report(report: &PurposeReviewReport) -> Result<(), CliError> {
4433    let json_bytes = serde_json::to_string_pretty(report)?
4434        .len()
4435        .checked_add(1)
4436        .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4437    if json_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
4438        return Err(purpose_review_report_too_large(json_bytes));
4439    }
4440    let toon_bytes = render_purpose_review_report(report).len();
4441    if toon_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
4442        return Err(purpose_review_report_too_large(toon_bytes));
4443    }
4444    Ok(())
4445}
4446
4447/// Build the stable purpose-review report/output limit failure.
4448fn purpose_review_report_too_large(actual: usize) -> CliError {
4449    CliError::InvalidInput(format!(
4450        "purpose review report contains {actual} bytes; maximum is {MAX_PURPOSE_REVIEW_REPORT_BYTES}"
4451    ))
4452}
4453
4454/// Apply one host-selected conditional batch with one database writer transaction.
4455fn apply_conditional_purpose_reviews(
4456    store: &AtlasStore,
4457    requests: &[PurposeReviewRequest],
4458) -> Result<PurposeReviewReport, CliError> {
4459    let prepared = requests
4460        .iter()
4461        .map(|request| {
4462            let path = validated_repo_node_key(Path::new(&request.path)).map_err(|source| {
4463                CliError::InvalidInput(format!(
4464                    "invalid purpose review path {:?}: {source}",
4465                    request.path
4466                ))
4467            })?;
4468            let purpose = request
4469                .purpose
4470                .as_deref()
4471                .map(str::trim)
4472                .filter(|value| !value.is_empty())
4473                .ok_or_else(|| {
4474                    CliError::InvalidInput(
4475                        "conditional purpose review requires an explicit reviewed purpose"
4476                            .to_string(),
4477                    )
4478                })?
4479                .to_string();
4480            let task = request.task.clone().ok_or_else(|| {
4481                CliError::InvalidInput(
4482                    "conditional purpose review requires task, work_key, and state_token together"
4483                        .to_string(),
4484                )
4485            })?;
4486            let work_key = request.work_key.clone().ok_or_else(|| {
4487                CliError::InvalidInput(
4488                    "conditional purpose review requires task, work_key, and state_token together"
4489                        .to_string(),
4490                )
4491            })?;
4492            let state_token = request.state_token.clone().ok_or_else(|| {
4493                CliError::InvalidInput(
4494                    "conditional purpose review requires task, work_key, and state_token together"
4495                        .to_string(),
4496                )
4497            })?;
4498            Ok((
4499                path.clone(),
4500                purpose.clone(),
4501                PurposeConditionalApplyRequest {
4502                    task,
4503                    path,
4504                    work_key,
4505                    state_token,
4506                    purpose,
4507                },
4508            ))
4509        })
4510        .collect::<Result<Vec<_>, CliError>>()?;
4511    let database_requests = prepared
4512        .iter()
4513        .map(|(_, _, request)| request.clone())
4514        .collect::<Vec<_>>();
4515    let results = store.conditionally_set_purposes(&database_requests)?;
4516    let items = prepared
4517        .into_iter()
4518        .zip(results)
4519        .map(|((path, purpose, _), result)| {
4520            debug_assert_eq!(path, result.path);
4521            PurposeReviewItem {
4522                path,
4523                classification: None,
4524                action: conditional_purpose_review_action(result.state, true),
4525                current_status: result
4526                    .current_purpose
4527                    .as_ref()
4528                    .map(|purpose| purpose.status.to_string())
4529                    .unwrap_or_default(),
4530                current_source: result
4531                    .current_purpose
4532                    .as_ref()
4533                    .map(|purpose| purpose.source.to_string())
4534                    .unwrap_or_default(),
4535                purpose,
4536                error: String::new(),
4537            }
4538        })
4539        .collect::<Vec<_>>();
4540    Ok(summarize_purpose_review(requests.len(), true, items))
4541}
4542
4543/// Return whether a row carries one complete queue-bound conditional review.
4544fn is_complete_conditional_purpose_review(request: &PurposeReviewRequest) -> bool {
4545    request.task.is_some()
4546        && request.work_key.is_some()
4547        && request.state_token.is_some()
4548        && request
4549            .purpose
4550            .as_deref()
4551            .is_some_and(|purpose| !purpose.trim().is_empty())
4552}
4553
4554/// Return whether a row attempts to use queue-bound conditional review.
4555fn has_conditional_purpose_review_field(request: &PurposeReviewRequest) -> bool {
4556    request.task.is_some() || request.work_key.is_some() || request.state_token.is_some()
4557}
4558
4559/// Aggregate stable batch counters from per-path review outcomes.
4560fn summarize_purpose_review(
4561    total: usize,
4562    applied: bool,
4563    items: Vec<PurposeReviewItem>,
4564) -> PurposeReviewReport {
4565    let changed = items
4566        .iter()
4567        .filter(|item| {
4568            matches!(
4569                item.action,
4570                PurposeReviewAction::Review | PurposeReviewAction::WouldReview
4571            )
4572        })
4573        .count();
4574    let skipped = items
4575        .iter()
4576        .filter(|item| {
4577            matches!(
4578                item.action,
4579                PurposeReviewAction::Skip | PurposeReviewAction::Accepted
4580            )
4581        })
4582        .count();
4583    let conflicts = items
4584        .iter()
4585        .filter(|item| {
4586            matches!(
4587                item.action,
4588                PurposeReviewAction::Stale
4589                    | PurposeReviewAction::Accepted
4590                    | PurposeReviewAction::Unavailable
4591            )
4592        })
4593        .count();
4594    let failed = items.iter().filter(|item| !item.error.is_empty()).count();
4595    PurposeReviewReport {
4596        applied,
4597        total,
4598        changed,
4599        skipped,
4600        conflicts,
4601        failed,
4602        items,
4603    }
4604}
4605
4606/// Validate and optionally apply one agent-reviewed purpose record.
4607fn review_purpose_request(
4608    store: &AtlasStore,
4609    request: &PurposeReviewRequest,
4610    apply: bool,
4611) -> Result<PurposeReviewItem, CliError> {
4612    let path = validated_repo_node_key(Path::new(&request.path)).map_err(|source| {
4613        CliError::InvalidInput(format!(
4614            "invalid purpose review path {:?}: {source}",
4615            request.path
4616        ))
4617    })?;
4618    let conditional = match (
4619        request.task.as_deref(),
4620        request.work_key.as_deref(),
4621        request.state_token.as_deref(),
4622    ) {
4623        (Some(task), Some(work_key), Some(state_token)) => Some((task, work_key, state_token)),
4624        (None, None, None) => None,
4625        _ => {
4626            return Ok(PurposeReviewItem {
4627                path,
4628                classification: None,
4629                action: PurposeReviewAction::Error,
4630                current_status: String::new(),
4631                current_source: String::new(),
4632                purpose: request.purpose.clone().unwrap_or_default(),
4633                error:
4634                    "conditional purpose review requires task, work_key, and state_token together"
4635                        .to_string(),
4636            });
4637        }
4638    };
4639    if let Some((task, work_key, state_token)) = conditional {
4640        let reviewed_purpose = request
4641            .purpose
4642            .as_deref()
4643            .map(str::trim)
4644            .filter(|value| !value.is_empty());
4645        let Some(reviewed_purpose) = reviewed_purpose else {
4646            return Ok(PurposeReviewItem {
4647                path,
4648                classification: None,
4649                action: PurposeReviewAction::Error,
4650                current_status: String::new(),
4651                current_source: String::new(),
4652                purpose: String::new(),
4653                error: "conditional purpose review requires an explicit reviewed purpose"
4654                    .to_string(),
4655            });
4656        };
4657        let state = if apply {
4658            store.conditionally_set_purpose(task, &path, work_key, state_token, reviewed_purpose)?
4659        } else {
4660            preview_conditional_purpose_review(store, task, &path, work_key, state_token)?
4661        };
4662        let current = store.load_node_by_path(&path)?;
4663        return Ok(PurposeReviewItem {
4664            path,
4665            classification: None,
4666            action: conditional_purpose_review_action(state, apply),
4667            current_status: current
4668                .as_ref()
4669                .map(|node| node.purpose.status.to_string())
4670                .unwrap_or_default(),
4671            current_source: current
4672                .as_ref()
4673                .map(|node| node.purpose.source.to_string())
4674                .unwrap_or_default(),
4675            purpose: reviewed_purpose.to_string(),
4676            error: String::new(),
4677        });
4678    }
4679    let Some(indexed) = store.load_node_by_path(&path)? else {
4680        return Ok(PurposeReviewItem {
4681            path,
4682            classification: None,
4683            action: PurposeReviewAction::Error,
4684            current_status: String::new(),
4685            current_source: String::new(),
4686            purpose: request.purpose.clone().unwrap_or_default(),
4687            error: "path is not indexed".to_string(),
4688        });
4689    };
4690    let current_status = indexed.purpose.status.to_string();
4691    let current_source = indexed.purpose.source.to_string();
4692    let current_purpose = indexed.purpose.purpose.clone().unwrap_or_default();
4693    let explicit_purpose = request
4694        .purpose
4695        .as_deref()
4696        .map(str::trim)
4697        .filter(|value| !value.is_empty());
4698    let Some(reviewed_purpose) = explicit_purpose.or_else(|| {
4699        request
4700            .confirm_existing
4701            .then_some(current_purpose.as_str())
4702            .filter(|value| !value.trim().is_empty())
4703    }) else {
4704        return Ok(PurposeReviewItem {
4705            path,
4706            classification: None,
4707            action: PurposeReviewAction::Error,
4708            current_status,
4709            current_source,
4710            purpose: String::new(),
4711            error: "provide a reviewed purpose or set confirm_existing=true".to_string(),
4712        });
4713    };
4714
4715    if request.confirm_existing
4716        && explicit_purpose.is_none()
4717        && (indexed.purpose.status == PurposeStatus::Suggested
4718            || indexed.purpose.source == PurposeSource::Generated)
4719    {
4720        return Ok(PurposeReviewItem {
4721            path,
4722            classification: None,
4723            action: PurposeReviewAction::Error,
4724            current_status,
4725            current_source,
4726            purpose: current_purpose,
4727            error: "generated suggestions require an explicit reviewed purpose".to_string(),
4728        });
4729    }
4730
4731    let reviewed_purpose = reviewed_purpose.trim().to_string();
4732    let action = if indexed.purpose.agent_reviewed() && current_purpose == reviewed_purpose {
4733        PurposeReviewAction::Skip
4734    } else if apply {
4735        store.set_purpose(&path, &reviewed_purpose, PurposeSource::Agent)?;
4736        PurposeReviewAction::Review
4737    } else {
4738        PurposeReviewAction::WouldReview
4739    };
4740    Ok(PurposeReviewItem {
4741        path,
4742        classification: None,
4743        action,
4744        current_status,
4745        current_source,
4746        purpose: reviewed_purpose,
4747        error: String::new(),
4748    })
4749}
4750
4751/// Preview one conditional review against the current queue state without writing.
4752fn preview_conditional_purpose_review(
4753    store: &AtlasStore,
4754    task: &str,
4755    path: &str,
4756    work_key: &str,
4757    state_token: &str,
4758) -> Result<PurposeConditionalApplyState, CliError> {
4759    let batch = store.load_purpose_curation_batch(task, &[path.to_string()])?;
4760    if let Some(candidate) = batch.items.first() {
4761        return Ok(
4762            if candidate.work_key == work_key && candidate.state_token == state_token {
4763                PurposeConditionalApplyState::Applied
4764            } else {
4765                PurposeConditionalApplyState::Stale
4766            },
4767        );
4768    }
4769    Ok(store
4770        .load_node_by_path(path)?
4771        .map_or(PurposeConditionalApplyState::PathUnavailable, |_node| {
4772            PurposeConditionalApplyState::Accepted
4773        }))
4774}
4775
4776/// Map database conditional-apply state into the stable review action contract.
4777const fn conditional_purpose_review_action(
4778    state: PurposeConditionalApplyState,
4779    apply: bool,
4780) -> PurposeReviewAction {
4781    match state {
4782        PurposeConditionalApplyState::Applied if apply => PurposeReviewAction::Review,
4783        PurposeConditionalApplyState::Applied => PurposeReviewAction::WouldReview,
4784        PurposeConditionalApplyState::Stale => PurposeReviewAction::Stale,
4785        PurposeConditionalApplyState::Accepted => PurposeReviewAction::Accepted,
4786        PurposeConditionalApplyState::PathUnavailable => PurposeReviewAction::Unavailable,
4787    }
4788}
4789
4790/// Build a purpose curation queue from the bounded health page.
4791pub(crate) fn purpose_curation_page(
4792    store: &AtlasStore,
4793    query: &HealthQuery,
4794    task: &str,
4795) -> Result<PurposeCurationPage, CliError> {
4796    let page = store.purpose_curation_findings_page_current(query)?;
4797    let paths = page
4798        .findings
4799        .iter()
4800        .map(|finding| finding.path.clone())
4801        .collect::<Vec<_>>();
4802    let batch = store.load_purpose_curation_batch(task, &paths)?;
4803    let file_paths = batch
4804        .items
4805        .iter()
4806        .filter(|candidate| candidate.node.node.kind == NodeKind::File)
4807        .map(|candidate| candidate.node.node.path.clone())
4808        .collect::<Vec<_>>();
4809    let classifications = store
4810        .file_content_classifications_for_paths(&file_paths)?
4811        .into_iter()
4812        .map(|row| (row.path, row.classification))
4813        .collect::<HashMap<_, _>>();
4814    let project_instance_id = batch.project_instance_id.to_string();
4815    let active_generation = batch.active_generation.get();
4816    let task = batch.task.clone();
4817    let work_key = batch.work_key.clone();
4818    let candidates = batch
4819        .items
4820        .into_iter()
4821        .map(|candidate| (candidate.node.node.path.clone(), candidate))
4822        .collect::<HashMap<_, _>>();
4823    let items = page
4824        .findings
4825        .iter()
4826        .filter_map(|finding| {
4827            let candidate = candidates.get(&finding.path)?;
4828            let node = &candidate.node;
4829            let review_signal = purpose_review_signal(&node.node, &node.purpose);
4830            Some(PurposeCurationItem {
4831                work_key: candidate.work_key.clone(),
4832                state_token: candidate.state_token.clone(),
4833                severity: health_severity_name(finding.severity).to_string(),
4834                id: finding.id.clone(),
4835                category: finding.category.clone(),
4836                path: finding.path.clone(),
4837                related_path: finding.related_path.clone().unwrap_or_default(),
4838                kind: node.node.kind.to_string(),
4839                language: node.node.language.clone().unwrap_or_default(),
4840                classification: classifications.get(&finding.path).copied(),
4841                purpose: node.purpose.purpose.clone().unwrap_or_default(),
4842                purpose_status: node.purpose.status.to_string(),
4843                purpose_source: node.purpose.source.to_string(),
4844                purpose_agent_reviewed: node.purpose.agent_reviewed(),
4845                review_priority: review_signal.priority.to_string(),
4846                review_reason: review_signal.reason.to_string(),
4847                content_summary: node.summary.clone().unwrap_or_default(),
4848                recommendation: "Inspect bounded context, then use conditional purpose review with this task, work_key, and state_token."
4849                    .to_string(),
4850            })
4851        })
4852        .collect::<Vec<_>>();
4853    let returned = items.len();
4854    Ok(PurposeCurationPage {
4855        project_instance_id,
4856        active_generation,
4857        task,
4858        work_key,
4859        actionable: returned > 0,
4860        curation_scope: purpose_queue_curation_scope(query),
4861        total: page.total,
4862        unfiltered_total: page.unfiltered_total,
4863        returned,
4864        start_index: page.start_index,
4865        limit: page.limit,
4866        max_limit: MAX_HEALTH_LIMIT,
4867        next_start_index: health_next_start_index(&page),
4868        truncated: health_next_start_index(&page).is_some(),
4869        source_only: query.scope.is_source_focused(),
4870        folder_scope: purpose_queue_folder_scope(query).to_string(),
4871        file_scope: purpose_queue_file_scope(query).to_string(),
4872        category: query.category.clone().unwrap_or_default(),
4873        severity: query.severity.map_or("", health_severity_name).to_string(),
4874        path_prefix: query.path_prefix.clone().unwrap_or_default(),
4875        summary_only: query.summary_only,
4876        items,
4877    })
4878}
4879
4880/// Render a bounded health page as compact TOON.
4881pub(crate) fn render_health_page(page: &HealthFindingsPage, query: &HealthQuery) -> String {
4882    let rows = page
4883        .findings
4884        .iter()
4885        .map(|finding| {
4886            json!({
4887                "severity": health_severity_name(finding.severity),
4888                "id": finding.id,
4889                "category": finding.category,
4890                "path": finding.path,
4891                "related_path": finding.related_path.as_deref().unwrap_or(""),
4892                "message": finding.message,
4893                "recommendation": finding.recommendation,
4894            })
4895        })
4896        .collect::<Vec<_>>();
4897    encode_agent_payload(&json!({
4898        "health": {
4899            "total": page.total,
4900            "unfiltered_total": page.unfiltered_total,
4901            "returned": page.returned,
4902            "start_index": page.start_index,
4903            "limit": page.limit,
4904            "max_limit": MAX_HEALTH_LIMIT,
4905            "next_start_index": health_next_start_index(page),
4906            "truncated": health_next_start_index(page).is_some(),
4907            "summary_only": query.summary_only,
4908            "source_only": query.scope.is_source_focused(),
4909            "category": query.category.as_deref().unwrap_or(""),
4910            "severity": query.severity.map_or("", health_severity_name),
4911            "path_prefix": query.path_prefix.as_deref().unwrap_or(""),
4912        },
4913        "health_findings": rows,
4914    }))
4915}
4916
4917/// Render one bounded current coverage page as compact TOON.
4918pub(crate) fn render_coverage_report(report: &CoverageDiscoveryReport) -> String {
4919    encode_agent_payload(&json!({ "coverage": report }))
4920}
4921
4922/// Render a purpose curation queue as compact TOON.
4923pub(crate) fn render_purpose_curation_page(page: &PurposeCurationPage) -> String {
4924    encode_agent_payload(&json!({
4925        "purpose_curation": {
4926            "project_instance_id": page.project_instance_id,
4927            "active_generation": page.active_generation,
4928            "task": page.task,
4929            "work_key": page.work_key,
4930            "actionable": page.actionable,
4931            "curation_scope": page.curation_scope,
4932            "total": page.total,
4933            "unfiltered_total": page.unfiltered_total,
4934            "returned": page.returned,
4935            "start_index": page.start_index,
4936            "limit": page.limit,
4937            "max_limit": page.max_limit,
4938            "next_start_index": page.next_start_index,
4939            "truncated": page.truncated,
4940            "source_only": page.source_only,
4941            "folder_scope": page.folder_scope,
4942            "file_scope": page.file_scope,
4943            "category": page.category,
4944            "severity": page.severity,
4945            "path_prefix": page.path_prefix,
4946            "summary_only": page.summary_only,
4947        },
4948        "purpose_curation_items": page.items,
4949    }))
4950}
4951
4952/// Render a batch purpose review report as compact TOON.
4953pub(crate) fn render_purpose_review_report(report: &PurposeReviewReport) -> String {
4954    encode_agent_payload(&json!({
4955        "purpose_review": {
4956            "applied": report.applied,
4957            "total": report.total,
4958            "changed": report.changed,
4959            "skipped": report.skipped,
4960            "conflicts": report.conflicts,
4961            "failed": report.failed,
4962        },
4963        "purpose_review_items": report.items,
4964    }))
4965}
4966
4967/// Return the folder inclusion scope for purpose curation metadata.
4968fn purpose_queue_folder_scope(query: &HealthQuery) -> &'static str {
4969    match query.scope {
4970        HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "source_relevant",
4971        _ => "all",
4972    }
4973}
4974
4975/// Return the file inclusion scope for purpose curation metadata.
4976fn purpose_queue_file_scope(query: &HealthQuery) -> &'static str {
4977    match query.scope {
4978        HealthScope::PurposeDefault => "high_impact",
4979        HealthScope::PurposeWithAssets => "high_impact_and_assets",
4980        HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "all_source",
4981        HealthScope::All | HealthScope::PurposeStrict => "all",
4982    }
4983}
4984
4985/// Return the truthful curation tier selected by explicit queue scope flags.
4986fn purpose_queue_curation_scope(query: &HealthQuery) -> &'static str {
4987    match query.scope {
4988        HealthScope::PurposeDefault => "low",
4989        HealthScope::PurposeWithAssets => "low_with_assets",
4990        HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "medium",
4991        HealthScope::All | HealthScope::PurposeStrict => "strict",
4992    }
4993}
4994
4995/// Return a stable lowercase severity name.
4996pub(crate) fn health_severity_name(severity: Severity) -> &'static str {
4997    severity.as_str()
4998}
4999
5000/// Return the next start index for a bounded health page.
5001fn health_next_start_index(page: &HealthFindingsPage) -> Option<usize> {
5002    let page_width = page.limit.min(page.total.saturating_sub(page.start_index));
5003    let page_end = page.start_index.saturating_add(page_width);
5004    if page_width == 0 || page_end >= page.total {
5005        None
5006    } else {
5007        Some(page_end)
5008    }
5009}
5010
5011/// Estimate source tokens for repository paths referenced by symbols/relations.
5012pub(crate) fn estimated_source_tokens_for_paths<'a>(
5013    store: &AtlasStore,
5014    paths: impl Iterator<Item = &'a str>,
5015) -> Result<usize, CliError> {
5016    let mut seen = HashSet::new();
5017    let mut total = 0usize;
5018    for path in paths {
5019        if seen.insert(path.to_string()) {
5020            total = total.saturating_add(estimated_source_tokens_for_path(store, path)?);
5021        }
5022    }
5023    Ok(total)
5024}
5025
5026/// Estimate source tokens for one indexed path, falling back safely for stale rows.
5027pub(crate) fn estimated_source_tokens_for_path(
5028    store: &AtlasStore,
5029    path: &str,
5030) -> Result<usize, CliError> {
5031    if let Some(indexed) = store.load_node_by_path(path)?
5032        && indexed.node.kind == NodeKind::File
5033    {
5034        return Ok(estimated_source_tokens_for_file_node(&indexed.node));
5035    }
5036    Ok(read_indexed_file_content(store, path).map_or_else(
5037        |_| estimate_tokens(path),
5038        |content| estimate_tokens(&content),
5039    ))
5040}
5041
5042/// Persisted file-text index report.
5043#[derive(Clone, Debug, Serialize)]
5044pub(crate) struct TextIndexReport {
5045    /// File nodes considered for indexed text.
5046    pub(crate) candidates: usize,
5047    /// UTF-8 files persisted for `SQLite`-backed search.
5048    pub(crate) indexed: usize,
5049    /// Files skipped because text could not be decoded as UTF-8.
5050    pub(crate) binary_or_non_utf8: usize,
5051    /// Files skipped because they exceeded the configured text-index size cap.
5052    pub(crate) too_large: usize,
5053    /// Total files skipped from the persisted text index.
5054    pub(crate) skipped: usize,
5055    /// Maximum UTF-8 file size persisted into `SQLite` text search.
5056    pub(crate) max_bytes: u64,
5057    /// Source bytes stored in the text index.
5058    pub(crate) bytes: usize,
5059}
5060
5061/// Deterministic structural-summary refresh report.
5062#[derive(Clone, Debug, Default, Serialize)]
5063pub(crate) struct StructuralSummaryReport {
5064    /// Indexed files considered for structural summaries.
5065    pub(crate) candidates: usize,
5066    /// Files whose observed summaries were refreshed.
5067    pub(crate) summarized: usize,
5068    /// Existing observed summaries cleared because current content was not summarizable.
5069    pub(crate) cleared: usize,
5070    /// Files skipped because they exceeded the parser size limit.
5071    pub(crate) too_large: usize,
5072    /// Files skipped because content was not valid UTF-8.
5073    pub(crate) binary_or_non_utf8: usize,
5074    /// Generated purpose suggestions that still need agent review.
5075    pub(crate) purpose_suggestions: usize,
5076}
5077
5078/// Options controlling full-text persistence for `SQLite` search.
5079#[derive(Clone, Copy, Debug)]
5080pub(crate) struct TextIndexOptions {
5081    /// Maximum UTF-8 file size persisted into `SQLite` text search.
5082    pub(crate) max_bytes: u64,
5083}
5084
5085impl TextIndexOptions {
5086    /// Create text-index options from config and command overrides.
5087    pub(crate) fn new(max_bytes: u64) -> Self {
5088        Self { max_bytes }
5089    }
5090}
5091
5092/// Outcome of considering one file for persisted text search.
5093#[derive(Clone, Debug)]
5094pub(crate) struct TextIndexRow {
5095    /// Repository-relative path considered for text indexing.
5096    path: String,
5097    /// Persistable text row when the file is search-indexed.
5098    text: Option<IndexedFileText>,
5099    /// Indexing outcome for reporting.
5100    reason: TextIndexSkipReason,
5101}
5102
5103/// Persisted text refresh result plus rows reused by structural summarizers.
5104pub(crate) struct TextIndexRefresh {
5105    /// Aggregate report rendered to callers.
5106    pub(crate) report: TextIndexReport,
5107    /// Per-file text outcomes from the same scan batch.
5108    pub(crate) rows: Vec<TextIndexRow>,
5109}
5110
5111/// Text-index outcome categories.
5112#[derive(Clone, Copy, Debug, Eq, PartialEq)]
5113pub(crate) enum TextIndexSkipReason {
5114    /// File text was persisted for search.
5115    Indexed,
5116    /// File exceeded the configured text-index size cap.
5117    TooLarge,
5118    /// File was binary or not valid UTF-8.
5119    BinaryOrNonUtf8,
5120}
5121
5122/// Symbol graph build report.
5123#[derive(Debug, Serialize)]
5124pub(crate) struct SymbolBuildReport {
5125    /// Indexed file candidates considered for symbols.
5126    pub(crate) candidates: usize,
5127    /// Files parsed during this build.
5128    pub(crate) parsed: usize,
5129    /// Files skipped because they were unchanged and already had symbols.
5130    pub(crate) unchanged: usize,
5131    /// Files skipped because they exceeded the configured size limit.
5132    pub(crate) too_large: usize,
5133    /// Files skipped because content was not valid UTF-8.
5134    pub(crate) binary_or_non_utf8: usize,
5135    /// Files skipped because the build deadline was reached.
5136    pub(crate) timed_out: usize,
5137    /// Worker thread count used for parser work.
5138    pub(crate) max_workers: usize,
5139    /// Optional timeout seconds requested for parser work.
5140    pub(crate) timeout_seconds: Option<u64>,
5141    /// Symbols persisted.
5142    pub(crate) symbols: usize,
5143    /// Relations persisted.
5144    pub(crate) relations: usize,
5145    /// Node summaries refreshed from symbol graphs.
5146    pub(crate) summaries: usize,
5147    /// Generated purpose suggestions that still need agent review.
5148    pub(crate) purpose_suggestions: usize,
5149}
5150
5151/// Filesystem and derived facts prepared before acquiring the `SQLite` writer.
5152struct IndexPublicationBatch {
5153    /// Complete publication generation observed before source preparation.
5154    base_generation: IndexGeneration,
5155    /// Derivation contract bound to every staged projection.
5156    contract_fingerprint: String,
5157    /// Canonical selected source root.
5158    root: PathBuf,
5159    /// Full or affected-path node mutation plus the final expected source state.
5160    nodes: NodePublicationBatch,
5161    /// Optional legacy-purpose inputs consumed by a full scan.
5162    purpose_import: Option<PurposeImportSnapshot>,
5163    /// Repository paths whose persisted source text must be replaced.
5164    text_paths: Vec<String>,
5165    /// Prepared persisted source-text rows and report.
5166    text: TextIndexRefresh,
5167    /// Prepared registry-owned or bounded-text fallback roles.
5168    content_classifications: Vec<FileContentClassification>,
5169    /// Prepared symbol graph, summary, and suggestion mutations.
5170    symbols: SymbolBuildStage,
5171    /// Prepared normalized repository graph and canonical resolution-key mutation.
5172    graph: graph_projection::StagedRepositoryGraph,
5173    /// Prepared structural-summary and suggestion mutations.
5174    structural_summaries: StructuralSummaryStage,
5175}
5176
5177/// Node mutations owned by one full or incremental publication.
5178enum NodePublicationBatch {
5179    /// Replace the complete observed source tree.
5180    Full {
5181        /// Complete staged node set.
5182        nodes: Vec<Node>,
5183    },
5184    /// Apply a bounded changed-path delta.
5185    Incremental {
5186        /// Added or modified nodes.
5187        nodes: Vec<Node>,
5188        /// Deleted paths and descendants to mark absent.
5189        absent_paths: Vec<String>,
5190        /// Complete expected source state used only for pre-publication revalidation.
5191        expected_nodes: Vec<Node>,
5192    },
5193}
5194
5195impl NodePublicationBatch {
5196    /// Return the complete source state that must still exist before publication.
5197    fn expected_nodes(&self) -> &[Node] {
5198        match self {
5199            Self::Full { nodes } => nodes,
5200            Self::Incremental { expected_nodes, .. } => expected_nodes,
5201        }
5202    }
5203}
5204
5205/// Reports produced after a staged batch commits successfully.
5206struct IndexPublicationOutcome {
5207    /// Legacy-purpose import decisions made against current authored state.
5208    purpose_import: PurposeImportReport,
5209    /// Persisted source-text report.
5210    text_index: TextIndexReport,
5211    /// Deterministic structural-summary report.
5212    structural_summaries: StructuralSummaryReport,
5213    /// Deep symbol graph report.
5214    symbols: SymbolBuildReport,
5215}
5216
5217/// Symbol mutations retained outside the `SQLite` writer transaction.
5218struct SymbolBuildStage {
5219    /// Aggregate symbol build report.
5220    report: SymbolBuildReport,
5221    /// Deterministically ordered projection changes.
5222    changes: Vec<SymbolProjectionChange>,
5223    /// Retained parser-output string bytes admitted by the resource boundary.
5224    retained_bytes: u64,
5225    /// Source identity details captured while sanitizing parser output.
5226    identity_admission: graph_projection::GraphIdentityAdmission,
5227}
5228
5229/// One closed symbol projection mutation.
5230enum SymbolProjectionChange {
5231    /// Persist one successfully parsed graph and its derived metadata.
5232    Parsed(SymbolParseSuccess),
5233    /// Clear stale symbol output for a skipped source file.
5234    Clear {
5235        /// Repository-relative path.
5236        path: String,
5237        /// Detected language used to preserve structural summaries where applicable.
5238        language: Option<String>,
5239    },
5240}
5241
5242/// Structural summary mutations retained outside the `SQLite` writer transaction.
5243struct StructuralSummaryStage {
5244    /// Aggregate structural-summary report.
5245    report: StructuralSummaryReport,
5246    /// Deterministically ordered summary changes.
5247    changes: Vec<StructuralSummaryChange>,
5248    /// Retained summary and suggestion string bytes.
5249    retained_bytes: u64,
5250}
5251
5252/// One file's closed structural-summary derivation.
5253#[derive(Default)]
5254struct StructuralSummaryDerivation {
5255    /// Optional persistence mutation for this file.
5256    change: Option<StructuralSummaryChange>,
5257    /// Observed summaries derived or reused.
5258    summarized: usize,
5259    /// Existing observed summaries cleared.
5260    cleared: usize,
5261    /// Files cleared because they exceeded the parser limit.
5262    too_large: usize,
5263    /// Files cleared because their content was not valid text.
5264    binary_or_non_utf8: usize,
5265    /// Unapproved purpose suggestions derived from observed summaries.
5266    purpose_suggestions: usize,
5267    /// String bytes retained until publication.
5268    retained_bytes: u64,
5269}
5270
5271/// One closed structural-summary projection mutation.
5272enum StructuralSummaryChange {
5273    /// Replace one observed summary and optional unreviewed purpose suggestion.
5274    Set {
5275        /// Repository-relative path.
5276        path: String,
5277        /// Deterministic observed summary.
5278        summary: String,
5279        /// Optional generated purpose suggestion.
5280        purpose_suggestion: Option<String>,
5281    },
5282    /// Clear a stale observed summary.
5283    Clear {
5284        /// Repository-relative path.
5285        path: String,
5286    },
5287}
5288
5289/// Watch command report.
5290#[derive(Debug, Serialize)]
5291pub(crate) struct WatchReport {
5292    /// Watcher mode.
5293    pub(crate) mode: String,
5294    /// Completed refresh cycles.
5295    pub(crate) cycles: usize,
5296    /// Whether the command ran a single refresh and exited.
5297    pub(crate) once: bool,
5298    /// Reason the watcher fell back from event mode, if any.
5299    #[serde(skip_serializing_if = "Option::is_none")]
5300    pub(crate) fallback_reason: Option<String>,
5301    /// Last persisted text search index report.
5302    pub(crate) text_index: TextIndexReport,
5303    /// Last structural summary refresh report.
5304    pub(crate) structural_summaries: StructuralSummaryReport,
5305    /// Last symbol refresh report.
5306    pub(crate) last_symbols: SymbolBuildReport,
5307}
5308
5309impl WatchReport {
5310    /// Report one verified unchanged pass without claiming parse or publication work.
5311    pub(crate) fn unchanged(text_options: TextIndexOptions) -> Self {
5312        Self::from_refresh(empty_index_refresh_report(text_options))
5313    }
5314
5315    /// Render one complete shared refresh outcome consistently across adapters.
5316    pub(crate) fn from_refresh(refresh: IndexRefreshReport) -> Self {
5317        Self {
5318            mode: WATCH_MODE_ONCE.to_string(),
5319            cycles: 1,
5320            once: true,
5321            fallback_reason: None,
5322            text_index: refresh.text_index,
5323            structural_summaries: refresh.structural_summaries,
5324            last_symbols: refresh.symbols,
5325        }
5326    }
5327}
5328
5329/// Debounced filesystem changes observed by watcher mode.
5330#[derive(Debug, Default)]
5331pub(crate) struct WatchChangeSet {
5332    /// Whether a full scan is required for correctness.
5333    requires_full_scan: bool,
5334    /// Relevant native paths from event batches.
5335    paths: HashSet<PathBuf>,
5336    /// Safe repository paths that can invalidate document-target resolution
5337    /// even when the target is not itself indexable.
5338    document_paths: HashSet<PathBuf>,
5339}
5340
5341impl WatchChangeSet {
5342    /// Return whether there is work to refresh.
5343    fn has_changes(&self) -> bool {
5344        self.requires_full_scan || !self.paths.is_empty() || !self.document_paths.is_empty()
5345    }
5346
5347    /// Merge another event batch into this set.
5348    fn merge(&mut self, other: Self) {
5349        self.requires_full_scan |= other.requires_full_scan;
5350        self.paths.extend(other.paths);
5351        self.document_paths.extend(other.document_paths);
5352    }
5353}
5354
5355/// Legacy purpose cleanup report.
5356#[derive(Debug, Serialize)]
5357pub(crate) struct LegacyPurposeReport {
5358    /// Whether files were modified.
5359    pub(crate) applied: bool,
5360    /// Number of `.purpose` files found.
5361    pub(crate) purpose_files_found: usize,
5362    /// Number of `.purpose` files removed.
5363    pub(crate) purpose_files_removed: usize,
5364    /// Source header candidates found.
5365    pub(crate) source_header_candidates: Vec<String>,
5366    /// Legacy purpose file paths.
5367    pub(crate) purpose_files: Vec<String>,
5368}
5369
5370/// Local settings report.
5371#[derive(Debug, Serialize)]
5372pub(crate) struct SettingsReport {
5373    /// Runtime cache directory that owns local `ProjectAtlas` state.
5374    pub(crate) cache_dir: PathStatus,
5375    /// `SQLite` database file status.
5376    pub(crate) db: PathStatus,
5377    /// `SQLite` write-ahead log file status.
5378    pub(crate) db_wal: PathStatus,
5379    /// `SQLite` shared-memory sidecar file status.
5380    pub(crate) db_shm: PathStatus,
5381    /// `SQLite` rollback journal sidecar file status.
5382    pub(crate) db_journal: PathStatus,
5383    /// Project-local MCP configuration file status.
5384    pub(crate) mcp_config: PathStatus,
5385    /// Config file used for map/lint/scan imports, when discovered.
5386    pub(crate) config_path: Option<String>,
5387    /// Repository root used by map/lint config.
5388    pub(crate) repo_root: Option<String>,
5389    /// Source that selected the repository root.
5390    pub(crate) root_detection_source: String,
5391    /// Whether config and DB root metadata agree.
5392    pub(crate) root_verified: bool,
5393    /// Root mismatches that should be fixed before trusting the binding.
5394    pub(crate) root_mismatches: Vec<String>,
5395    /// Generated map path.
5396    pub(crate) map_path: Option<String>,
5397    /// Non-source summary path.
5398    pub(crate) nonsource_files_path: Option<String>,
5399    /// Default output format.
5400    pub(crate) default_format: String,
5401    /// Default search case sensitivity.
5402    pub(crate) default_search_case_sensitive: bool,
5403    /// Source used by search commands.
5404    pub(crate) search_source: String,
5405    /// Maximum UTF-8 file size persisted into `SQLite` text search.
5406    pub(crate) text_index_max_bytes: u64,
5407    /// Watcher runtime status.
5408    pub(crate) watcher: WatchStatusReport,
5409    /// Current index statistics, if the index exists.
5410    pub(crate) index: Option<SettingsIndexStats>,
5411    /// Content-free telemetry retention and maintenance state, when the index exists.
5412    pub(crate) telemetry: Option<TelemetryRetentionState>,
5413    /// Read-only schema, publication, coverage, and `SQLite` operating diagnostics.
5414    pub(crate) database: DatabaseSettingsReport,
5415    /// Content-free language capability registry identity and derived counts.
5416    pub(crate) language_registry: LanguageRegistryReport,
5417    /// Closed classified-content navigation surface supported by this runtime.
5418    pub(crate) classified_navigation: SettingsClassifiedNavigationReport,
5419    /// Digest of the currently implemented provider-backed relation contract.
5420    pub(crate) semantic_relation_contract_digest: String,
5421    /// Versioned accepted relation-family inventory and lifecycle state.
5422    pub(crate) relation_family_inventory: RelationFamilyInventoryReport,
5423    /// Typed search-mode readiness without an implicit index build.
5424    pub(crate) search: SettingsSearchReport,
5425    /// Content-free optional parser-pack lifecycle state.
5426    pub(crate) optional_parser_pack: OptionalParserSettingsReport,
5427}
5428
5429/// Content-free classified navigation capabilities shared by CLI and MCP settings.
5430#[derive(Clone, Debug, Serialize)]
5431pub(crate) struct SettingsClassifiedNavigationReport {
5432    /// Closed persisted content roles.
5433    pub(crate) classifications: [ContentClassification; 5],
5434    /// Caller-visible explicit selection values.
5435    pub(crate) selections: [ContentSelection; 3],
5436    /// Canonical stored document relation.
5437    pub(crate) document_relation: &'static str,
5438    /// Inbound adapter view over the same stored relation.
5439    pub(crate) inbound_document_view: &'static str,
5440}
5441
5442/// Return the closed classified navigation contract without inspecting private data.
5443pub(crate) fn classified_navigation_capabilities() -> SettingsClassifiedNavigationReport {
5444    SettingsClassifiedNavigationReport {
5445        classifications: [
5446            ContentClassification::Source,
5447            ContentClassification::Documentation,
5448            ContentClassification::ConfigurationData,
5449            ContentClassification::OtherText,
5450            ContentClassification::Opaque,
5451        ],
5452        selections: [
5453            ContentSelection::Source,
5454            ContentSelection::Documentation,
5455            ContentSelection::Both,
5456        ],
5457        document_relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents).as_str(),
5458        inbound_document_view: "documented_by",
5459    }
5460}
5461
5462/// Readiness of one settings capability.
5463#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
5464#[serde(rename_all = "snake_case")]
5465pub(crate) enum SettingsCapabilityState {
5466    /// The capability has usable persisted state.
5467    Ready,
5468    /// The capability is implemented but the selected index has no rows yet.
5469    Empty,
5470    /// The capability is not available in the current runtime/index combination.
5471    Unavailable,
5472}
5473
5474/// Typed settings projection for one search mode.
5475#[derive(Debug, Serialize)]
5476pub(crate) struct SettingsSearchModeReport {
5477    /// Current readiness.
5478    pub(crate) state: SettingsCapabilityState,
5479}
5480
5481/// Typed lexical-search settings projection.
5482#[derive(Debug, Serialize)]
5483pub(crate) struct SettingsLexicalSearchReport {
5484    /// Current readiness.
5485    pub(crate) state: SettingsCapabilityState,
5486    /// Authoritative source searched by the correctness path.
5487    pub(crate) source: &'static str,
5488    /// Whether returned candidates are verified against persisted exact text.
5489    pub(crate) exact_verification: bool,
5490}
5491
5492/// Content-free readiness for supported and planned search modes.
5493#[derive(Debug, Serialize)]
5494pub(crate) struct SettingsSearchReport {
5495    /// Compatible default when no explicit mode is supplied.
5496    pub(crate) default_mode: &'static str,
5497    /// Deterministic persisted-text search.
5498    pub(crate) lexical: SettingsLexicalSearchReport,
5499    /// Optional FTS candidate acceleration.
5500    pub(crate) fts: SettingsSearchModeReport,
5501    /// Optional semantic retrieval.
5502    pub(crate) semantic: SettingsSearchModeReport,
5503    /// Optional lexical-complete hybrid retrieval.
5504    pub(crate) hybrid: SettingsSearchModeReport,
5505}
5506
5507/// Optional parser state present in both feature-enabled and default-core-only builds.
5508#[derive(Debug, Serialize)]
5509pub(crate) struct OptionalParserSettingsReport {
5510    /// Whether the supervised optional-parser lifecycle is compiled into this binary.
5511    pub(crate) compiled: bool,
5512    /// Bounded lifecycle metadata when the supervisor feature is present.
5513    #[cfg(feature = "optional-parser-supervisor")]
5514    pub(crate) lifecycle: OptionalParserPackLifecycleReport,
5515    /// Honest state for a binary compiled without the optional lifecycle.
5516    #[cfg(not(feature = "optional-parser-supervisor"))]
5517    pub(crate) state: &'static str,
5518}
5519
5520/// Filesystem status for a diagnostic path.
5521#[derive(Debug, Serialize)]
5522pub(crate) struct PathStatus {
5523    /// Lossless UTF-8 path, when one is available.
5524    pub(crate) path: Option<String>,
5525    /// Whether the path exists.
5526    pub(crate) exists: bool,
5527    /// File size in bytes when the path is an existing file.
5528    pub(crate) size_bytes: Option<u64>,
5529}
5530
5531/// Indexed state summary for settings diagnostics.
5532#[derive(Debug, Serialize)]
5533pub(crate) struct SettingsIndexStats {
5534    /// Canonical project root stored in the index metadata.
5535    pub(crate) project_root: Option<String>,
5536    /// Indexed file count.
5537    pub(crate) files: usize,
5538    /// Indexed folder count.
5539    pub(crate) folders: usize,
5540    /// Missing purpose count.
5541    pub(crate) missing_purposes: usize,
5542    /// Stale purpose count.
5543    pub(crate) stale_purposes: usize,
5544    /// Suggested purpose count.
5545    pub(crate) suggested_purposes: usize,
5546    /// Persisted searchable text rows.
5547    pub(crate) indexed_text_files: usize,
5548    /// Persisted searchable text bytes.
5549    pub(crate) indexed_text_bytes: usize,
5550    /// Persisted symbol count.
5551    pub(crate) symbols: usize,
5552    /// Persisted symbol relation count.
5553    pub(crate) relations: usize,
5554    /// Token telemetry event count.
5555    pub(crate) token_calls: usize,
5556    /// Unresolved structural health finding count.
5557    pub(crate) health_findings: usize,
5558}
5559
5560/// Watcher status report.
5561#[derive(Debug, Serialize)]
5562pub(crate) struct WatchStatusReport {
5563    /// Whether a watcher implementation is available in this binary.
5564    pub(crate) available: bool,
5565    /// Whether a watcher is active.
5566    pub(crate) active: bool,
5567    /// Watcher mode.
5568    pub(crate) mode: String,
5569    /// Whether event-backed watching is available.
5570    pub(crate) event_backend_available: bool,
5571    /// Operational recommendation.
5572    pub(crate) recommendation: String,
5573}
5574
5575/// Runtime index/cache cleanup report.
5576#[derive(Debug, Serialize)]
5577pub(crate) struct ResetIndexReport {
5578    /// Whether files were modified.
5579    pub(crate) applied: bool,
5580    /// Whether the command only previewed paths.
5581    pub(crate) dry_run: bool,
5582    /// Runtime files selected for cleanup.
5583    files: Vec<PathStatus>,
5584    /// Number of selected files removed.
5585    pub(crate) removed: usize,
5586}
5587
5588/// Build settings diagnostics shared by CLI and MCP.
5589pub(crate) fn build_settings_report(
5590    db: &Path,
5591    config_path: Option<&Path>,
5592    format: OutputFormat,
5593) -> Result<SettingsReport, CliError> {
5594    let admission = preflight_database_discovery(db, config_path, true)?;
5595    let db = admitted_database_path(db, admission.as_ref());
5596    let absolute_db = absolute_path(db)?;
5597    let resolved_config = resolved_mcp_config_path(&absolute_db, config_path)?;
5598    let config = if let Some(config_path) = resolved_config.as_deref() {
5599        load_atlas_config(Some(config_path))?
5600    } else {
5601        let project_root = default_mcp_project_root(&absolute_db, None)?;
5602        load_atlas_config_for_root(&project_root)?
5603    };
5604    let config_root_identity = CanonicalProjectRoot::from_path(&config.root).ok();
5605    let cache_dir = absolute_db
5606        .parent()
5607        .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
5608    let database = database_settings_report(&absolute_db)?;
5609    let (index, telemetry, file_text_fts, db_root_identity, db_root_matches_config) =
5610        if database.schema.compatibility == DatabaseSchemaCompatibility::Current {
5611            let store = AtlasStore::open_read_only(&absolute_db)?;
5612            let db_root_identity = store.project_root_identity()?;
5613            let db_root_matches_config = config_root_identity
5614                .as_ref()
5615                .is_some_and(|config_root| store.project_root_identity_matches(config_root));
5616            let snapshot_publication = store.index_publication()?;
5617            if settings_publication_matches(
5618                database.publication.as_ref(),
5619                snapshot_publication.as_ref(),
5620            ) {
5621                (
5622                    Some(settings_index_stats(&store)?),
5623                    Some(store.telemetry_retention_state()?),
5624                    Some(store.file_text_fts_state()?),
5625                    db_root_identity,
5626                    db_root_matches_config,
5627                )
5628            } else {
5629                (None, None, None, db_root_identity, db_root_matches_config)
5630            }
5631        } else {
5632            (None, None, None, None, false)
5633        };
5634    let repo_root = config_root_identity
5635        .as_ref()
5636        .and_then(|root| root.display_string().ok());
5637    let mut root_mismatches = Vec::new();
5638    if let (Some(db_root), Some(config_root)) =
5639        (db_root_identity.as_ref(), config_root_identity.as_ref())
5640    {
5641        if !db_root_matches_config {
5642            root_mismatches.push(format!(
5643                "db root {:?} does not match config root {:?}",
5644                db_root
5645                    .display_string()
5646                    .unwrap_or_else(|_| "<native display unavailable>".to_string()),
5647                config_root
5648                    .display_string()
5649                    .unwrap_or_else(|_| "<native display unavailable>".to_string())
5650            ));
5651        }
5652    } else if db_root_identity.is_some() != config_root_identity.is_some() {
5653        root_mismatches
5654            .push("db root and config root do not both have a usable native identity".to_string());
5655    }
5656    let root_detection_source = if resolved_config.is_some() {
5657        "config"
5658    } else if db_root_identity.is_some() {
5659        "db"
5660    } else {
5661        "db-path-or-cwd"
5662    }
5663    .to_string();
5664    let lexical_publication_ready = database.publication.as_ref().is_some_and(|publication| {
5665        publication.state == IndexPublicationState::Complete
5666            && publication.generation != IndexGeneration::ZERO
5667            && publication.contract_fingerprint_state == DatabasePublicationContractState::Valid
5668    });
5669    let lexical_state = match (lexical_publication_ready, index.as_ref()) {
5670        (true, Some(stats)) if stats.indexed_text_files == 0 => SettingsCapabilityState::Empty,
5671        (true, Some(_)) => SettingsCapabilityState::Ready,
5672        _ => SettingsCapabilityState::Unavailable,
5673    };
5674    let fts_state = match (lexical_publication_ready, file_text_fts.as_ref()) {
5675        (true, Some(state)) if !state.synchronized => SettingsCapabilityState::Unavailable,
5676        (true, Some(state)) if state.source_rows == 0 => SettingsCapabilityState::Empty,
5677        (true, Some(_)) => SettingsCapabilityState::Ready,
5678        _ => SettingsCapabilityState::Unavailable,
5679    };
5680    let search = SettingsSearchReport {
5681        default_mode: "lexical",
5682        lexical: SettingsLexicalSearchReport {
5683            state: lexical_state,
5684            source: "persisted_text",
5685            exact_verification: true,
5686        },
5687        fts: SettingsSearchModeReport { state: fts_state },
5688        semantic: SettingsSearchModeReport {
5689            state: SettingsCapabilityState::Unavailable,
5690        },
5691        hybrid: SettingsSearchModeReport {
5692            state: SettingsCapabilityState::Unavailable,
5693        },
5694    };
5695    #[cfg(feature = "optional-parser-supervisor")]
5696    let optional_parser_pack = OptionalParserSettingsReport {
5697        compiled: true,
5698        lifecycle: OptionalParserPackLifecycle::new(&config.root, None)?.status()?,
5699    };
5700    #[cfg(not(feature = "optional-parser-supervisor"))]
5701    let optional_parser_pack = OptionalParserSettingsReport {
5702        compiled: false,
5703        state: "compiled_unavailable",
5704    };
5705    Ok(SettingsReport {
5706        cache_dir: path_status(&cache_dir)?,
5707        db: path_status(&absolute_db)?,
5708        db_wal: path_status(&db_sidecar_path(&absolute_db, "wal"))?,
5709        db_shm: path_status(&db_sidecar_path(&absolute_db, "shm"))?,
5710        db_journal: path_status(&db_sidecar_path(&absolute_db, "journal"))?,
5711        mcp_config: path_status(&mcp_config_path_for_db(&absolute_db))?,
5712        config_path: resolved_config
5713            .as_deref()
5714            .and_then(lossless_native_path_display),
5715        repo_root,
5716        root_detection_source,
5717        root_verified: root_mismatches.is_empty(),
5718        root_mismatches,
5719        map_path: lossless_native_path_display(&config.map_path),
5720        nonsource_files_path: lossless_native_path_display(&config.nonsource_files_path),
5721        default_format: format!("{format:?}").to_ascii_lowercase(),
5722        default_search_case_sensitive: false,
5723        search_source: "sqlite-file-text".to_string(),
5724        text_index_max_bytes: config.text_index_max_bytes(),
5725        watcher: watcher_status_report(false),
5726        index,
5727        telemetry,
5728        database,
5729        language_registry: language_registry_report(),
5730        classified_navigation: classified_navigation_capabilities(),
5731        semantic_relation_contract_digest: semantic_resolution_contract_digest(),
5732        relation_family_inventory: relation_family_inventory_report(),
5733        search,
5734        optional_parser_pack,
5735    })
5736}
5737
5738/// Reject mixed settings projections when publication changed between read snapshots.
5739fn settings_publication_matches(
5740    diagnostic: Option<&DatabasePublicationReport>,
5741    snapshot: Option<&IndexPublication>,
5742) -> bool {
5743    match (diagnostic, snapshot) {
5744        (None, None) => true,
5745        (Some(diagnostic), Some(snapshot))
5746            if diagnostic.state == snapshot.state
5747                && diagnostic.generation == snapshot.generation =>
5748        {
5749            match diagnostic.contract_fingerprint_state {
5750                DatabasePublicationContractState::Missing => {
5751                    snapshot.contract_fingerprint.is_none()
5752                }
5753                DatabasePublicationContractState::Valid => {
5754                    diagnostic.contract_fingerprint.as_deref()
5755                        == snapshot.contract_fingerprint.as_deref()
5756                }
5757                DatabasePublicationContractState::Invalid => false,
5758            }
5759        }
5760        _ => false,
5761    }
5762}
5763
5764/// Build index statistics for settings diagnostics.
5765pub(crate) fn settings_index_stats(store: &AtlasStore) -> Result<SettingsIndexStats, CliError> {
5766    let overview = store.overview()?;
5767    let health_findings = store.unresolved_health_finding_count_current()?;
5768    Ok(SettingsIndexStats {
5769        project_root: store
5770            .project_root_identity()?
5771            .and_then(|root| root.display_string().ok()),
5772        files: overview.files,
5773        folders: overview.folders,
5774        missing_purposes: overview.missing_purposes,
5775        stale_purposes: overview.stale_purposes,
5776        suggested_purposes: overview.suggested_purposes,
5777        indexed_text_files: store.file_text_count()?,
5778        indexed_text_bytes: store.file_text_byte_count()?,
5779        symbols: store.symbol_count()?,
5780        relations: store.symbol_relation_count()?,
5781        token_calls: store.token_overview(None)?.calls,
5782        health_findings,
5783    })
5784}
5785
5786/// Preview or remove local runtime index/cache files.
5787pub(crate) fn reset_index_files(
5788    db: &Path,
5789    apply: bool,
5790    dry_run: bool,
5791    include_mcp_config: bool,
5792    admission: Option<&crate::project_state::ProjectStateGuard>,
5793) -> Result<ResetIndexReport, CliError> {
5794    let db = admitted_database_path(db, admission);
5795    let targets = reset_index_targets(db, include_mcp_config, admission)?;
5796    let files = targets
5797        .iter()
5798        .map(|path| path_status(path))
5799        .collect::<Result<Vec<_>, _>>()?;
5800    let should_apply = apply && !dry_run;
5801    let mut removed = 0;
5802    if should_apply {
5803        for target in &targets {
5804            verify_reset_admission(db, admission)?;
5805            if target.is_file() {
5806                fs::remove_file(target).map_err(|source| CliError::Io {
5807                    path: target.clone(),
5808                    source,
5809                })?;
5810                removed += 1;
5811            }
5812        }
5813    }
5814    Ok(ResetIndexReport {
5815        applied: should_apply,
5816        dry_run: !should_apply,
5817        files,
5818        removed,
5819    })
5820}
5821
5822/// Remove registered reset targets only after staging and lifecycle revalidation.
5823pub(crate) fn reset_index_files_with_revalidation(
5824    db: &Path,
5825    include_mcp_config: bool,
5826    admission: Option<&crate::project_state::ProjectStateGuard>,
5827    mut revalidate: impl FnMut() -> Result<(), CliError>,
5828) -> Result<ResetIndexReport, CliError> {
5829    // A replaced registration must retain its lifecycle error before even
5830    // enumerating the newly selected namespace or its recovery files.
5831    revalidate()?;
5832    let db = admitted_database_path(db, admission);
5833    let targets = reset_index_targets(db, include_mcp_config, admission)?;
5834    let files = targets
5835        .iter()
5836        .map(|path| path_status(path))
5837        .collect::<Result<Vec<_>, _>>()?;
5838    if !targets.iter().any(|target| target.is_file()) {
5839        revalidate()?;
5840        return Ok(ResetIndexReport {
5841            applied: true,
5842            dry_run: false,
5843            files,
5844            removed: 0,
5845        });
5846    }
5847
5848    let absolute_db = absolute_path(db)?;
5849    let parent = absolute_db.parent().ok_or_else(|| {
5850        CliError::InvalidInput("reset database path has no parent directory".to_string())
5851    })?;
5852    let recovery = tempfile::Builder::new()
5853        .prefix("reset-recovery-")
5854        .tempdir_in(parent)
5855        .map_err(|source| CliError::Io {
5856            path: parent.to_path_buf(),
5857            source,
5858        })?
5859        .keep();
5860    let mut staged = Vec::with_capacity(targets.len());
5861    for (index, target) in targets.iter().enumerate() {
5862        if let Err(error) = revalidate().and_then(|()| verify_reset_admission(db, admission)) {
5863            return Err(recover_staged_reset_files(
5864                db, admission, &recovery, &staged, error,
5865            ));
5866        }
5867        if !target.is_file() {
5868            continue;
5869        }
5870        let recovery_file = recovery.join(format!("{index:02}.reset"));
5871        if let Err(source) = move_reset_file_noclobber(target, &recovery_file) {
5872            let operation = revalidate().err().unwrap_or_else(|| CliError::Io {
5873                path: target.clone(),
5874                source,
5875            });
5876            return Err(recover_staged_reset_files(
5877                db, admission, &recovery, &staged, operation,
5878            ));
5879        }
5880        staged.push((target.clone(), recovery_file));
5881    }
5882
5883    if let Err(error) = revalidate().and_then(|()| verify_reset_admission(db, admission)) {
5884        return Err(recover_staged_reset_files(
5885            db, admission, &recovery, &staged, error,
5886        ));
5887    }
5888
5889    let removed = staged.len();
5890    for (_, recovery_file) in &staged {
5891        verify_reset_admission(db, admission).map_err(|error| {
5892            retained_reset_recovery(&recovery, &error, "owning namespace changed during cleanup")
5893        })?;
5894        if let Err(source) = fs::remove_file(recovery_file) {
5895            return Err(CliError::Io {
5896                path: recovery,
5897                source,
5898            });
5899        }
5900    }
5901    fs::remove_dir(&recovery).map_err(|source| CliError::Io {
5902        path: recovery,
5903        source,
5904    })?;
5905    Ok(ResetIndexReport {
5906        applied: true,
5907        dry_run: false,
5908        files,
5909        removed,
5910    })
5911}
5912
5913/// Restore only through the retained reset namespace; otherwise retain its files.
5914fn recover_staged_reset_files(
5915    db: &Path,
5916    admission: Option<&crate::project_state::ProjectStateGuard>,
5917    recovery: &Path,
5918    staged: &[(PathBuf, PathBuf)],
5919    operation: CliError,
5920) -> CliError {
5921    if let Err(error) = verify_reset_admission(db, admission) {
5922        return retained_reset_recovery(recovery, &operation, &error.to_string());
5923    }
5924    restore_staged_reset_files(db, admission, recovery, staged, operation)
5925}
5926
5927/// Report retained reset artifacts without resolving a changed directory again.
5928fn retained_reset_recovery(recovery: &Path, operation: &CliError, cause: &str) -> CliError {
5929    CliError::Io {
5930        path: recovery.to_path_buf(),
5931        source: io::Error::other(format!(
5932            "{operation}; staged reset recovery retained because its owning namespace could not be verified: {cause}; the reported recovery path is its original location and may have moved with that directory"
5933        )),
5934    }
5935}
5936
5937/// Return the fixed file inventory owned by one index reset.
5938fn reset_index_targets(
5939    db: &Path,
5940    include_mcp_config: bool,
5941    admission: Option<&crate::project_state::ProjectStateGuard>,
5942) -> Result<Vec<PathBuf>, CliError> {
5943    let absolute_db = absolute_path(db)?;
5944    let mut targets = vec![
5945        absolute_db.clone(),
5946        db_sidecar_path(&absolute_db, "wal"),
5947        db_sidecar_path(&absolute_db, "shm"),
5948        db_sidecar_path(&absolute_db, "journal"),
5949    ];
5950    if let Some(admission) = admission {
5951        targets.extend(
5952            admission
5953                .alternate_reset_paths()
5954                .map_err(|source| CliError::Io {
5955                    path: absolute_db.clone(),
5956                    source,
5957                })?,
5958        );
5959    }
5960    if include_mcp_config {
5961        targets.push(mcp_config_path_for_db(&absolute_db));
5962    }
5963    targets.sort();
5964    targets.dedup();
5965    Ok(targets)
5966}
5967
5968/// Recheck only reset's retained namespace before unlinking or staging an entry.
5969fn verify_reset_admission(
5970    db: &Path,
5971    admission: Option<&crate::project_state::ProjectStateGuard>,
5972) -> Result<(), CliError> {
5973    if let Some(admission) = admission {
5974        admission.verify().map_err(|source| CliError::Io {
5975            path: db.to_path_buf(),
5976            source,
5977        })?;
5978    }
5979    Ok(())
5980}
5981
5982/// Move one existing reset target without overwriting another path.
5983fn move_reset_file_noclobber(source: &Path, destination: &Path) -> io::Result<()> {
5984    let temporary = tempfile::TempPath::try_from_path(source)?;
5985    match temporary.persist_noclobber(destination) {
5986        Ok(()) => Ok(()),
5987        Err(mut error) => {
5988            error.path.disable_cleanup(true);
5989            Err(error.error)
5990        }
5991    }
5992}
5993
5994/// Restore staged reset files without overwriting a replacement lifecycle.
5995fn restore_staged_reset_files(
5996    db: &Path,
5997    admission: Option<&crate::project_state::ProjectStateGuard>,
5998    recovery: &Path,
5999    staged: &[(PathBuf, PathBuf)],
6000    operation: CliError,
6001) -> CliError {
6002    for (target, recovery_file) in staged.iter().rev() {
6003        if let Err(error) = verify_reset_admission(db, admission) {
6004            return retained_reset_recovery(recovery, &operation, &error.to_string());
6005        }
6006        if let Err(source) = move_reset_file_noclobber(recovery_file, target) {
6007            let kind = source.kind();
6008            return CliError::Io {
6009                path: recovery.to_path_buf(),
6010                source: io::Error::new(
6011                    kind,
6012                    format!("{operation}; reset recovery restore failed: {source}"),
6013                ),
6014            };
6015        }
6016    }
6017    // Preserve the owning failure; only an empty recovery directory can remain here.
6018    drop(fs::remove_dir(recovery));
6019    operation
6020}
6021
6022/// Resolve the config path that should travel with generated MCP configs.
6023pub(crate) fn resolved_mcp_config_path(
6024    db: &Path,
6025    config: Option<&Path>,
6026) -> Result<Option<PathBuf>, CliError> {
6027    let admission = preflight_database_discovery(db, config, true)?;
6028    let db = admitted_database_path(db, admission.as_ref());
6029    let legacy_root = legacy_project_root_candidate(db, admission.as_ref())?;
6030    if let Some(path) = config {
6031        return Ok(Some(absolute_path(path)?));
6032    }
6033    let mut candidate_roots = Vec::new();
6034    if db.exists()
6035        && let Some(project_root) = read_project_root_identity_read_only(db)?
6036    {
6037        candidate_roots.push(project_root.into_path());
6038    }
6039    if let Some(project_root) = legacy_root {
6040        candidate_roots.push(project_root);
6041    }
6042    let absolute_db = absolute_path(db)?;
6043    if let Some(project_root) = project_root_from_db_path(&absolute_db) {
6044        candidate_roots.push(project_root);
6045    }
6046    for root in candidate_roots {
6047        for candidate in config_candidates_for_root(&root) {
6048            if candidate.exists() {
6049                return Ok(Some(absolute_path(&candidate)?));
6050            }
6051        }
6052    }
6053    Ok(None)
6054}
6055
6056/// Return supported config paths for one project root.
6057fn config_candidates_for_root(root: &Path) -> [PathBuf; 2] {
6058    [
6059        root.join(".projectatlas").join("config.toml"),
6060        root.join("projectatlas.toml"),
6061    ]
6062}
6063
6064/// Return an absolute path without requiring the target to exist.
6065pub(crate) fn absolute_path(path: &Path) -> Result<PathBuf, CliError> {
6066    if path.is_absolute() {
6067        return Ok(path.to_path_buf());
6068    }
6069    let current_dir = std::env::current_dir().map_err(|source| CliError::Io {
6070        path: PathBuf::from("."),
6071        source,
6072    })?;
6073    Ok(current_dir.join(path))
6074}
6075
6076/// Return a diagnostic status for one path.
6077pub(crate) fn path_status(path: &Path) -> Result<PathStatus, CliError> {
6078    let absolute = absolute_path(path)?;
6079    let metadata = fs::metadata(&absolute).ok();
6080    Ok(PathStatus {
6081        path: lossless_native_path_display(&absolute),
6082        exists: metadata.is_some(),
6083        size_bytes: metadata
6084            .as_ref()
6085            .and_then(|metadata| metadata.is_file().then_some(metadata.len())),
6086    })
6087}
6088
6089/// Return the path to a `SQLite` sidecar file.
6090pub(crate) fn db_sidecar_path(db: &Path, suffix: &str) -> PathBuf {
6091    let mut sidecar = db.as_os_str().to_os_string();
6092    sidecar.push(format!("-{suffix}"));
6093    PathBuf::from(sidecar)
6094}
6095
6096/// Return the project-local MCP config path associated with a database path.
6097pub(crate) fn mcp_config_path_for_db(db: &Path) -> PathBuf {
6098    db.parent().map_or_else(
6099        || PathBuf::from("projectatlas.mcp.json"),
6100        |parent| parent.join("projectatlas.mcp.json"),
6101    )
6102}
6103
6104/// Build a watcher status report from a lightweight runtime probe.
6105pub(crate) fn watcher_status_report(active: bool) -> WatchStatusReport {
6106    let notify_available = notify_runtime_available();
6107    let mode = if notify_available {
6108        WATCH_MODE_NOTIFY
6109    } else {
6110        WATCH_MODE_POLLING
6111    };
6112    let recommendation = if notify_available {
6113        "Run `projectatlas watch --once` for one refresh or `projectatlas watch` for event-backed refresh with portable polling fallback."
6114    } else {
6115        "Run `projectatlas watch --once` for one refresh or `projectatlas watch` for portable polling refresh."
6116    };
6117    WatchStatusReport {
6118        available: true,
6119        active,
6120        mode: mode.to_string(),
6121        event_backend_available: notify_available,
6122        recommendation: recommendation.to_string(),
6123    }
6124}
6125
6126/// Typed SQLite-owned portion of one lint result.
6127#[derive(Debug, Eq, PartialEq, Serialize)]
6128pub(crate) struct DatabaseLintReport {
6129    /// Purpose strictness used to select blocking health categories.
6130    pub(crate) purpose_level: String,
6131    /// Blocking findings returned within the bounded page.
6132    pub(crate) shown: usize,
6133    /// Total matching health findings before category blocking policy.
6134    pub(crate) total: usize,
6135    /// Typed blocking health findings.
6136    pub(crate) findings: Vec<HealthFinding>,
6137}
6138
6139impl DatabaseLintReport {
6140    /// Render the compatibility text from typed health findings.
6141    fn render_text(&self) -> Result<String, CliError> {
6142        if self.findings.is_empty() {
6143            return Ok(String::new());
6144        }
6145        let mut report = format!(
6146            "ProjectAtlas SQLite index health findings (purpose-level {}, showing {} of {}):\n",
6147            self.purpose_level, self.shown, self.total
6148        );
6149        for finding in &self.findings {
6150            writeln!(
6151                &mut report,
6152                "- [{}] {}: {}",
6153                finding.category, finding.path, finding.recommendation
6154            )
6155            .map_err(|source| CliError::Output(io::Error::other(source.to_string())))?;
6156        }
6157        Ok(report)
6158    }
6159}
6160
6161/// Shared typed lint result consumed by CLI and MCP adapters.
6162#[derive(Debug, Eq, PartialEq, Serialize)]
6163pub(crate) struct LintReport {
6164    /// Whether every selected lint check passed.
6165    pub(crate) ok: bool,
6166    /// CLI-compatible process exit code.
6167    pub(crate) exit_code: i32,
6168    /// Map and filesystem-owned lint facts.
6169    pub(crate) map: atlas_map::MapLintReport,
6170    /// SQLite-owned lint facts when an index exists.
6171    pub(crate) index: Option<DatabaseLintReport>,
6172    /// Human-readable compatibility rendering of the same facts.
6173    pub(crate) report: String,
6174}
6175
6176/// Build one shared typed lint result for CLI and MCP callers.
6177pub(crate) fn lint_project(
6178    config: &atlas_map::AtlasMapConfig,
6179    db: &Path,
6180    config_path: Option<&Path>,
6181    options: atlas_map::LintOptions,
6182    purpose_level: PurposeLintLevel,
6183) -> Result<LintReport, CliError> {
6184    let map = atlas_map::lint_map(config, options)?;
6185    let index = lint_database_if_present(db, &config.root, config_path, purpose_level)?;
6186    let exit_code = map.exit_code().max(i32::from(
6187        index
6188            .as_ref()
6189            .is_some_and(|report| !report.findings.is_empty()),
6190    ));
6191    let mut report = map.render_text();
6192    if let Some(index) = &index {
6193        let index_text = index.render_text()?;
6194        if !index_text.is_empty() {
6195            if !report.is_empty() && !report.ends_with('\n') {
6196                report.push('\n');
6197            }
6198            report.push_str(&index_text);
6199        }
6200    }
6201    Ok(LintReport {
6202        ok: exit_code == 0,
6203        exit_code,
6204        map,
6205        index,
6206        report,
6207    })
6208}
6209
6210/// Build typed lint facts for an existing `SQLite` index.
6211pub(crate) fn lint_database_if_present(
6212    db: &Path,
6213    root: &Path,
6214    config_path: Option<&Path>,
6215    purpose_level: PurposeLintLevel,
6216) -> Result<Option<DatabaseLintReport>, CliError> {
6217    match db.try_exists() {
6218        Ok(false) => return Ok(None),
6219        Ok(true) => {}
6220        Err(source) => {
6221            return Err(CliError::Io {
6222                path: db.to_path_buf(),
6223                source,
6224            });
6225        }
6226    }
6227    let control = standalone_index_work_control();
6228    let exact = open_exact_saved_source_matches_index_controlled(db, root, config_path, &control)?;
6229    let store = &exact.store;
6230    let query = purpose_level.health_query();
6231    let page = store.unresolved_health_findings_page_current(&query)?;
6232    let blocking = page
6233        .findings
6234        .into_iter()
6235        .filter(|finding| purpose_level.blocks_category(finding.category.as_str()))
6236        .collect::<Vec<_>>();
6237    let report = DatabaseLintReport {
6238        purpose_level: purpose_level.as_str().to_string(),
6239        shown: blocking.len(),
6240        total: page.total,
6241        findings: blocking,
6242    };
6243    store.finish_index_read_snapshot()?;
6244    Ok(Some(report))
6245}
6246
6247/// Purpose curation strictness used by `projectatlas lint`.
6248#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6249pub(crate) enum PurposeLintLevel {
6250    /// Advisory first-pass curation scope for folders and high-impact files.
6251    Low,
6252    /// Also require agent review for all source files.
6253    Medium,
6254    /// Require agent review for every indexed file and folder.
6255    Strict,
6256}
6257
6258impl PurposeLintLevel {
6259    /// Stable CLI/report label.
6260    pub(crate) fn as_str(self) -> &'static str {
6261        match self {
6262            Self::Low => "low",
6263            Self::Medium => "medium",
6264            Self::Strict => "strict",
6265        }
6266    }
6267
6268    /// Convert lint strictness into the bounded DB health query.
6269    fn health_query(self) -> HealthQuery {
6270        let scope = match self {
6271            Self::Low => HealthScope::purpose_default(),
6272            Self::Medium => HealthScope::purpose_with_source_files(),
6273            Self::Strict => HealthScope::purpose_strict(),
6274        };
6275        HealthQuery {
6276            start_index: 0,
6277            limit: MAX_HEALTH_LIMIT,
6278            category: None,
6279            severity: Some(Severity::Warning),
6280            path_prefix: None,
6281            summary_only: false,
6282            scope,
6283        }
6284    }
6285
6286    /// Return whether a category should make lint fail at this strictness.
6287    fn blocks_category(self, category: &str) -> bool {
6288        match category {
6289            CATEGORY_STALE_PURPOSE
6290            | CATEGORY_DUPLICATE_PURPOSE
6291            | CATEGORY_REPEATED_TEMPORARY_FOLDER => true,
6292            CATEGORY_MISSING_PURPOSE
6293            | CATEGORY_SUGGESTED_PURPOSE_REVIEW
6294            | CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => self != Self::Low,
6295            _ => false,
6296        }
6297    }
6298}
6299
6300/// Return whether the platform watcher can be constructed in this process.
6301pub(crate) fn notify_runtime_available() -> bool {
6302    let (sender, _receiver) = mpsc::channel();
6303    RecommendedWatcher::new(
6304        move |result: notify::Result<Event>| {
6305            if sender.send(result).is_err() {
6306                // Receiver shutdown only means this status probe is done.
6307            }
6308        },
6309        Config::default(),
6310    )
6311    .is_ok()
6312}
6313
6314/// Options controlling source parsing during symbol graph builds.
6315#[derive(Clone, Copy, Debug)]
6316pub(crate) struct SymbolBuildOptions {
6317    /// Maximum file size parsed for symbols.
6318    pub(crate) max_bytes: u64,
6319    /// Optional maximum worker threads for parser work.
6320    max_workers: Option<usize>,
6321    /// Optional deadline for starting parser work.
6322    timeout: Option<Duration>,
6323    /// Serialized timeout value for reports.
6324    pub(crate) timeout_seconds: Option<u64>,
6325}
6326
6327impl SymbolBuildOptions {
6328    /// Create symbol build options from CLI/MCP values.
6329    pub(crate) fn new(
6330        max_bytes: u64,
6331        max_workers: Option<usize>,
6332        timeout_seconds: Option<u64>,
6333    ) -> Self {
6334        Self {
6335            max_bytes: max_bytes.min(MAX_SYMBOL_FILE_BYTES),
6336            max_workers: max_workers.filter(|workers| *workers > 0),
6337            timeout: timeout_seconds.map(Duration::from_secs),
6338            timeout_seconds,
6339        }
6340    }
6341
6342    /// Apply a worker ceiling without weakening a tighter caller limit.
6343    #[must_use]
6344    pub(crate) fn with_worker_ceiling(mut self, max_workers: usize) -> Self {
6345        let ceiling = max_workers.max(1);
6346        self.max_workers = Some(
6347            self.max_workers
6348                .map_or(ceiling, |workers| workers.min(ceiling)),
6349        );
6350        self
6351    }
6352
6353    /// Return the worker count that will be reported.
6354    pub(crate) fn reported_workers(self) -> usize {
6355        self.effective_workers()
6356    }
6357
6358    /// Derive the worker count from caller policy, host availability, and the safety ceiling.
6359    fn effective_workers(self) -> usize {
6360        let available = thread::available_parallelism().map_or(1, usize::from);
6361        self.max_workers
6362            .unwrap_or(available)
6363            .min(available)
6364            .min(INDEX_WORKER_SAFE_CEILING)
6365    }
6366
6367    /// Return whether the parser build deadline has elapsed.
6368    pub(crate) fn is_timed_out(self, started_at: Instant) -> bool {
6369        self.timeout
6370            .is_some_and(|timeout| started_at.elapsed() >= timeout)
6371    }
6372}
6373
6374/// Bound a worker pool by its work cardinality and runtime ceiling.
6375fn worker_count_for_work(work_items: usize, max_workers: usize) -> usize {
6376    work_items.min(max_workers.clamp(1, INDEX_WORKER_SAFE_CEILING))
6377}
6378
6379/// Aggregate rows and retained string bytes admitted by one symbol publication.
6380#[derive(Clone, Copy, Debug)]
6381struct SymbolPublicationLimits {
6382    /// Maximum symbol rows persisted by the operation.
6383    symbol_rows: u64,
6384    /// Maximum relation rows persisted by the operation.
6385    relation_rows: u64,
6386    /// Maximum retained parser-output string bytes persisted by the operation.
6387    output_bytes: u64,
6388}
6389
6390impl SymbolPublicationLimits {
6391    /// Durable process-safe limits used by CLI and MCP indexing operations.
6392    const STANDARD: Self = Self {
6393        symbol_rows: 2_000_000,
6394        relation_rows: 8_000_000,
6395        output_bytes: MAX_PUBLICATION_STAGING_BYTES,
6396    };
6397}
6398
6399/// Create one indexing boundary with the caller timeout capped by the safe default.
6400pub(crate) fn index_work_control(options: &SymbolBuildOptions) -> IndexWorkControl {
6401    IndexWorkControl::new(IndexCancellation::new(), options.timeout)
6402        .with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT)
6403        .with_worker_ceiling(options.effective_workers())
6404}
6405
6406/// Create a bounded work boundary for runtime paths without symbol options.
6407pub(crate) fn standalone_index_work_control() -> IndexWorkControl {
6408    IndexWorkControl::new(IndexCancellation::new(), Some(DEFAULT_INDEX_WORK_TIMEOUT))
6409}
6410
6411/// Apply the runtime's safe whole-operation deadline without weakening caller bounds.
6412fn bounded_index_work_control(control: &IndexWorkControl) -> IndexWorkControl {
6413    control.with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT)
6414}
6415
6416/// Source file queued for symbol parsing.
6417#[derive(Clone, Debug)]
6418pub(crate) struct SymbolParseJob {
6419    /// Repository-relative file path.
6420    pub(crate) path: String,
6421    /// Native absolute file path.
6422    native_path: PathBuf,
6423    /// Content hash captured by the staged filesystem scan.
6424    expected_content_hash: String,
6425    /// Detected language name.
6426    language: Option<String>,
6427    /// Existing node summary fallback.
6428    fallback_summary: Option<String>,
6429    /// Whether a generated purpose suggestion should be written or refreshed.
6430    purpose_needs_suggestion: bool,
6431}
6432
6433/// Successful parser output waiting for sequential DB persistence.
6434#[derive(Debug)]
6435pub(crate) struct SymbolParseSuccess {
6436    /// Repository-relative file path.
6437    pub(crate) path: String,
6438    /// Extracted symbol graph.
6439    graph: SymbolGraph,
6440    /// Bounded Markdown headings and explicit document candidates from the same parse.
6441    markdown_facts: Option<Box<MarkdownFacts>>,
6442    /// Source-owned lexical/import occurrences from the same captured parse.
6443    source_semantics: Option<Box<SourceSemanticFacts>>,
6444    /// File-level parser kept independent from fact-level parser provenance.
6445    source_parser: ParserKind,
6446    /// Observed one-line source summary.
6447    summary: String,
6448    /// Whether the existing parser worker derived the summary through the structural adapter.
6449    summary_is_structural: bool,
6450    /// Optional generated purpose suggestion.
6451    purpose_suggestion: Option<String>,
6452}
6453
6454/// Outcome from one parser worker.
6455#[derive(Debug)]
6456pub(crate) enum SymbolParseOutcome {
6457    /// Source parsed successfully.
6458    Parsed(SymbolParseSuccess),
6459    /// File was skipped because it was not UTF-8 source text.
6460    BinaryOrNonUtf8 {
6461        /// Repository-relative file path.
6462        path: String,
6463    },
6464    /// A supported document failed its typed format, archive, or parser boundary.
6465    InvalidInput {
6466        /// Repository-relative file path.
6467        path: String,
6468        /// Bounded extraction diagnostic.
6469        message: String,
6470    },
6471    /// Source bytes changed after the staged filesystem scan.
6472    SourceChanged {
6473        /// Repository-relative file path.
6474        path: String,
6475    },
6476    /// Source read failed.
6477    Io {
6478        /// Native path that failed to read.
6479        path: PathBuf,
6480        /// Source IO error.
6481        source: io::Error,
6482    },
6483    /// Cooperative parsing work was canceled or reached its deadline.
6484    IndexWork(IndexWorkFailure),
6485}
6486
6487/// Failure from one cancellation-aware bounded source read.
6488#[derive(Debug)]
6489enum SourceReadFailure {
6490    /// The source file could not be opened or read.
6491    Io(io::Error),
6492    /// The shared indexing operation was canceled or reached its deadline.
6493    IndexWork(IndexWorkFailure),
6494    /// The source grew beyond the caller's admitted byte count.
6495    LimitExceeded {
6496        /// First observed byte count beyond the limit.
6497        observed: u64,
6498    },
6499}
6500
6501/// Read at most one admitted source-byte budget while checking cooperative stop state.
6502fn read_source_bytes_controlled(
6503    path: &Path,
6504    max_bytes: u64,
6505    stage: IndexWorkStage,
6506    control: &IndexWorkControl,
6507) -> Result<Vec<u8>, SourceReadFailure> {
6508    control.check(stage).map_err(SourceReadFailure::IndexWork)?;
6509    let mut file = fs::File::open(path).map_err(SourceReadFailure::Io)?;
6510    let mut bytes = Vec::new();
6511    let mut buffer = [0_u8; CONTROLLED_SOURCE_READ_BUFFER_BYTES];
6512    loop {
6513        control.check(stage).map_err(SourceReadFailure::IndexWork)?;
6514        let count = file.read(&mut buffer).map_err(SourceReadFailure::Io)?;
6515        if count == 0 {
6516            break;
6517        }
6518        let observed = u64::try_from(bytes.len())
6519            .unwrap_or(u64::MAX)
6520            .saturating_add(count as u64);
6521        if observed > max_bytes {
6522            return Err(SourceReadFailure::LimitExceeded { observed });
6523        }
6524        bytes.extend_from_slice(&buffer[..count]);
6525    }
6526    control.check(stage).map_err(SourceReadFailure::IndexWork)?;
6527    Ok(bytes)
6528}
6529
6530/// Build selected symbol graphs under explicit aggregate publication limits.
6531#[cfg(test)]
6532fn build_symbols_for_paths_with_limits(
6533    store: &mut AtlasStore,
6534    root: &Path,
6535    options: &SymbolBuildOptions,
6536    previous_hashes: Option<&HashMap<String, String>>,
6537    target_paths: Option<&HashSet<String>>,
6538    control: &IndexWorkControl,
6539    limits: SymbolPublicationLimits,
6540) -> Result<SymbolBuildReport, CliError> {
6541    let nodes = if let Some(paths) = target_paths {
6542        let mut sorted_paths = paths.iter().cloned().collect::<Vec<_>>();
6543        sorted_paths.sort();
6544        store
6545            .load_nodes_by_paths(&sorted_paths)?
6546            .into_iter()
6547            .map(|indexed| indexed.node)
6548            .collect::<Vec<_>>()
6549    } else {
6550        store
6551            .load_nodes()?
6552            .into_iter()
6553            .map(|indexed| indexed.node)
6554            .collect::<Vec<_>>()
6555    };
6556    #[cfg(feature = "optional-parser-supervisor")]
6557    let optional_parser_selection =
6558        OptionalParserPackLifecycle::new(root, None)?.derive_project_selection()?;
6559    let staged = stage_symbols_for_nodes_with_limits(
6560        store,
6561        root,
6562        #[cfg(feature = "optional-parser-supervisor")]
6563        &optional_parser_selection,
6564        &nodes,
6565        options,
6566        previous_hashes,
6567        target_paths,
6568        &[],
6569        &HashSet::new(),
6570        control,
6571        limits,
6572    )?;
6573    let mut staged = staged;
6574    apply_symbol_build_stage(store, &mut staged, control)?;
6575    Ok(staged.report)
6576}
6577
6578/// Keep document admission at its parser-owned ceiling and ordinary source at caller policy.
6579fn source_input_byte_limit(
6580    path: &str,
6581    language: Option<&str>,
6582    observed: Option<u64>,
6583    source_limit: u64,
6584    stage: IndexWorkStage,
6585) -> Result<u64, IndexWorkFailure> {
6586    if document_format_for_path(path, language).is_none() {
6587        return Ok(source_limit);
6588    }
6589    let maximum = MAX_DOCUMENT_COMPRESSED_BYTES as u64;
6590    if let Some(observed) = observed.filter(|observed| *observed > maximum) {
6591        return Err(IndexWorkFailure::resource_limit(
6592            stage,
6593            IndexWorkResource::SourceBytes,
6594            maximum,
6595            observed,
6596        ));
6597    }
6598    Ok(maximum)
6599}
6600
6601/// Build selected symbol mutations without acquiring the `SQLite` writer.
6602#[allow(clippy::too_many_arguments)]
6603fn stage_symbols_for_nodes_with_limits(
6604    store: &AtlasStore,
6605    root: &Path,
6606    #[cfg(feature = "optional-parser-supervisor")]
6607    optional_parser_selection: &OptionalParserPackProjectSelection,
6608    nodes: &[Node],
6609    options: &SymbolBuildOptions,
6610    previous_hashes: Option<&HashMap<String, String>>,
6611    target_paths: Option<&HashSet<String>>,
6612    staged_text: &[TextIndexRow],
6613    protected_purpose_paths: &HashSet<String>,
6614    control: &IndexWorkControl,
6615    limits: SymbolPublicationLimits,
6616) -> Result<SymbolBuildStage, CliError> {
6617    control.check(IndexWorkStage::SymbolParsing)?;
6618    #[cfg(feature = "optional-parser-supervisor")]
6619    let admit_optional_languages = optional_parser_selection.selection_key().is_some();
6620    #[cfg(not(feature = "optional-parser-supervisor"))]
6621    let admit_optional_languages = false;
6622    let root = root.canonicalize().map_err(|source| CliError::Io {
6623        path: root.to_path_buf(),
6624        source,
6625    })?;
6626    let considered_paths = nodes
6627        .iter()
6628        .filter(|node| node.kind == NodeKind::File)
6629        .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6630        .map(|node| node.path.clone())
6631        .collect::<Vec<_>>();
6632    let previously_parsed_paths = store.source_parse_metadata_paths_for_paths(&considered_paths)?;
6633    let mut candidate_paths = nodes
6634        .iter()
6635        .filter(|node| node.kind == NodeKind::File)
6636        .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6637        .filter(|node| {
6638            is_symbol_candidate_for_admission(
6639                &node.path,
6640                node.language.as_deref(),
6641                admit_optional_languages,
6642            )
6643        })
6644        .map(|node| node.path.clone())
6645        .collect::<Vec<_>>();
6646    candidate_paths.sort();
6647    let existing_nodes = store
6648        .load_nodes_by_paths(&candidate_paths)?
6649        .into_iter()
6650        .map(|indexed| (indexed.node.path.clone(), indexed))
6651        .collect::<HashMap<_, _>>();
6652    let symbol_counts = store.symbol_counts_for_paths(&candidate_paths)?;
6653    let mut report = SymbolBuildReport {
6654        candidates: 0,
6655        parsed: 0,
6656        unchanged: 0,
6657        too_large: 0,
6658        binary_or_non_utf8: 0,
6659        timed_out: 0,
6660        max_workers: options.reported_workers(),
6661        timeout_seconds: options.timeout_seconds,
6662        symbols: 0,
6663        relations: 0,
6664        summaries: 0,
6665        purpose_suggestions: 0,
6666    };
6667    let mut jobs = Vec::new();
6668    let mut changes = Vec::new();
6669    let mut output_bytes = 0_u64;
6670    for node in nodes
6671        .iter()
6672        .filter(|node| node.kind == NodeKind::File)
6673        .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6674        .filter(|node| {
6675            !is_symbol_candidate_for_admission(
6676                &node.path,
6677                node.language.as_deref(),
6678                admit_optional_languages,
6679            ) && previously_parsed_paths.contains(&node.path)
6680        })
6681    {
6682        output_bytes = checked_symbol_publication_usage(
6683            output_bytes,
6684            node.path.len() as u64 + node.language.as_ref().map_or(0, String::len) as u64,
6685            limits.output_bytes,
6686            IndexWorkResource::OutputBytes,
6687        )?;
6688        changes.push(SymbolProjectionChange::Clear {
6689            path: node.path.clone(),
6690            language: node.language.clone(),
6691        });
6692    }
6693    for node in nodes
6694        .iter()
6695        .filter(|node| node.kind == NodeKind::File)
6696        .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6697        .filter(|node| {
6698            is_symbol_candidate_for_admission(
6699                &node.path,
6700                node.language.as_deref(),
6701                admit_optional_languages,
6702            )
6703        })
6704    {
6705        control.check(IndexWorkStage::SymbolParsing)?;
6706        report.candidates += 1;
6707        let max_bytes = source_input_byte_limit(
6708            &node.path,
6709            node.language.as_deref(),
6710            node.size_bytes,
6711            options.max_bytes,
6712            IndexWorkStage::SymbolParsing,
6713        )?;
6714        if node.size_bytes.is_some_and(|size| size > max_bytes) {
6715            output_bytes = checked_symbol_publication_usage(
6716                output_bytes,
6717                node.path.len() as u64 + node.language.as_ref().map_or(0, String::len) as u64,
6718                limits.output_bytes,
6719                IndexWorkResource::OutputBytes,
6720            )?;
6721            changes.push(SymbolProjectionChange::Clear {
6722                path: node.path.clone(),
6723                language: node.language.clone(),
6724            });
6725            report.too_large += 1;
6726            continue;
6727        }
6728        let symbol_count = symbol_counts.get(&node.path).copied().unwrap_or_default();
6729        if node.content_hash.as_ref().is_some_and(|hash| {
6730            previous_hashes.and_then(|hashes| hashes.get(&node.path)) == Some(hash)
6731        }) {
6732            let has_source_index =
6733                symbol_count > 0 || store.load_source_parse_metadata(&node.path)?.is_some();
6734            if has_source_index {
6735                report.unchanged += 1;
6736                continue;
6737            }
6738        }
6739        let existing = existing_nodes.get(&node.path);
6740        jobs.push(SymbolParseJob {
6741            path: node.path.clone(),
6742            native_path: root.join(repo_path_to_native(&node.path)),
6743            expected_content_hash: node
6744                .content_hash
6745                .clone()
6746                .ok_or_else(|| source_changed_during_derivation(&root, &node.path))?,
6747            language: node.language.clone(),
6748            fallback_summary: existing.and_then(|indexed| indexed.summary.clone()),
6749            purpose_needs_suggestion: !protected_purpose_paths.contains(&node.path)
6750                && existing.is_none_or(|indexed| {
6751                    matches!(
6752                        indexed.purpose.status,
6753                        PurposeStatus::Missing | PurposeStatus::Suggested
6754                    )
6755                }),
6756        });
6757        if jobs.len() > MAX_SYMBOL_PARSE_JOBS {
6758            return Err(IndexWorkFailure::resource_limit(
6759                IndexWorkStage::SymbolParsing,
6760                IndexWorkResource::SymbolJobs,
6761                MAX_SYMBOL_PARSE_JOBS as u64,
6762                jobs.len() as u64,
6763            )
6764            .into());
6765        }
6766    }
6767    report.max_workers = worker_count_for_work(jobs.len(), report.max_workers);
6768    if !jobs.is_empty() {
6769        let verified_text = staged_text
6770            .iter()
6771            .filter_map(|row| row.text.as_ref().map(|text| (row.path.as_str(), text)))
6772            .collect::<HashMap<_, _>>();
6773        let pool = ThreadPoolBuilder::new()
6774            .num_threads(report.max_workers)
6775            .build()
6776            .map_err(|source| {
6777                CliError::InvalidInput(format!("symbol worker pool failed: {source}"))
6778            })?;
6779        #[cfg(feature = "optional-parser-supervisor")]
6780        let outcomes = optional_parser_runtime::parse_symbol_jobs_controlled(
6781            &root,
6782            optional_parser_selection,
6783            &pool,
6784            &jobs,
6785            options,
6786            control,
6787            &verified_text,
6788        )?;
6789        #[cfg(not(feature = "optional-parser-supervisor"))]
6790        let outcomes =
6791            parse_symbol_job_batches_controlled(&pool, &jobs, options, control, &verified_text)?;
6792        for outcome in outcomes {
6793            match outcome {
6794                SymbolParseOutcome::Parsed(parsed) => {
6795                    let next_symbols = checked_symbol_publication_usage(
6796                        report.symbols as u64,
6797                        graph_projection::navigable_symbol_count(&parsed.graph) as u64,
6798                        limits.symbol_rows,
6799                        IndexWorkResource::SymbolRows,
6800                    )?;
6801                    let next_relations = checked_symbol_publication_usage(
6802                        report.relations as u64,
6803                        parsed.graph.relations.len() as u64,
6804                        limits.relation_rows,
6805                        IndexWorkResource::RelationRows,
6806                    )?;
6807                    let next_output_bytes = checked_symbol_publication_usage(
6808                        output_bytes,
6809                        symbol_parse_output_bytes(&parsed),
6810                        limits.output_bytes,
6811                        IndexWorkResource::OutputBytes,
6812                    )?;
6813                    report.summaries += 1;
6814                    if parsed.purpose_suggestion.is_some() {
6815                        report.purpose_suggestions += 1;
6816                    }
6817                    report.parsed += 1;
6818                    report.symbols = next_symbols as usize;
6819                    report.relations = next_relations as usize;
6820                    output_bytes = next_output_bytes;
6821                    changes.push(SymbolProjectionChange::Parsed(parsed));
6822                }
6823                SymbolParseOutcome::BinaryOrNonUtf8 { path } => {
6824                    let language = nodes
6825                        .iter()
6826                        .find(|node| node.path == path)
6827                        .and_then(|node| node.language.clone());
6828                    output_bytes = checked_symbol_publication_usage(
6829                        output_bytes,
6830                        path.len() as u64 + language.as_ref().map_or(0, String::len) as u64,
6831                        limits.output_bytes,
6832                        IndexWorkResource::OutputBytes,
6833                    )?;
6834                    changes.push(SymbolProjectionChange::Clear { path, language });
6835                    report.binary_or_non_utf8 += 1;
6836                }
6837                SymbolParseOutcome::InvalidInput { path, message } => {
6838                    return Err(CliError::InvalidInput(format!(
6839                        "document extraction failed for {path}: {message}"
6840                    )));
6841                }
6842                SymbolParseOutcome::SourceChanged { path } => {
6843                    return Err(source_changed_during_derivation(&root, &path));
6844                }
6845                SymbolParseOutcome::Io { path, source } => {
6846                    return Err(CliError::Io { path, source });
6847                }
6848                SymbolParseOutcome::IndexWork(failure) => return Err(failure.into()),
6849            }
6850        }
6851    }
6852    control.check(IndexWorkStage::SymbolParsing)?;
6853    let mut staged = SymbolBuildStage {
6854        report,
6855        changes,
6856        retained_bytes: output_bytes,
6857        identity_admission: graph_projection::GraphIdentityAdmission::default(),
6858    };
6859    staged.identity_admission = graph_projection::admit_symbol_build_stage_with_limit(
6860        &mut staged,
6861        control,
6862        limits.output_bytes.saturating_sub(output_bytes),
6863    )?;
6864    staged.retained_bytes = staged
6865        .retained_bytes
6866        .saturating_add(staged.identity_admission.retained_bytes());
6867    Ok(staged)
6868}
6869
6870/// Parse all built-in symbol jobs in bounded Rayon batches.
6871#[cfg(not(feature = "optional-parser-supervisor"))]
6872fn parse_symbol_job_batches_controlled(
6873    pool: &rayon::ThreadPool,
6874    jobs: &[SymbolParseJob],
6875    options: &SymbolBuildOptions,
6876    control: &IndexWorkControl,
6877    verified_text: &HashMap<&str, &IndexedFileText>,
6878) -> Result<Vec<SymbolParseOutcome>, CliError> {
6879    let mut outcomes = Vec::with_capacity(jobs.len());
6880    for batch in jobs.chunks(SYMBOL_PARSE_BATCH_SIZE) {
6881        control.check(IndexWorkStage::SymbolParsing)?;
6882        outcomes.extend(parse_symbol_jobs_controlled(
6883            pool,
6884            batch,
6885            options,
6886            control,
6887            verified_text,
6888        )?);
6889    }
6890    Ok(outcomes)
6891}
6892
6893/// Apply prepared symbol mutations inside the parent publication transaction.
6894fn apply_symbol_build_stage(
6895    store: &mut AtlasStore,
6896    staged: &mut SymbolBuildStage,
6897    control: &IndexWorkControl,
6898) -> Result<(), CliError> {
6899    if !staged.identity_admission.source_admitted() {
6900        staged.identity_admission = graph_projection::admit_symbol_build_stage_with_limit(
6901            staged,
6902            control,
6903            MAX_PUBLICATION_STAGING_BYTES.saturating_sub(staged.retained_bytes),
6904        )?;
6905        staged.retained_bytes = staged
6906            .retained_bytes
6907            .saturating_add(staged.identity_admission.retained_bytes());
6908    }
6909    for change in &staged.changes {
6910        control.check(IndexWorkStage::Publication)?;
6911        match change {
6912            SymbolProjectionChange::Parsed(parsed) => {
6913                store.set_node_summary(&parsed.path, &parsed.summary)?;
6914                if let Some(suggestion) = parsed.purpose_suggestion.as_deref() {
6915                    store.set_suggested_purpose(&parsed.path, suggestion)?;
6916                }
6917                let mut metadata = SourceParseMetadata::from_graph(&parsed.graph);
6918                metadata.parser = parsed.source_parser;
6919                store.replace_symbol_graph_with_metadata(&parsed.graph, &metadata)?;
6920            }
6921            SymbolProjectionChange::Clear { path, language } => {
6922                clear_skipped_symbol_index(store, path, language.as_deref())?;
6923            }
6924        }
6925    }
6926    control.check(IndexWorkStage::Publication)?;
6927    Ok(())
6928}
6929
6930/// Parse one bounded symbol batch under the shared work boundary.
6931#[cfg(not(feature = "optional-parser-supervisor"))]
6932fn parse_symbol_jobs_controlled(
6933    pool: &rayon::ThreadPool,
6934    jobs: &[SymbolParseJob],
6935    options: &SymbolBuildOptions,
6936    control: &IndexWorkControl,
6937    verified_text: &HashMap<&str, &IndexedFileText>,
6938) -> Result<Vec<SymbolParseOutcome>, CliError> {
6939    control.check(IndexWorkStage::SymbolParsing)?;
6940    Ok(pool.install(|| {
6941        jobs.par_iter()
6942            .map(|job| {
6943                parse_symbol_job_with_staged_text_controlled(
6944                    job,
6945                    options,
6946                    control,
6947                    verified_text.get(job.path.as_str()).copied(),
6948                )
6949            })
6950            .collect::<Vec<_>>()
6951    }))
6952}
6953
6954/// Admit one aggregate symbol-publication resource before persistence.
6955fn checked_symbol_publication_usage(
6956    current: u64,
6957    added: u64,
6958    limit: u64,
6959    resource: IndexWorkResource,
6960) -> Result<u64, CliError> {
6961    let observed = current.saturating_add(added);
6962    if observed > limit {
6963        return Err(IndexWorkFailure::resource_limit(
6964            IndexWorkStage::SymbolParsing,
6965            resource,
6966            limit,
6967            observed,
6968        )
6969        .into());
6970    }
6971    Ok(observed)
6972}
6973
6974/// Count retained string bytes in one parser output without serializing a second copy.
6975fn symbol_parse_output_bytes(parsed: &SymbolParseSuccess) -> u64 {
6976    let graph = &parsed.graph;
6977    let mut bytes = graph.path.len() as u64
6978        + graph.language.as_ref().map_or(0, String::len) as u64
6979        + parsed.summary.len() as u64
6980        + parsed.purpose_suggestion.as_ref().map_or(0, String::len) as u64;
6981    for symbol in &graph.symbols {
6982        if graph_projection::is_document_coverage_marker(graph, symbol) {
6983            continue;
6984        }
6985        bytes = bytes.saturating_add(
6986            symbol.path.len() as u64
6987                + symbol.language.as_ref().map_or(0, String::len) as u64
6988                + symbol.name.len() as u64
6989                + symbol.signature.len() as u64
6990                + symbol.documentation.as_ref().map_or(0, String::len) as u64
6991                + symbol.parent.as_ref().map_or(0, String::len) as u64
6992                + symbol.detail.as_ref().map_or(0, String::len) as u64,
6993        );
6994    }
6995    for relation in &graph.relations {
6996        bytes = bytes.saturating_add(
6997            relation.path.len() as u64
6998                + relation.source_name.len() as u64
6999                + relation.target_name.len() as u64
7000                + relation.context.len() as u64,
7001        );
7002    }
7003    if let Some(facts) = &parsed.source_semantics {
7004        bytes = bytes.saturating_add(facts.retained_bytes());
7005    }
7006    if let Some(facts) = &parsed.markdown_facts {
7007        for heading in &facts.headings {
7008            bytes = bytes
7009                .saturating_add(heading.text.len() as u64)
7010                .saturating_add(heading.slug.len() as u64);
7011        }
7012        for candidate in &facts.link_candidates {
7013            bytes = bytes
7014                .saturating_add(candidate.selector.len() as u64)
7015                .saturating_add(candidate.label.as_ref().map_or(0, String::len) as u64)
7016                .saturating_add(candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64);
7017        }
7018    }
7019    bytes
7020}
7021
7022/// Parse one source file into a symbol graph.
7023#[cfg(test)]
7024pub(crate) fn parse_symbol_job(
7025    job: &SymbolParseJob,
7026    options: &SymbolBuildOptions,
7027    started_at: Instant,
7028) -> SymbolParseOutcome {
7029    let control = options
7030        .timeout
7031        .and_then(|timeout| started_at.checked_add(timeout))
7032        .map_or_else(
7033            || IndexWorkControl::new(IndexCancellation::new(), None),
7034            |deadline| IndexWorkControl::with_deadline(IndexCancellation::new(), deadline),
7035        );
7036    parse_symbol_job_controlled(job, options, &control)
7037}
7038
7039/// Borrow byte-preserving staged UTF-8 input for built-in parsers when admission matches.
7040fn parse_symbol_job_with_staged_text_controlled(
7041    job: &SymbolParseJob,
7042    options: &SymbolBuildOptions,
7043    control: &IndexWorkControl,
7044    verified_text: Option<&IndexedFileText>,
7045) -> SymbolParseOutcome {
7046    if let Err(failure) = control.check(IndexWorkStage::SymbolParsing) {
7047        return SymbolParseOutcome::IndexWork(failure);
7048    }
7049    if document_format_for_path(&job.path, job.language.as_deref()).is_none()
7050        && let Some(text) = verified_text
7051        && text.path == job.path
7052        && text.content_hash.as_deref() == Some(job.expected_content_hash.as_str())
7053        && text.content.len() as u64 <= options.max_bytes
7054    {
7055        return parse_admitted_symbol_job(job, &text.content, None, options, control);
7056    }
7057    parse_symbol_job_controlled(job, options, control)
7058}
7059
7060/// Parse one source file while observing cancellation before and during parsing.
7061fn parse_symbol_job_controlled(
7062    job: &SymbolParseJob,
7063    options: &SymbolBuildOptions,
7064    control: &IndexWorkControl,
7065) -> SymbolParseOutcome {
7066    if options.is_timed_out(control.started_at()) {
7067        return SymbolParseOutcome::IndexWork(IndexWorkFailure::DeadlineExceeded {
7068            stage: IndexWorkStage::SymbolParsing,
7069        });
7070    }
7071    if let Err(failure) = control.check(IndexWorkStage::SymbolParsing) {
7072        return SymbolParseOutcome::IndexWork(failure);
7073    }
7074    let bytes = match admit_symbol_job_bytes(job, options, control) {
7075        Ok(bytes) => bytes,
7076        Err(outcome) => return *outcome,
7077    };
7078    if document_format_for_path(&job.path, job.language.as_deref()).is_some() {
7079        return parse_document_symbol_job(job, &bytes, options, control);
7080    }
7081    let Ok(content) = String::from_utf8(bytes) else {
7082        return SymbolParseOutcome::BinaryOrNonUtf8 {
7083            path: job.path.clone(),
7084        };
7085    };
7086    parse_admitted_symbol_job(job, &content, None, options, control)
7087}
7088
7089/// Read, bound, and hash-check one source exactly once for symbol staging.
7090fn admit_symbol_job_bytes(
7091    job: &SymbolParseJob,
7092    options: &SymbolBuildOptions,
7093    control: &IndexWorkControl,
7094) -> Result<Vec<u8>, Box<SymbolParseOutcome>> {
7095    let max_bytes = source_input_byte_limit(
7096        &job.path,
7097        job.language.as_deref(),
7098        None,
7099        options.max_bytes,
7100        IndexWorkStage::SymbolParsing,
7101    )
7102    .map_err(|failure| Box::new(SymbolParseOutcome::IndexWork(failure)))?;
7103    let bytes = match read_source_bytes_controlled(
7104        &job.native_path,
7105        max_bytes,
7106        IndexWorkStage::SymbolParsing,
7107        control,
7108    ) {
7109        Ok(bytes) => bytes,
7110        Err(SourceReadFailure::Io(source)) => {
7111            return Err(Box::new(SymbolParseOutcome::Io {
7112                path: job.native_path.clone(),
7113                source,
7114            }));
7115        }
7116        Err(SourceReadFailure::IndexWork(failure)) => {
7117            return Err(Box::new(SymbolParseOutcome::IndexWork(failure)));
7118        }
7119        Err(SourceReadFailure::LimitExceeded { observed }) => {
7120            return Err(Box::new(SymbolParseOutcome::IndexWork(
7121                IndexWorkFailure::resource_limit(
7122                    IndexWorkStage::SymbolParsing,
7123                    IndexWorkResource::SourceBytes,
7124                    max_bytes,
7125                    observed,
7126                ),
7127            )));
7128        }
7129    };
7130    if let Err(failure) = control.check(IndexWorkStage::SymbolParsing) {
7131        return Err(Box::new(SymbolParseOutcome::IndexWork(failure)));
7132    }
7133    if blake3::hash(&bytes).to_hex().as_str() != job.expected_content_hash {
7134        return Err(Box::new(SymbolParseOutcome::SourceChanged {
7135            path: job.path.clone(),
7136        }));
7137    }
7138    Ok(bytes)
7139}
7140
7141/// Parse one admitted PDF/DOCX byte stream into the existing publication shape.
7142fn parse_document_symbol_job(
7143    job: &SymbolParseJob,
7144    bytes: &[u8],
7145    _options: &SymbolBuildOptions,
7146    control: &IndexWorkControl,
7147) -> SymbolParseOutcome {
7148    let facts = match extract_document_symbol_facts_controlled(
7149        bytes,
7150        &job.path,
7151        job.language.as_deref(),
7152        control,
7153    ) {
7154        Ok(facts) => facts,
7155        Err(error) => return document_parse_error_outcome(&job.path, error),
7156    };
7157    let summary = document_summary_from_facts(&facts);
7158    let graph = facts.symbol_graph(&job.path, job.language.as_deref());
7159    let purpose_suggestion = job
7160        .purpose_needs_suggestion
7161        .then(|| suggest_file_purpose(&job.path, &summary));
7162    SymbolParseOutcome::Parsed(SymbolParseSuccess {
7163        path: job.path.clone(),
7164        graph,
7165        markdown_facts: None,
7166        source_semantics: None,
7167        source_parser: ParserKind::Structural,
7168        summary,
7169        summary_is_structural: false,
7170        purpose_suggestion,
7171    })
7172}
7173
7174/// Preserve typed cancellation/resource failures while surfacing malformed documents as input errors.
7175fn document_parse_error_outcome(path: &str, error: DocumentExtractionError) -> SymbolParseOutcome {
7176    match error {
7177        DocumentExtractionError::Work(failure) => SymbolParseOutcome::IndexWork(failure),
7178        DocumentExtractionError::ResourceLimit {
7179            limit,
7180            observed,
7181            maximum,
7182        } => {
7183            let resource = match limit {
7184                DocumentLimit::FactCount => IndexWorkResource::SymbolRows,
7185                DocumentLimit::ExecutionFuel => IndexWorkResource::ParserFuel,
7186                DocumentLimit::ParserWorkBytes => IndexWorkResource::ParserWorkBytes,
7187                DocumentLimit::OutputBytes => IndexWorkResource::OutputBytes,
7188                DocumentLimit::EntryCount | DocumentLimit::NestingDepth => {
7189                    IndexWorkResource::Entries
7190                }
7191                DocumentLimit::InputBytes
7192                | DocumentLimit::CompressedBytes
7193                | DocumentLimit::ExpandedBytes
7194                | DocumentLimit::MemoryBytes => IndexWorkResource::SourceBytes,
7195            };
7196            SymbolParseOutcome::IndexWork(IndexWorkFailure::resource_limit(
7197                IndexWorkStage::SymbolParsing,
7198                resource,
7199                maximum as u64,
7200                observed as u64,
7201            ))
7202        }
7203        other => SymbolParseOutcome::InvalidInput {
7204            path: path.to_owned(),
7205            message: other.to_string(),
7206        },
7207    }
7208}
7209
7210/// Map one document extraction failure to the navigation boundary's typed error contract.
7211fn document_navigation_error(path: &str, error: DocumentExtractionError) -> CliError {
7212    match error {
7213        DocumentExtractionError::Work(failure) => failure.into(),
7214        DocumentExtractionError::ResourceLimit {
7215            limit,
7216            observed,
7217            maximum,
7218        } => {
7219            let resource = match limit {
7220                DocumentLimit::FactCount => IndexWorkResource::SymbolRows,
7221                DocumentLimit::ExecutionFuel => IndexWorkResource::ParserFuel,
7222                DocumentLimit::ParserWorkBytes => IndexWorkResource::ParserWorkBytes,
7223                DocumentLimit::OutputBytes => IndexWorkResource::OutputBytes,
7224                DocumentLimit::EntryCount | DocumentLimit::NestingDepth => {
7225                    IndexWorkResource::Entries
7226                }
7227                DocumentLimit::InputBytes
7228                | DocumentLimit::CompressedBytes
7229                | DocumentLimit::ExpandedBytes
7230                | DocumentLimit::MemoryBytes => IndexWorkResource::SourceBytes,
7231            };
7232            IndexWorkFailure::resource_limit(
7233                IndexWorkStage::TextIndex,
7234                resource,
7235                maximum as u64,
7236                observed as u64,
7237            )
7238            .into()
7239        }
7240        other => CliError::InvalidInput(format!("document extraction failed for {path}: {other}")),
7241    }
7242}
7243
7244/// Extract conservative facts from admitted source and retain independent source provenance.
7245fn parse_admitted_symbol_job(
7246    job: &SymbolParseJob,
7247    content: &str,
7248    source_parser: Option<ParserKind>,
7249    options: &SymbolBuildOptions,
7250    control: &IndexWorkControl,
7251) -> SymbolParseOutcome {
7252    if options.is_timed_out(control.started_at()) {
7253        return SymbolParseOutcome::IndexWork(IndexWorkFailure::DeadlineExceeded {
7254            stage: IndexWorkStage::SymbolParsing,
7255        });
7256    }
7257    let (observed_source_parser, graph, markdown_facts, source_semantics) = if job
7258        .language
7259        .as_deref()
7260        .and_then(language_capability)
7261        .is_some_and(|capability| capability.symbol_parser == SymbolParserOwner::Markdown)
7262    {
7263        let facts = match extract_markdown_facts_controlled(content, control) {
7264            Ok(facts) => facts,
7265            Err(failure) => return SymbolParseOutcome::IndexWork(failure),
7266        };
7267        let graph = facts.symbol_graph(&job.path, job.language.as_deref());
7268        (graph.parser, graph, Some(Box::new(facts)), None)
7269    } else {
7270        let (parser, graph, semantics) = match extract_symbol_graph_with_semantics_controlled(
7271            &job.path,
7272            job.language.as_deref(),
7273            content,
7274            control,
7275        ) {
7276            Ok(extraction) => extraction.into_parts(),
7277            Err(failure) => return SymbolParseOutcome::IndexWork(failure),
7278        };
7279        (parser, graph, None, Some(Box::new(semantics)))
7280    };
7281    let source_parser = source_parser.unwrap_or(observed_source_parser);
7282    let structural_summary = if let Some(facts) = &markdown_facts {
7283        markdown_summary_from_facts(
7284            facts,
7285            content
7286                .lines()
7287                .filter(|line| !line.trim().is_empty())
7288                .count(),
7289        )
7290    } else if graph.symbols.is_empty() {
7291        structural_summary_for_path(&job.path, job.language.as_deref(), content)
7292    } else {
7293        None
7294    };
7295    let summary_is_structural = structural_summary.is_some();
7296    let summary = structural_summary
7297        .unwrap_or_else(|| summarize_symbol_graph(&graph, job.fallback_summary.as_deref()));
7298    let purpose_suggestion = job
7299        .purpose_needs_suggestion
7300        .then(|| suggest_file_purpose(&job.path, &summary));
7301    SymbolParseOutcome::Parsed(SymbolParseSuccess {
7302        path: job.path.clone(),
7303        graph,
7304        markdown_facts,
7305        source_semantics,
7306        source_parser,
7307        summary,
7308        summary_is_structural,
7309        purpose_suggestion,
7310    })
7311}
7312
7313/// Return an empty symbol build report.
7314pub(crate) fn empty_symbol_build_report() -> SymbolBuildReport {
7315    SymbolBuildReport {
7316        candidates: 0,
7317        parsed: 0,
7318        unchanged: 0,
7319        too_large: 0,
7320        binary_or_non_utf8: 0,
7321        timed_out: 0,
7322        max_workers: 0,
7323        timeout_seconds: None,
7324        symbols: 0,
7325        relations: 0,
7326        summaries: 0,
7327        purpose_suggestions: 0,
7328    }
7329}
7330
7331/// Return an empty text-index report for a no-op refresh.
7332fn empty_text_index_report(options: TextIndexOptions) -> TextIndexReport {
7333    TextIndexReport {
7334        candidates: 0,
7335        indexed: 0,
7336        binary_or_non_utf8: 0,
7337        too_large: 0,
7338        skipped: 0,
7339        max_bytes: options.max_bytes,
7340        bytes: 0,
7341    }
7342}
7343
7344/// Create a deterministic one-line content summary from extracted symbols.
7345pub(crate) fn summarize_symbol_graph(graph: &SymbolGraph, fallback: Option<&str>) -> String {
7346    if graph.symbols.is_empty() {
7347        if let Some(fallback) = fallback.filter(|summary| !is_scanner_fallback_summary(summary)) {
7348            return fallback.to_string();
7349        }
7350        let language = observed_language_label(graph.language.as_deref());
7351        if observed_content_noun(graph.language.as_deref()) == "document" {
7352            return format!("{language} document with no headings found.");
7353        }
7354        return format!("{language} source file with no declarations found.");
7355    }
7356    let language = observed_language_label(graph.language.as_deref());
7357    let primary_names = primary_symbol_names(graph, 4);
7358    let primary_kinds = primary_symbol_kinds(graph);
7359    let imports = relation_targets(graph, RelationKind::Imports, 2);
7360    let dependencies = relation_targets(graph, RelationKind::DependsOn, 3);
7361    if !dependencies.is_empty() {
7362        let subject = observed_manifest_subject(&language);
7363        return format!(
7364            "{subject} declaring {} and depending on {}.",
7365            primary_names.join(", "),
7366            dependencies.join(", ")
7367        );
7368    }
7369    if !imports.is_empty() {
7370        return format!(
7371            "{language} source defining {} {} with imports {}.",
7372            primary_kinds,
7373            primary_names.join(", "),
7374            imports.join(", ")
7375        );
7376    }
7377    if observed_content_noun(graph.language.as_deref()) == "document" {
7378        return format!(
7379            "{language} document with {} {}.",
7380            primary_kinds,
7381            primary_names.join(", ")
7382        );
7383    }
7384    format!(
7385        "{language} source defining {} {}.",
7386        primary_kinds,
7387        primary_names.join(", ")
7388    )
7389}
7390
7391/// Return the truthful noun for registry-classified source versus documentation.
7392fn observed_content_noun(language: Option<&str>) -> &'static str {
7393    if content_classification(language, true)
7394        == projectatlas_core::language::ContentClassification::Documentation
7395    {
7396        "document"
7397    } else {
7398        "source"
7399    }
7400}
7401
7402/// Return a readable language label for agent-facing content summaries.
7403fn observed_language_label(language: Option<&str>) -> String {
7404    match language.unwrap_or("source") {
7405        "cargo-manifest" => "cargo manifest".to_string(),
7406        "cargo-lock" => "cargo lock".to_string(),
7407        "rust-build-script" => "rust build script".to_string(),
7408        "objective-c" => "Objective-C".to_string(),
7409        "csharp" => "C#".to_string(),
7410        "cpp" => "C++".to_string(),
7411        other => other.replace('-', " "),
7412    }
7413}
7414
7415/// Return the subject phrase for manifest-style content summaries.
7416fn observed_manifest_subject(language: &str) -> String {
7417    if language.contains("manifest") {
7418        language.to_string()
7419    } else {
7420        format!("{language} manifest")
7421    }
7422}
7423
7424/// Return a compact phrase describing the most important symbol kinds.
7425pub(crate) fn primary_symbol_kinds(graph: &SymbolGraph) -> String {
7426    let mut function_like = 0_usize;
7427    let mut type_like = 0_usize;
7428    let mut manifest_like = 0_usize;
7429    let mut value_like = 0_usize;
7430    let mut heading_like = 0_usize;
7431    for symbol in &graph.symbols {
7432        match symbol.kind {
7433            SymbolKind::Function | SymbolKind::Method => function_like += 1,
7434            SymbolKind::Class
7435            | SymbolKind::Struct
7436            | SymbolKind::Enum
7437            | SymbolKind::Trait
7438            | SymbolKind::Interface
7439            | SymbolKind::Type => type_like += 1,
7440            SymbolKind::Package | SymbolKind::Workspace | SymbolKind::Dependency => {
7441                manifest_like += 1;
7442            }
7443            SymbolKind::Value => value_like += 1,
7444            SymbolKind::Heading => heading_like += 1,
7445            SymbolKind::Module | SymbolKind::Import | SymbolKind::Unknown => {}
7446        }
7447    }
7448    if manifest_like > 0 && function_like == 0 && type_like == 0 {
7449        return "manifest entries".to_string();
7450    }
7451    if value_like > 0 && function_like == 0 && type_like == 0 {
7452        return value_only_symbol_kind_label(graph, value_like);
7453    }
7454    if heading_like > 0 && function_like == 0 && type_like == 0 {
7455        return if heading_like == 1 {
7456            "heading".to_string()
7457        } else {
7458            "headings".to_string()
7459        };
7460    }
7461    match (type_like, function_like) {
7462        (0, 0) => "symbols".to_string(),
7463        (0, 1) => "function".to_string(),
7464        (0, _) => "functions".to_string(),
7465        (1, 0) => "type".to_string(),
7466        (_, 0) => "types".to_string(),
7467        (1, 1) => "type and function".to_string(),
7468        (1, _) => "type and functions".to_string(),
7469        (_, 1) => "types and function".to_string(),
7470        (_, _) => "types and functions".to_string(),
7471    }
7472}
7473
7474/// Return the right value-only summary noun for the indexed language.
7475pub(crate) fn value_only_symbol_kind_label(graph: &SymbolGraph, count: usize) -> String {
7476    let language = graph.language.as_deref().unwrap_or_default();
7477    let binding_language = matches!(
7478        language,
7479        "javascript" | "typescript" | "tsx" | "vue" | "svelte"
7480    ) || graph
7481        .symbols
7482        .iter()
7483        .any(|symbol| symbol.detail.as_deref() == Some("fallback-composition-binding"));
7484    let singular = if binding_language { "binding" } else { "value" };
7485    let plural = if binding_language {
7486        "bindings"
7487    } else {
7488        "values"
7489    };
7490    if count == 1 {
7491        singular.to_string()
7492    } else {
7493        plural.to_string()
7494    }
7495}
7496
7497/// Return stable names for the most important declaration symbols.
7498pub(crate) fn primary_symbol_names(graph: &SymbolGraph, limit: usize) -> Vec<String> {
7499    let has_primary_definitions = graph.symbols.iter().any(|symbol| {
7500        matches!(
7501            symbol.kind,
7502            SymbolKind::Function
7503                | SymbolKind::Method
7504                | SymbolKind::Class
7505                | SymbolKind::Struct
7506                | SymbolKind::Enum
7507                | SymbolKind::Trait
7508                | SymbolKind::Interface
7509                | SymbolKind::Type
7510        )
7511    });
7512    let mut names = graph
7513        .symbols
7514        .iter()
7515        .filter(|symbol| {
7516            if has_primary_definitions && symbol.kind == SymbolKind::Value {
7517                return false;
7518            }
7519            !matches!(
7520                symbol.kind,
7521                SymbolKind::Import
7522                    | SymbolKind::Dependency
7523                    | SymbolKind::Module
7524                    | SymbolKind::Unknown
7525            )
7526        })
7527        .map(|symbol| symbol.name.clone())
7528        .collect::<Vec<_>>();
7529    if names.is_empty() {
7530        names = graph
7531            .symbols
7532            .iter()
7533            .map(|symbol| symbol.name.clone())
7534            .collect::<Vec<_>>();
7535    }
7536    names.sort();
7537    names.dedup();
7538    names.truncate(limit);
7539    if names.is_empty() {
7540        vec!["indexed symbols".to_string()]
7541    } else {
7542        names
7543    }
7544}
7545
7546/// Return relation targets for one relation kind.
7547pub(crate) fn relation_targets(
7548    graph: &SymbolGraph,
7549    kind: RelationKind,
7550    limit: usize,
7551) -> Vec<String> {
7552    let mut targets = graph
7553        .relations
7554        .iter()
7555        .filter(|relation| relation.kind == kind)
7556        .map(|relation| relation.target_name.clone())
7557        .collect::<Vec<_>>();
7558    targets.sort();
7559    targets.dedup();
7560    targets.truncate(limit);
7561    targets
7562}
7563
7564/// Create a generated file-purpose suggestion from a path and content summary.
7565pub(crate) fn suggest_file_purpose(path: &str, summary: &str) -> String {
7566    let subject = path_context_subject(path);
7567    if let Some(text) = summary
7568        .strip_prefix("pdf document text: ")
7569        .or_else(|| summary.strip_prefix("docx document text: "))
7570    {
7571        format!("Document {text}")
7572    } else if summary.contains("dataset manifest") {
7573        if let Some(datasets) = summary_between(summary, " including ", " and keys") {
7574            format!("Define the {subject} dataset manifest for {datasets}.")
7575        } else {
7576            format!("Define the {subject} dataset manifest.")
7577        }
7578    } else if let Some(workflow) = summary_between(summary, "yaml workflow ", " triggered") {
7579        format!("Define the {workflow} workflow.")
7580    } else if summary.contains("manifest") {
7581        if let Some(package) = summary_between(summary, " manifest for ", " with ") {
7582            format!("Define the {package} manifest.")
7583        } else {
7584            format!("Define the {subject} manifest.")
7585        }
7586    } else if let Some(title) = summary_between(summary, "document titled ", " with ") {
7587        format!("Document {title}.")
7588    } else if summary.contains("stylesheet") {
7589        format!("Style the {subject} stylesheet.")
7590    } else if summary.contains("config") {
7591        format!("Configure the {subject}.")
7592    } else if is_gradle_build_script(path) {
7593        if let Some(declarations) = summary_primary_declarations(summary) {
7594            format!("Define Gradle build tasks around {declarations}.")
7595        } else {
7596            "Configure the Gradle build.".to_string()
7597        }
7598    } else if let Some(declarations) = summary_primary_declarations(summary) {
7599        format!("Implement the {subject} source around {declarations}.")
7600    } else if summary.contains("source") {
7601        format!("Implement the {subject} source.")
7602    } else {
7603        format!("Implement the {subject}.")
7604    }
7605}
7606
7607/// Return whether a path is a Gradle build script rather than ordinary Kotlin/Groovy source.
7608fn is_gradle_build_script(path: &str) -> bool {
7609    let normalized = path.replace('\\', "/");
7610    normalized.ends_with("build.gradle") || normalized.ends_with("build.gradle.kts")
7611}
7612
7613/// Return a path-aware subject phrase for a generated purpose suggestion.
7614fn path_context_subject(path: &str) -> String {
7615    let normalized = path.replace('\\', "/");
7616    let mut segments = normalized
7617        .split('/')
7618        .filter(|segment| !segment.is_empty() && *segment != ".")
7619        .collect::<Vec<_>>();
7620    let Some(file_name) = segments.pop() else {
7621        return "path".to_string();
7622    };
7623    let stem = file_name
7624        .rsplit_once('.')
7625        .map_or(file_name, |(stem, _)| stem);
7626    let stem_words = path_segment_words(stem);
7627    let parent_words = segments
7628        .iter()
7629        .rev()
7630        .find(|segment| !is_generic_context_segment(segment))
7631        .map(|segment| path_segment_words(segment));
7632    match parent_words {
7633        Some(parent) if !parent.is_empty() && parent != stem_words => {
7634            format!("{parent} {stem_words}")
7635        }
7636        _ => stem_words,
7637    }
7638}
7639
7640/// Convert one path segment into readable lowercase words.
7641fn path_segment_words(segment: &str) -> String {
7642    let mut words = String::new();
7643    let mut previous_lowercase = false;
7644    for character in segment.chars() {
7645        if character == '-' || character == '_' || character == '.' {
7646            push_word_space(&mut words);
7647            previous_lowercase = false;
7648            continue;
7649        }
7650        if character.is_uppercase() && previous_lowercase {
7651            push_word_space(&mut words);
7652        }
7653        words.extend(character.to_lowercase());
7654        previous_lowercase = character.is_lowercase() || character.is_ascii_digit();
7655    }
7656    let words = words.trim();
7657    if words.is_empty() {
7658        "path".to_string()
7659    } else {
7660        words.to_string()
7661    }
7662}
7663
7664/// Append one word separator when the phrase already has content.
7665fn push_word_space(words: &mut String) {
7666    if !words.ends_with(' ') && !words.is_empty() {
7667        words.push(' ');
7668    }
7669}
7670
7671/// Return whether a path segment is too generic to add useful purpose context.
7672fn is_generic_context_segment(segment: &str) -> bool {
7673    matches!(
7674        segment.to_ascii_lowercase().as_str(),
7675        "src"
7676            | "source"
7677            | "sources"
7678            | "app"
7679            | "apps"
7680            | "lib"
7681            | "libs"
7682            | "crate"
7683            | "crates"
7684            | "package"
7685            | "packages"
7686            | "test"
7687            | "tests"
7688            | "spec"
7689            | "specs"
7690            | "fixture"
7691            | "fixtures"
7692            | "example"
7693            | "examples"
7694            | "script"
7695            | "scripts"
7696    )
7697}
7698
7699/// Extract primary declaration names from a deterministic content summary.
7700fn summary_primary_declarations(summary: &str) -> Option<String> {
7701    let after_marker = summary
7702        .split_once(" source defining ")
7703        .map(|(_, value)| value)
7704        .or_else(|| summary.split_once(" declaring ").map(|(_, value)| value))?;
7705    let declaration_clause = trim_summary_clause(after_marker);
7706    let names = strip_declaration_kind_prefix(declaration_clause)
7707        .split(',')
7708        .map(str::trim)
7709        .filter(|name| !name.is_empty())
7710        .take(3)
7711        .map(ToOwned::to_owned)
7712        .collect::<Vec<_>>();
7713    if names.is_empty() {
7714        None
7715    } else {
7716        Some(join_human_names(&names))
7717    }
7718}
7719
7720/// Trim trailing summary details from a declaration clause.
7721fn trim_summary_clause(value: &str) -> &str {
7722    value
7723        .split(" with imports ")
7724        .next()
7725        .unwrap_or(value)
7726        .split(" and depending on ")
7727        .next()
7728        .unwrap_or(value)
7729        .trim_end_matches('.')
7730        .trim()
7731}
7732
7733/// Remove the deterministic symbol-kind phrase before the primary names.
7734fn strip_declaration_kind_prefix(value: &str) -> &str {
7735    const PREFIXES: &[&str] = &[
7736        "types and functions ",
7737        "type and functions ",
7738        "types and function ",
7739        "type and function ",
7740        "manifest entries ",
7741        "functions ",
7742        "function ",
7743        "types ",
7744        "type ",
7745        "bindings ",
7746        "binding ",
7747        "values ",
7748        "value ",
7749        "symbols ",
7750    ];
7751    PREFIXES
7752        .iter()
7753        .find_map(|prefix| value.strip_prefix(prefix))
7754        .unwrap_or(value)
7755}
7756
7757/// Join declaration names as a compact human phrase.
7758fn join_human_names(names: &[String]) -> String {
7759    match names {
7760        [] => String::new(),
7761        [one] => one.clone(),
7762        [first, second] => format!("{first} and {second}"),
7763        [first, second, third, ..] => format!("{first}, {second}, and {third}"),
7764    }
7765}
7766
7767/// Return a non-empty substring between two markers.
7768fn summary_between<'a>(summary: &'a str, start: &str, end: &str) -> Option<&'a str> {
7769    let after_start = summary.split_once(start)?.1;
7770    let value = after_start.split_once(end)?.0.trim();
7771    (!value.is_empty()).then_some(value)
7772}
7773
7774/// Return whether a language should be parsed for symbols.
7775pub(crate) fn is_symbol_candidate(path: &str, language: Option<&str>) -> bool {
7776    let Some(language) = language else {
7777        let embedded_host = normalized_language_extension(Path::new(path))
7778            .and_then(|extension| detect_language_for_path(path, Some(&extension)))
7779            .and_then(|language| language_capability(&language))
7780            .is_some_and(|capability| capability.embedded_language.is_some());
7781        return embedded_host
7782            || path.ends_with("Cargo.toml")
7783            || path.ends_with("Cargo.lock")
7784            || Path::new(path)
7785                .extension()
7786                .and_then(|extension| extension.to_str())
7787                .is_some_and(|extension| {
7788                    ["ps1", "psm1", "psd1"]
7789                        .iter()
7790                        .any(|expected| extension.eq_ignore_ascii_case(expected))
7791                });
7792    };
7793    language_capability(language).is_none_or(|capability| {
7794        capability.symbol_parser != SymbolParserOwner::Unavailable
7795            || capability.embedded_language.is_some()
7796    })
7797}
7798
7799/// Apply the project-selected optional-language boundary to symbol work admission.
7800fn is_symbol_candidate_for_admission(
7801    path: &str,
7802    language: Option<&str>,
7803    admit_optional_languages: bool,
7804) -> bool {
7805    if !admit_optional_languages
7806        && language
7807            .and_then(language_capability)
7808            .is_some_and(|capability| capability.optional_pack.is_some())
7809    {
7810        return false;
7811    }
7812    is_symbol_candidate(path, language)
7813}
7814
7815/// Clear stale symbol output while preserving structural summaries when present.
7816fn clear_skipped_symbol_index(
7817    store: &AtlasStore,
7818    path: &str,
7819    language: Option<&str>,
7820) -> Result<(), CliError> {
7821    if is_structural_summary_candidate(path, language) {
7822        store.clear_symbol_graph_for_path(path)?;
7823    } else {
7824        store.clear_source_index_for_path(path)?;
7825    }
7826    Ok(())
7827}
7828
7829/// Normalize and validate a user-supplied path as a repository-relative file key.
7830pub(crate) fn validated_file_key(file: &Path) -> Result<String, CliError> {
7831    validated_repo_file_key(file).map_err(|source| CliError::InvalidInput(source.to_string()))
7832}
7833
7834/// Normalize a folder filter into the repository path convention.
7835pub(crate) fn normalized_folder_filter(folder: &str) -> Result<String, CliError> {
7836    let trimmed = folder.trim().trim_end_matches(['/', '\\']);
7837    if trimmed.is_empty() || trimmed == "." {
7838        return Ok(".".to_string());
7839    }
7840    validated_file_key(Path::new(trimmed)).map_err(|_error| {
7841        CliError::InvalidInput(format!(
7842            "folder filter {folder:?} must be a project-relative path"
7843        ))
7844    })
7845}
7846
7847/// Validate that a path belongs to the indexed project file set.
7848pub(crate) fn validated_indexed_file_key(
7849    store: &AtlasStore,
7850    file: &Path,
7851) -> Result<String, CliError> {
7852    let file_key = validated_file_key(file)?;
7853    let indexed = store
7854        .load_node_by_path(&file_key)?
7855        .ok_or_else(|| CliError::InvalidInput(format!("file {file_key:?} is not indexed")))?;
7856    if indexed.node.kind != NodeKind::File {
7857        return Err(CliError::InvalidInput(format!(
7858            "path {file_key:?} is not an indexed file"
7859        )));
7860    }
7861    Ok(file_key)
7862}
7863
7864/// Load the project root recorded by the latest scan.
7865pub(crate) fn indexed_project_root(store: &AtlasStore) -> Result<PathBuf, CliError> {
7866    store
7867        .project_root_identity()?
7868        .map(CanonicalProjectRoot::into_path)
7869        .ok_or_else(|| {
7870            CliError::InvalidInput(
7871                "indexed project root is missing; run projectatlas scan <project-root> first"
7872                    .to_string(),
7873            )
7874        })
7875}
7876
7877/// Build an absolute native path for a previously validated indexed file key.
7878pub(crate) fn indexed_native_path(store: &AtlasStore, file_key: &str) -> Result<PathBuf, CliError> {
7879    Ok(indexed_project_root(store)?.join(repo_path_to_native(file_key)))
7880}
7881
7882/// Read content for a previously validated indexed file key.
7883pub(crate) fn read_indexed_file_content(
7884    store: &AtlasStore,
7885    file_key: &str,
7886) -> Result<String, CliError> {
7887    let native = indexed_native_path(store, file_key)?;
7888    let indexed = store.load_node_by_path(file_key)?.ok_or_else(|| {
7889        CliError::InvalidInput(format!("indexed file {file_key:?} was not found"))
7890    })?;
7891    let project_root = lossless_project_root_display(&indexed_project_root(store)?);
7892    let metadata = match fs::metadata(&native) {
7893        Ok(metadata) => metadata,
7894        Err(source) if source.kind() == io::ErrorKind::NotFound => {
7895            return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7896                project_root,
7897                worktree: None,
7898                status: IndexReadStatus::RefreshRequired,
7899                reason: IndexRefreshReason::PathsChanged,
7900                scope: IndexRefreshScope::Full,
7901                changed: 1,
7902                added: 0,
7903                removed: 1,
7904                modified: 0,
7905                sample_paths: vec![file_key.to_string()],
7906            })));
7907        }
7908        Err(source) => {
7909            return Err(CliError::VerificationIncomplete(Box::new(
7910                IndexVerificationIncomplete {
7911                    project_root,
7912                    worktree: None,
7913                    status: IndexReadStatus::VerificationIncomplete,
7914                    reason: IndexVerificationReason::SourceInspectionFailed,
7915                    scope: IndexRefreshScope::Full,
7916                    message: format!("failed to read '{}': {source}", native.display()),
7917                },
7918            )));
7919        }
7920    };
7921    if indexed
7922        .node
7923        .size_bytes
7924        .is_some_and(|indexed_bytes| indexed_bytes != metadata.len())
7925    {
7926        return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7927            project_root,
7928            worktree: None,
7929            status: IndexReadStatus::RefreshRequired,
7930            reason: IndexRefreshReason::SourceChanged,
7931            scope: IndexRefreshScope::Full,
7932            changed: 1,
7933            added: 0,
7934            removed: 0,
7935            modified: 1,
7936            sample_paths: vec![file_key.to_string()],
7937        })));
7938    }
7939    if metadata.len() > MAX_INDEXED_NAVIGATION_SOURCE_BYTES {
7940        return Err(CliError::VerificationIncomplete(Box::new(
7941            IndexVerificationIncomplete {
7942                project_root,
7943                worktree: None,
7944                status: IndexReadStatus::VerificationIncomplete,
7945                reason: IndexVerificationReason::SourceTooLarge,
7946                scope: IndexRefreshScope::Full,
7947                message: format!(
7948                    "indexed file {file_key:?} contains {} bytes; bounded navigation reads admit at most {MAX_INDEXED_NAVIGATION_SOURCE_BYTES} bytes",
7949                    metadata.len()
7950                ),
7951            },
7952        )));
7953    }
7954    let file = fs::File::open(&native).map_err(|source| {
7955        CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
7956            project_root: project_root.clone(),
7957            worktree: None,
7958            status: IndexReadStatus::VerificationIncomplete,
7959            reason: IndexVerificationReason::SourceInspectionFailed,
7960            scope: IndexRefreshScope::Full,
7961            message: format!("failed to open '{}': {source}", native.display()),
7962        }))
7963    })?;
7964    let mut bytes = Vec::with_capacity(metadata.len() as usize);
7965    file.take(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1)
7966        .read_to_end(&mut bytes)
7967        .map_err(|source| {
7968            CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
7969                project_root: project_root.clone(),
7970                worktree: None,
7971                status: IndexReadStatus::VerificationIncomplete,
7972                reason: IndexVerificationReason::SourceInspectionFailed,
7973                scope: IndexRefreshScope::Full,
7974                message: format!("failed to read '{}': {source}", native.display()),
7975            }))
7976        })?;
7977    if bytes.len() as u64 != metadata.len()
7978        || bytes.len() as u64 > MAX_INDEXED_NAVIGATION_SOURCE_BYTES
7979    {
7980        return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7981            project_root,
7982            worktree: None,
7983            status: IndexReadStatus::RefreshRequired,
7984            reason: IndexRefreshReason::SourceChanged,
7985            scope: IndexRefreshScope::Full,
7986            changed: 1,
7987            added: 0,
7988            removed: 0,
7989            modified: 1,
7990            sample_paths: vec![file_key.to_string()],
7991        })));
7992    }
7993    let current_hash = blake3::hash(&bytes).to_hex().to_string();
7994    if indexed.node.content_hash.as_deref() != Some(current_hash.as_str()) {
7995        return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7996            project_root,
7997            worktree: None,
7998            status: IndexReadStatus::RefreshRequired,
7999            reason: IndexRefreshReason::SourceChanged,
8000            scope: IndexRefreshScope::Full,
8001            changed: 1,
8002            added: 0,
8003            removed: 0,
8004            modified: 1,
8005            sample_paths: vec![file_key.to_string()],
8006        })));
8007    }
8008    if document_format_for_path(file_key, indexed.node.language.as_deref()).is_some() {
8009        return extract_document_text_controlled(
8010            &bytes,
8011            file_key,
8012            indexed.node.language.as_deref(),
8013            &standalone_index_work_control(),
8014        )
8015        .map(|facts| facts.text)
8016        .map_err(|error| document_navigation_error(file_key, error));
8017    }
8018    String::from_utf8(bytes).map_err(|source| {
8019        CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
8020            project_root,
8021            worktree: None,
8022            status: IndexReadStatus::VerificationIncomplete,
8023            reason: IndexVerificationReason::SourceInspectionFailed,
8024            scope: IndexRefreshScope::Full,
8025            message: format!("indexed file {file_key:?} is not valid UTF-8: {source}"),
8026        }))
8027    })
8028}
8029
8030/// Run the watcher refresh loop.
8031pub(crate) fn run_watch_loop(
8032    store: &mut AtlasStore,
8033    plan: &ScanRuntimePlan,
8034    once: bool,
8035    poll_seconds: u64,
8036    max_cycles: usize,
8037    symbol_options: &SymbolBuildOptions,
8038) -> Result<WatchReport, CliError> {
8039    if once {
8040        return run_single_watch_refresh(store, plan, symbol_options);
8041    }
8042    run_watch_with_polling_fallback(
8043        store,
8044        plan,
8045        poll_seconds,
8046        max_cycles,
8047        symbol_options,
8048        |store| run_notify_watch_loop(store, plan, poll_seconds, max_cycles, symbol_options),
8049    )
8050}
8051
8052/// Run an event-backed watcher and preserve current changes through polling fallback.
8053fn run_watch_with_polling_fallback<F>(
8054    store: &mut AtlasStore,
8055    plan: &ScanRuntimePlan,
8056    poll_seconds: u64,
8057    max_cycles: usize,
8058    symbol_options: &SymbolBuildOptions,
8059    run_notify: F,
8060) -> Result<WatchReport, CliError>
8061where
8062    F: FnOnce(&mut AtlasStore) -> Result<WatchReport, CliError>,
8063{
8064    match run_notify(store) {
8065        Ok(report) => Ok(report),
8066        Err(error @ CliError::RefreshRequired(_)) => Err(error),
8067        Err(error) => run_polling_watch_loop(
8068            store,
8069            plan,
8070            poll_seconds,
8071            max_cycles,
8072            symbol_options,
8073            Some(error.to_string()),
8074        ),
8075    }
8076}
8077
8078/// Run one deterministic watcher refresh and exit.
8079pub(crate) fn run_single_watch_refresh(
8080    store: &mut AtlasStore,
8081    plan: &ScanRuntimePlan,
8082    symbol_options: &SymbolBuildOptions,
8083) -> Result<WatchReport, CliError> {
8084    let control = index_work_control(symbol_options);
8085    run_single_watch_refresh_controlled(store, plan, symbol_options, &control)
8086}
8087
8088/// Run one watcher refresh under one cancellation and publication boundary.
8089pub(crate) fn run_single_watch_refresh_controlled(
8090    store: &mut AtlasStore,
8091    plan: &ScanRuntimePlan,
8092    symbol_options: &SymbolBuildOptions,
8093    control: &IndexWorkControl,
8094) -> Result<WatchReport, CliError> {
8095    let bounded_control = bounded_index_work_control(control);
8096    let control = &bounded_control;
8097    control.check(IndexWorkStage::RepositoryTraversal)?;
8098    let current_plan = plan.reload_controlled(control)?;
8099    let last_refresh = refresh_index_controlled(store, &current_plan, symbol_options, control)?;
8100    Ok(WatchReport::from_refresh(last_refresh))
8101}
8102
8103/// Run an event-backed watcher loop with `notify`.
8104pub(crate) fn run_notify_watch_loop(
8105    store: &mut AtlasStore,
8106    plan: &ScanRuntimePlan,
8107    poll_seconds: u64,
8108    max_cycles: usize,
8109    symbol_options: &SymbolBuildOptions,
8110) -> Result<WatchReport, CliError> {
8111    let mut current_plan = plan.reload()?;
8112    let watch_root = current_plan
8113        .root
8114        .canonicalize()
8115        .map_err(|source| CliError::Io {
8116            path: current_plan.root.clone(),
8117            source,
8118        })?;
8119    let (sender, receiver) = mpsc::sync_channel(WATCH_EVENT_QUEUE_CAPACITY);
8120    let continuity_lost = Arc::new(AtomicBool::new(false));
8121    let callback_continuity_lost = Arc::clone(&continuity_lost);
8122    let mut watcher = RecommendedWatcher::new(
8123        move |result: notify::Result<Event>| {
8124            match sender.try_send(result) {
8125                Ok(()) => {}
8126                Err(TrySendError::Full(_result)) => {
8127                    callback_continuity_lost.store(true, Ordering::Release);
8128                }
8129                Err(TrySendError::Disconnected(_result)) => {
8130                    // Receiver shutdown means the command is exiting.
8131                }
8132            }
8133        },
8134        Config::default(),
8135    )
8136    .map_err(|source| CliError::Watcher(source.to_string()))?;
8137    watcher
8138        .watch(&watch_root, RecursiveMode::Recursive)
8139        .map_err(|source| CliError::Watcher(source.to_string()))?;
8140    let debounce = Duration::from_secs(poll_seconds.max(1));
8141    let mut cycles = 0;
8142    let mut last_refresh = refresh_index(store, &current_plan, symbol_options)?;
8143    cycles += 1;
8144    while max_cycles == 0 || cycles < max_cycles {
8145        let changes = wait_for_index_event_with_continuity(
8146            &receiver,
8147            &watch_root,
8148            debounce,
8149            &current_plan.scan_options,
8150            &continuity_lost,
8151        )?;
8152        if changes.has_changes() {
8153            current_plan = plan.reload()?;
8154            last_refresh =
8155                refresh_index_for_changes(store, &current_plan, &changes, symbol_options)?;
8156            cycles += 1;
8157        }
8158    }
8159    Ok(WatchReport {
8160        mode: WATCH_MODE_NOTIFY.to_string(),
8161        cycles,
8162        once: false,
8163        fallback_reason: None,
8164        text_index: last_refresh.text_index,
8165        structural_summaries: last_refresh.structural_summaries,
8166        last_symbols: last_refresh.symbols,
8167    })
8168}
8169
8170/// Wait for one bounded event batch and preserve local queue-overflow uncertainty.
8171fn wait_for_index_event_with_continuity(
8172    receiver: &mpsc::Receiver<notify::Result<Event>>,
8173    root: &Path,
8174    debounce: Duration,
8175    scan_options: &ScanOptions,
8176    continuity_lost: &AtomicBool,
8177) -> Result<WatchChangeSet, CliError> {
8178    let mut changes = notify_result_changes(
8179        root,
8180        scan_options,
8181        receiver.recv().map_err(|source| {
8182            CliError::Watcher(format!("watch event channel disconnected: {source}"))
8183        })?,
8184    )?;
8185    loop {
8186        match receiver.recv_timeout(debounce) {
8187            Ok(result) => {
8188                changes.merge(notify_result_changes(root, scan_options, result)?);
8189            }
8190            Err(RecvTimeoutError::Timeout) => break,
8191            Err(RecvTimeoutError::Disconnected) => {
8192                return Err(CliError::Watcher(
8193                    "watch event channel disconnected".to_string(),
8194                ));
8195            }
8196        }
8197    }
8198    if continuity_lost.swap(false, Ordering::AcqRel) {
8199        changes.requires_full_scan = true;
8200    }
8201    Ok(changes)
8202}
8203
8204/// Convert a `notify` result into index-relevant changes.
8205pub(crate) fn notify_result_changes(
8206    root: &Path,
8207    scan_options: &ScanOptions,
8208    result: notify::Result<Event>,
8209) -> Result<WatchChangeSet, CliError> {
8210    let event = result.map_err(|source| CliError::Watcher(source.to_string()))?;
8211    Ok(notify_event_changes(root, scan_options, &event))
8212}
8213
8214/// Convert a `notify` event into index-relevant changes.
8215pub(crate) fn notify_event_changes(
8216    root: &Path,
8217    scan_options: &ScanOptions,
8218    event: &Event,
8219) -> WatchChangeSet {
8220    if !event_kind_affects_index(event.kind) {
8221        return WatchChangeSet::default();
8222    }
8223    let mut changes = WatchChangeSet {
8224        requires_full_scan: event.need_rescan(),
8225        paths: HashSet::new(),
8226        document_paths: HashSet::new(),
8227    };
8228    for path in &event.paths {
8229        let candidate = absolute_watch_path(root, path);
8230        if safe_watch_relative_path(root, &candidate).is_some() {
8231            changes.document_paths.insert(candidate.clone());
8232        }
8233        if watch_path_requires_full_scan(root, &candidate) {
8234            changes.requires_full_scan = true;
8235            changes.paths.insert(candidate);
8236            continue;
8237        }
8238        let Some(index_path) = normalized_watch_index_path(root, path, scan_options) else {
8239            continue;
8240        };
8241        if matches!(
8242            event.kind,
8243            EventKind::Modify(notify::event::ModifyKind::Name(_))
8244        ) || watch_path_requires_full_scan(root, &index_path)
8245        {
8246            changes.requires_full_scan = true;
8247        }
8248        changes.paths.insert(index_path);
8249    }
8250    changes
8251}
8252
8253/// Return whether a `notify` event kind can change indexed content.
8254pub(crate) fn event_kind_affects_index(kind: EventKind) -> bool {
8255    !matches!(kind, EventKind::Access(_))
8256}
8257
8258/// Return whether a native event path belongs to indexed repository content.
8259#[cfg(test)]
8260pub(crate) fn watch_path_affects_index(
8261    root: &Path,
8262    path: &Path,
8263    scan_options: &ScanOptions,
8264) -> bool {
8265    normalized_watch_index_path(root, path, scan_options).is_some()
8266}
8267
8268/// Return one repository-contained native path after watcher normalization and policy checks.
8269fn normalized_watch_index_path(
8270    root: &Path,
8271    path: &Path,
8272    scan_options: &ScanOptions,
8273) -> Option<PathBuf> {
8274    let candidate = absolute_watch_path(root, path);
8275    let relative = safe_watch_relative_path(root, &candidate)?;
8276    if relative == "." {
8277        return Some(root.to_path_buf());
8278    }
8279    let policy_path = if candidate.strip_prefix(root).is_ok() {
8280        candidate.clone()
8281    } else {
8282        match candidate.try_exists() {
8283            Ok(true) => candidate.clone(),
8284            Ok(false) => root.join(repo_path_to_native(&relative)),
8285            Err(_) => return None,
8286        }
8287    };
8288    // Unknown ignore state should not admit a path into the incremental index.
8289    let Ok(gitignore_ignored) = gitignore_excludes_path(root, &policy_path) else {
8290        return None;
8291    };
8292    if gitignore_ignored {
8293        return None;
8294    }
8295    if relative.split('/').any(|component| component == ".purpose")
8296        || scan_options.excludes_relative_path(&relative)
8297    {
8298        return None;
8299    }
8300    Some(candidate)
8301}
8302
8303/// Return a safe normalized repository path for a watcher event.
8304fn safe_watch_relative_path(root: &Path, candidate: &Path) -> Option<String> {
8305    let relative = normalize_repo_path(root, candidate)
8306        .ok()
8307        .or_else(|| native_display_relative_path(root, candidate))?;
8308    valid_watch_relative_path(relative)
8309}
8310
8311/// Reconcile equivalent native paths when Windows extended prefixes differ.
8312fn native_display_relative_path(root: &Path, candidate: &Path) -> Option<String> {
8313    let root = normalize_native_path_display_str(root.to_str()?);
8314    let candidate = normalize_native_path_display_str(candidate.to_str()?);
8315    let root = if root == "/" {
8316        root.as_str()
8317    } else {
8318        root.trim_end_matches('/')
8319    };
8320    if candidate == root || cfg!(windows) && candidate.eq_ignore_ascii_case(root) {
8321        return Some(".".to_string());
8322    }
8323    let prefix = if root == "/" {
8324        "/".to_string()
8325    } else {
8326        format!("{root}/")
8327    };
8328    if let Some(relative) = candidate.strip_prefix(&prefix) {
8329        return Some(relative.to_string());
8330    }
8331    #[cfg(windows)]
8332    {
8333        let prefix_candidate = candidate.get(..prefix.len())?;
8334        if prefix_candidate.eq_ignore_ascii_case(&prefix) {
8335            return candidate.get(prefix.len()..).map(ToOwned::to_owned);
8336        }
8337    }
8338    None
8339}
8340
8341/// Reject empty, current-directory, and parent traversal path components.
8342fn valid_watch_relative_path(relative: String) -> Option<String> {
8343    if relative == "." {
8344        return Some(relative);
8345    }
8346    if relative
8347        .split('/')
8348        .any(|component| component.is_empty() || component == "." || component == "..")
8349    {
8350        return None;
8351    }
8352    Some(relative)
8353}
8354
8355/// Return an absolute path for a watcher event path.
8356pub(crate) fn absolute_watch_path(root: &Path, path: &Path) -> PathBuf {
8357    if path.is_absolute() {
8358        path.to_path_buf()
8359    } else {
8360        root.join(path)
8361    }
8362}
8363
8364/// Return whether a path event requires a full scan for correctness.
8365pub(crate) fn watch_path_requires_full_scan(root: &Path, path: &Path) -> bool {
8366    let Some(relative) = safe_watch_relative_path(root, path) else {
8367        return false;
8368    };
8369    if relative == "." {
8370        return true;
8371    }
8372    path.is_dir()
8373        || matches!(relative.rsplit('/').next(), Some(".gitignore" | ".ignore"))
8374        || relative == ".git"
8375        || relative.ends_with("/.git")
8376        || relative == ".git/info/exclude"
8377        || relative.ends_with("/.git/info/exclude")
8378        || matches!(
8379            relative.rsplit('/').next(),
8380            Some("tsconfig.json" | "jsconfig.json")
8381        )
8382        || index_policy_path(relative.as_str())
8383}
8384
8385/// Return whether one repository-relative path owns derived-index policy.
8386fn index_policy_path(relative: &str) -> bool {
8387    CORE_INDEX_POLICY_PATHS.contains(&relative)
8388        || cfg!(feature = "optional-parser-supervisor") && {
8389            #[cfg(feature = "optional-parser-supervisor")]
8390            {
8391                relative == OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH
8392            }
8393            #[cfg(not(feature = "optional-parser-supervisor"))]
8394            {
8395                false
8396            }
8397        }
8398}
8399
8400/// Run the portable polling watcher fallback loop.
8401pub(crate) fn run_polling_watch_loop(
8402    store: &mut AtlasStore,
8403    plan: &ScanRuntimePlan,
8404    poll_seconds: u64,
8405    max_cycles: usize,
8406    symbol_options: &SymbolBuildOptions,
8407    fallback_reason: Option<String>,
8408) -> Result<WatchReport, CliError> {
8409    let mut cycles = 0;
8410    let mut current_plan = plan.reload()?;
8411    let mut last_refresh = refresh_index(store, &current_plan, symbol_options)?;
8412    cycles += 1;
8413    while max_cycles == 0 || cycles < max_cycles {
8414        thread::sleep(Duration::from_secs(poll_seconds.max(1)));
8415        current_plan = plan.reload()?;
8416        last_refresh = refresh_index(store, &current_plan, symbol_options)?;
8417        cycles += 1;
8418    }
8419    Ok(WatchReport {
8420        mode: WATCH_MODE_POLLING.to_string(),
8421        cycles,
8422        once: false,
8423        fallback_reason,
8424        text_index: last_refresh.text_index,
8425        structural_summaries: last_refresh.structural_summaries,
8426        last_symbols: last_refresh.symbols,
8427    })
8428}
8429
8430/// Combined refresh output for watcher and one-shot refresh paths.
8431pub(crate) struct IndexRefreshReport {
8432    /// Persisted text search index refresh report.
8433    pub(crate) text_index: TextIndexReport,
8434    /// Structural summary refresh report.
8435    pub(crate) structural_summaries: StructuralSummaryReport,
8436    /// Deep symbol graph refresh report.
8437    symbols: SymbolBuildReport,
8438}
8439
8440/// Refresh filesystem and symbol state.
8441pub(crate) fn refresh_index(
8442    store: &mut AtlasStore,
8443    plan: &ScanRuntimePlan,
8444    symbol_options: &SymbolBuildOptions,
8445) -> Result<IndexRefreshReport, CliError> {
8446    let control = index_work_control(symbol_options);
8447    refresh_index_controlled(store, plan, symbol_options, &control)
8448}
8449
8450/// Refresh every derived projection under one cancellation boundary.
8451pub(crate) fn refresh_index_controlled(
8452    store: &mut AtlasStore,
8453    plan: &ScanRuntimePlan,
8454    symbol_options: &SymbolBuildOptions,
8455    control: &IndexWorkControl,
8456) -> Result<IndexRefreshReport, CliError> {
8457    let bounded_control = bounded_index_work_control(control);
8458    let control = &bounded_control;
8459    let reuse_unchanged_symbols = publication_contract_matches(store, plan)?;
8460    if reuse_unchanged_symbols {
8461        let assessment =
8462            detect_index_freshness_controlled(store, plan, ScanLimits::default(), control)?;
8463        return refresh_index_from_assessment_controlled(
8464            store,
8465            plan,
8466            symbol_options,
8467            assessment,
8468            control,
8469        );
8470    }
8471    refresh_full_index_controlled(
8472        store,
8473        plan,
8474        symbol_options,
8475        reuse_unchanged_symbols,
8476        control,
8477    )
8478}
8479
8480/// Consume exact read-side discovery through the existing affected or full refresh owner.
8481pub(crate) fn refresh_index_from_assessment_controlled(
8482    store: &mut AtlasStore,
8483    plan: &ScanRuntimePlan,
8484    symbol_options: &SymbolBuildOptions,
8485    assessment: IndexFreshnessAssessment,
8486    control: &IndexWorkControl,
8487) -> Result<IndexRefreshReport, CliError> {
8488    verify_captured_source_identity(store, plan, &assessment.source)?;
8489    match assessment.delta {
8490        None => {
8491            graph_projection::cleanup_abandoned_repository_graph_staging(
8492                store, &plan.root, control,
8493            )?;
8494            return Ok(empty_index_refresh_report(plan.text_options));
8495        }
8496        Some(delta) if delta.paths_complete && !delta.requires_full_scan => {
8497            let changes = WatchChangeSet {
8498                requires_full_scan: false,
8499                document_paths: delta.paths.clone(),
8500                paths: delta.paths,
8501            };
8502            match refresh_index_for_changes_from_source_controlled(
8503                store,
8504                plan,
8505                &changes,
8506                symbol_options,
8507                Some(assessment.source),
8508                control,
8509            ) {
8510                Ok(report) => return Ok(report),
8511                Err(error) if incremental_refresh_needs_full(&error) => {}
8512                Err(error) => return Err(error),
8513            }
8514        }
8515        Some(_) => {
8516            return refresh_full_index_from_source_controlled(
8517                store,
8518                plan,
8519                symbol_options,
8520                true,
8521                Some(assessment.source),
8522                control,
8523            );
8524        }
8525    }
8526    refresh_full_index_controlled(store, plan, symbol_options, true, control)
8527}
8528
8529/// Reconcile complete state when policy or an incremental work bound requires it.
8530fn refresh_full_index_controlled(
8531    store: &mut AtlasStore,
8532    plan: &ScanRuntimePlan,
8533    symbol_options: &SymbolBuildOptions,
8534    reuse_unchanged_symbols: bool,
8535    control: &IndexWorkControl,
8536) -> Result<IndexRefreshReport, CliError> {
8537    refresh_full_index_from_source_controlled(
8538        store,
8539        plan,
8540        symbol_options,
8541        reuse_unchanged_symbols,
8542        None,
8543        control,
8544    )
8545}
8546
8547/// Keep the exact inventory alive only in the full staging owner that consumes it.
8548fn refresh_full_index_from_source_controlled(
8549    store: &mut AtlasStore,
8550    plan: &ScanRuntimePlan,
8551    symbol_options: &SymbolBuildOptions,
8552    reuse_unchanged_symbols: bool,
8553    captured: Option<CapturedSourceState>,
8554    control: &IndexWorkControl,
8555) -> Result<IndexRefreshReport, CliError> {
8556    control.check(IndexWorkStage::Publication)?;
8557    store.probe_index_publication_writer()?;
8558    let batch = stage_full_index_publication_from_source(
8559        store,
8560        plan,
8561        symbol_options,
8562        reuse_unchanged_symbols,
8563        false,
8564        captured,
8565        control,
8566    )?;
8567    revalidate_staged_publication_inputs_controlled(
8568        plan,
8569        batch.nodes.expected_nodes(),
8570        None,
8571        control,
8572    )?;
8573    if staged_full_refresh_is_unchanged(store, &batch)? {
8574        return Ok(empty_index_refresh_report(plan.text_options));
8575    }
8576    let outcome = publish_index_batch(store, batch, control)?;
8577    Ok(IndexRefreshReport {
8578        text_index: outcome.text_index,
8579        structural_summaries: outcome.structural_summaries,
8580        symbols: outcome.symbols,
8581    })
8582}
8583
8584/// Return whether one fully staged watcher refresh matches complete durable state.
8585fn staged_full_refresh_is_unchanged(
8586    store: &AtlasStore,
8587    batch: &IndexPublicationBatch,
8588) -> Result<bool, CliError> {
8589    let NodePublicationBatch::Full { nodes: expected } = &batch.nodes else {
8590        return Ok(false);
8591    };
8592    if batch.purpose_import.is_some() {
8593        return Ok(false);
8594    }
8595    let Some(publication) = store.index_publication()? else {
8596        return Ok(false);
8597    };
8598    if publication.state != IndexPublicationState::Complete
8599        || publication.generation != batch.base_generation
8600        || publication.contract_fingerprint.as_deref() != Some(batch.contract_fingerprint.as_str())
8601    {
8602        return Ok(false);
8603    }
8604    let current = store
8605        .load_nodes()?
8606        .into_iter()
8607        .map(|indexed| indexed.node)
8608        .collect::<Vec<_>>();
8609    Ok(current.len() == expected.len()
8610        && current
8611            .iter()
8612            .zip(expected)
8613            .all(|(current, expected)| same_indexed_source(current, expected)))
8614}
8615
8616/// Build the stable report for a verified watcher no-op.
8617fn empty_index_refresh_report(text_options: TextIndexOptions) -> IndexRefreshReport {
8618    IndexRefreshReport {
8619        text_index: empty_text_index_report(text_options),
8620        structural_summaries: StructuralSummaryReport::default(),
8621        symbols: empty_symbol_build_report(),
8622    }
8623}
8624
8625/// Refresh filesystem and symbol state for a debounced event batch.
8626pub(crate) fn refresh_index_for_changes(
8627    store: &mut AtlasStore,
8628    plan: &ScanRuntimePlan,
8629    changes: &WatchChangeSet,
8630    symbol_options: &SymbolBuildOptions,
8631) -> Result<IndexRefreshReport, CliError> {
8632    let control = index_work_control(symbol_options);
8633    match refresh_index_for_changes_controlled(store, plan, changes, symbol_options, &control) {
8634        Err(error) if incremental_refresh_needs_full(&error) => {
8635            let reuse = publication_contract_matches(store, plan)?;
8636            refresh_full_index_controlled(store, plan, symbol_options, reuse, &control)
8637        }
8638        result => result,
8639    }
8640}
8641
8642/// Identify explicit-refresh uncertainty that can safely use complete reconciliation.
8643fn incremental_refresh_needs_full(error: &CliError) -> bool {
8644    matches!(error,
8645        CliError::RefreshRequired(report)
8646            if report.reason == IndexRefreshReason::DependencyClosureLimit
8647    ) || matches!(
8648        error,
8649        CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
8650            resource: IndexWorkResource::SourceBytes | IndexWorkResource::Entries,
8651            ..
8652        })
8653    )
8654}
8655
8656/// Refresh one watcher batch under one cancellation and publication boundary.
8657pub(crate) fn refresh_index_for_changes_controlled(
8658    store: &mut AtlasStore,
8659    plan: &ScanRuntimePlan,
8660    changes: &WatchChangeSet,
8661    symbol_options: &SymbolBuildOptions,
8662    control: &IndexWorkControl,
8663) -> Result<IndexRefreshReport, CliError> {
8664    refresh_index_for_changes_from_source_controlled(
8665        store,
8666        plan,
8667        changes,
8668        symbol_options,
8669        None,
8670        control,
8671    )
8672}
8673
8674/// Consume optional exact discovery instead of inspecting affected paths again.
8675fn refresh_index_for_changes_from_source_controlled(
8676    store: &mut AtlasStore,
8677    plan: &ScanRuntimePlan,
8678    changes: &WatchChangeSet,
8679    symbol_options: &SymbolBuildOptions,
8680    captured: Option<CapturedSourceState>,
8681    control: &IndexWorkControl,
8682) -> Result<IndexRefreshReport, CliError> {
8683    let bounded_control = bounded_index_work_control(control);
8684    let control = &bounded_control;
8685    control.check(IndexWorkStage::RepositoryTraversal)?;
8686    if changes.requires_full_scan || !publication_contract_matches(store, plan)? {
8687        return refresh_index_controlled(store, plan, symbol_options, control);
8688    }
8689    let changed_event_path_count = changes.paths.union(&changes.document_paths).count();
8690    if changed_event_path_count > MAX_INCREMENTAL_CHANGED_PATHS {
8691        return Err(IndexWorkFailure::resource_limit(
8692            IndexWorkStage::RepositoryTraversal,
8693            IndexWorkResource::Entries,
8694            MAX_INCREMENTAL_CHANGED_PATHS as u64,
8695            changed_event_path_count as u64,
8696        )
8697        .into());
8698    }
8699    let root = &plan.root;
8700    let base_generation = publication_base_generation(store)?;
8701    if let Some(source) = &captured {
8702        verify_captured_source_identity(store, plan, source)?;
8703    }
8704    let mut baseline_nodes = Vec::new();
8705    if captured.is_none() {
8706        store.visit_source_nodes_controlled(control, |node| {
8707            baseline_nodes.push(node);
8708            Ok(())
8709        })?;
8710    }
8711    let mut nodes = Vec::new();
8712    let mut absent_paths = Vec::new();
8713    let mut source_bytes = 0_u64;
8714    let scan_policy = RootScanPolicy::discover(root, &plan.scan_options, control)
8715        .map_err(|source| source_inspection_error(root, source))?;
8716    let mut direct_document_paths = changes
8717        .paths
8718        .union(&changes.document_paths)
8719        .map(|path| normalized_deleted_path(root, path))
8720        .collect::<Result<Vec<_>, _>>()?
8721        .into_iter()
8722        .flatten()
8723        .collect::<BTreeSet<_>>();
8724    for path in sorted_watch_paths(&changes.paths) {
8725        control.check(IndexWorkStage::RepositoryTraversal)?;
8726        let node = if let Some(source) = &captured {
8727            normalized_deleted_path(root, &path)?.and_then(|key| {
8728                source
8729                    .nodes
8730                    .binary_search_by(|node| node.path.cmp(&key))
8731                    .ok()
8732                    .map(|index| source.nodes[index].clone())
8733            })
8734        } else {
8735            scan_incremental_path_controlled(
8736                root,
8737                &scan_policy,
8738                &path,
8739                MAX_INCREMENTAL_SOURCE_BYTES.saturating_sub(source_bytes),
8740                control,
8741            )?
8742        };
8743        if let Some(node) = node {
8744            source_bytes = source_bytes
8745                .checked_add(node.size_bytes.unwrap_or_default())
8746                .ok_or_else(|| {
8747                    IndexWorkFailure::resource_limit(
8748                        IndexWorkStage::SourceMetadata,
8749                        IndexWorkResource::SourceBytes,
8750                        MAX_INCREMENTAL_SOURCE_BYTES,
8751                        u64::MAX,
8752                    )
8753                })?;
8754            if source_bytes > MAX_INCREMENTAL_SOURCE_BYTES {
8755                return Err(IndexWorkFailure::resource_limit(
8756                    IndexWorkStage::SourceMetadata,
8757                    IndexWorkResource::SourceBytes,
8758                    MAX_INCREMENTAL_SOURCE_BYTES,
8759                    source_bytes,
8760                )
8761                .into());
8762            }
8763            nodes.push(node);
8764        } else if let Some(key) = normalized_deleted_path(root, &path)? {
8765            absent_paths.push(key);
8766        }
8767    }
8768    absent_paths.sort();
8769    absent_paths.dedup();
8770    let sorted_candidate_paths = nodes
8771        .iter()
8772        .map(|node| node.path.clone())
8773        .chain(absent_paths.iter().cloned())
8774        .chain(direct_document_paths.iter().cloned())
8775        .collect::<BTreeSet<_>>()
8776        .into_iter()
8777        .collect::<Vec<_>>();
8778    let existing_nodes = store
8779        .load_nodes_by_paths_controlled(&sorted_candidate_paths, Some(control))?
8780        .into_iter()
8781        .map(|indexed| (indexed.node.path.clone(), indexed.node))
8782        .collect::<HashMap<_, _>>();
8783    if absent_paths.iter().any(|path| {
8784        existing_nodes
8785            .get(path)
8786            .is_some_and(|node| node.kind == NodeKind::Folder)
8787    }) {
8788        return refresh_index_controlled(store, plan, symbol_options, control);
8789    }
8790    graph_projection::cleanup_abandoned_repository_graph_staging(store, root, control)?;
8791    direct_document_paths.extend(
8792        nodes
8793            .iter()
8794            .map(|node| node.path.clone())
8795            .chain(absent_paths.iter().cloned()),
8796    );
8797    nodes.retain(|node| {
8798        existing_nodes
8799            .get(&node.path)
8800            .is_none_or(|indexed| !same_indexed_source(node, indexed))
8801    });
8802    absent_paths.retain(|path| existing_nodes.contains_key(path));
8803    let changed_paths = nodes
8804        .iter()
8805        .map(|node| node.path.clone())
8806        .chain(absent_paths.iter().cloned())
8807        .collect::<HashSet<_>>();
8808    direct_document_paths
8809        .retain(|path| changed_paths.contains(path) || !existing_nodes.contains_key(path));
8810    let expected_nodes = match captured {
8811        Some(source) => source.nodes,
8812        None => expected_nodes_after_incremental(baseline_nodes, &nodes, &absent_paths),
8813    };
8814    if nodes.is_empty() && absent_paths.is_empty() && direct_document_paths.is_empty() {
8815        revalidate_staged_publication_inputs_controlled(plan, &expected_nodes, None, control)?;
8816        return Ok(empty_index_refresh_report(plan.text_options));
8817    }
8818    control.check(IndexWorkStage::Publication)?;
8819    store.probe_index_publication_writer()?;
8820    drop(existing_nodes);
8821    let previous_hashes = indexed_file_hashes_for_paths(store, &changed_paths)?;
8822    let mut text_paths = changed_paths.iter().cloned().collect::<Vec<_>>();
8823    text_paths.sort();
8824    let text =
8825        stage_text_index_for_changed_paths_controlled(root, &nodes, plan.text_options, control)?;
8826    let content_classifications = stage_file_content_classifications(&nodes, &text.rows);
8827    let protected_purpose_paths = protected_purpose_paths(&nodes, None);
8828    let target_paths = nodes
8829        .iter()
8830        .filter(|node| node.kind == NodeKind::File)
8831        .map(|node| node.path.clone())
8832        .collect::<HashSet<_>>();
8833    let contract_fingerprint = plan.publication_contract_fingerprint();
8834    let retained_before_symbols = staged_publication_identity_bytes(root, &contract_fingerprint)
8835        .saturating_add(staged_string_bytes(&text_paths))
8836        .saturating_add(staged_string_bytes(&absent_paths))
8837        .saturating_add(staged_node_bytes(&expected_nodes))
8838        .saturating_add(staged_node_bytes(&nodes))
8839        .saturating_add(staged_text_bytes(&text))
8840        .saturating_add(staged_file_content_classification_bytes(
8841            &content_classifications,
8842        ));
8843    let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
8844    let mut symbols = stage_symbols_for_nodes_with_limits(
8845        store,
8846        root,
8847        #[cfg(feature = "optional-parser-supervisor")]
8848        &plan.optional_parser_selection,
8849        &nodes,
8850        symbol_options,
8851        Some(&previous_hashes),
8852        Some(&target_paths),
8853        &text.rows,
8854        &protected_purpose_paths,
8855        control,
8856        symbol_limits,
8857    )?;
8858    let graph = graph_projection::stage_incremental_repository_graph_with_limit(
8859        store,
8860        root,
8861        base_generation,
8862        &expected_nodes,
8863        &direct_document_paths.into_iter().collect::<Vec<_>>(),
8864        &scan_policy,
8865        &symbols,
8866        control,
8867        MAX_PUBLICATION_STAGING_BYTES.saturating_sub(retained_before_symbols),
8868    )?;
8869    graph.account_source_recovery(&mut symbols.report)?;
8870    let structural_summaries = stage_structural_summaries_for_nodes_controlled(
8871        store,
8872        &nodes,
8873        &text.rows,
8874        Some(&symbols),
8875        &protected_purpose_paths,
8876        symbol_options.effective_workers(),
8877        control,
8878    )?;
8879    enforce_publication_staging_budget(
8880        retained_before_symbols
8881            .saturating_add(symbols.retained_bytes)
8882            .saturating_add(graph.retained_bytes())
8883            .saturating_add(structural_summaries.retained_bytes),
8884    )?;
8885    let batch = IndexPublicationBatch {
8886        base_generation,
8887        contract_fingerprint,
8888        root: root.clone(),
8889        nodes: NodePublicationBatch::Incremental {
8890            nodes,
8891            absent_paths,
8892            expected_nodes,
8893        },
8894        purpose_import: None,
8895        text_paths,
8896        text,
8897        content_classifications,
8898        symbols,
8899        graph,
8900        structural_summaries,
8901    };
8902    revalidate_staged_publication_inputs_controlled(
8903        plan,
8904        batch.nodes.expected_nodes(),
8905        None,
8906        control,
8907    )?;
8908    let outcome = publish_index_batch(store, batch, control)?;
8909    Ok(IndexRefreshReport {
8910        text_index: outcome.text_index,
8911        structural_summaries: outcome.structural_summaries,
8912        symbols: outcome.symbols,
8913    })
8914}
8915
8916/// Fence operation-owned discovery against the current publication and selected plan.
8917fn verify_captured_source_identity(
8918    store: &AtlasStore,
8919    plan: &ScanRuntimePlan,
8920    source: &CapturedSourceState,
8921) -> Result<(), CliError> {
8922    if store.captured_project_binding()?.project_instance_id != source.project_instance_id {
8923        return Err(CliError::InvalidInput(
8924            "captured source belongs to a different project database".to_string(),
8925        ));
8926    }
8927    if source.root != plan.root
8928        || source.contract_fingerprint != plan.publication_contract_fingerprint()
8929    {
8930        return Err(CliError::RefreshRequired(Box::new(
8931            index_policy_refresh_required(&plan.root),
8932        )));
8933    }
8934    let generation = verify_index_publication(store, plan)?;
8935    if source.generation != generation {
8936        return Err(projectatlas_db::DbError::PublicationBaseGenerationChanged {
8937            expected: source.generation,
8938            found: generation,
8939        }
8940        .into());
8941    }
8942    Ok(())
8943}
8944
8945/// Inspect one affected path when no exact operation-owned discovery is available.
8946fn scan_incremental_path_controlled(
8947    root: &Path,
8948    policy: &RootScanPolicy,
8949    path: &Path,
8950    remaining_source_bytes: u64,
8951    control: &IndexWorkControl,
8952) -> Result<Option<Node>, CliError> {
8953    match path.try_exists() {
8954        Ok(true) => scan_path_with_policy_controlled(
8955            policy,
8956            path,
8957            ScanLimits::new(1, remaining_source_bytes, 1),
8958            control,
8959        )
8960        .map_err(|source| source_inspection_error(root, source)),
8961        Ok(false) => Ok(None),
8962        Err(source) => Err(CliError::VerificationIncomplete(Box::new(
8963            IndexVerificationIncomplete {
8964                project_root: lossless_project_root_display(root),
8965                worktree: None,
8966                status: IndexReadStatus::VerificationIncomplete,
8967                reason: IndexVerificationReason::SourceInspectionFailed,
8968                scope: IndexRefreshScope::Full,
8969                message: format!("failed to inspect '{}': {source}", path.display()),
8970            },
8971        ))),
8972    }
8973}
8974
8975/// Seed built-in purposes for reserved `ProjectAtlas` metadata nodes when needed.
8976pub(crate) fn seed_builtin_projectatlas_purposes(
8977    store: &AtlasStore,
8978    nodes: &[Node],
8979) -> Result<(), CliError> {
8980    let indexed_paths = nodes
8981        .iter()
8982        .map(|node| node.path.as_str())
8983        .collect::<HashSet<_>>();
8984    for (path, purpose) in BUILTIN_PROJECTATLAS_PURPOSES {
8985        if !indexed_paths.contains(path) {
8986            continue;
8987        }
8988        let Some(indexed) = store.load_node_by_path(path)? else {
8989            continue;
8990        };
8991        if !matches!(
8992            indexed.purpose.status,
8993            PurposeStatus::Approved | PurposeStatus::Stale
8994        ) {
8995            store.set_purpose(path, purpose, PurposeSource::Imported)?;
8996        }
8997    }
8998    Ok(())
8999}
9000
9001/// Refresh structural summaries while observing the operation work boundary.
9002#[cfg(test)]
9003pub(crate) fn refresh_structural_summaries_for_nodes(
9004    store: &mut AtlasStore,
9005    nodes: &[Node],
9006    text_rows: &[TextIndexRow],
9007) -> Result<StructuralSummaryReport, CliError> {
9008    let control = standalone_index_work_control();
9009    refresh_structural_summaries_for_nodes_controlled(store, nodes, text_rows, &control)
9010}
9011
9012/// Refresh structural summaries while observing the operation work boundary.
9013#[cfg(test)]
9014fn refresh_structural_summaries_for_nodes_controlled(
9015    store: &mut AtlasStore,
9016    nodes: &[Node],
9017    text_rows: &[TextIndexRow],
9018    control: &IndexWorkControl,
9019) -> Result<StructuralSummaryReport, CliError> {
9020    let staged = stage_structural_summaries_for_nodes_controlled(
9021        store,
9022        nodes,
9023        text_rows,
9024        None,
9025        &HashSet::new(),
9026        2,
9027        control,
9028    )?;
9029    apply_structural_summary_stage(store, &staged, control)?;
9030    Ok(staged.report)
9031}
9032
9033/// Derive structural summary mutations without acquiring the `SQLite` writer.
9034fn stage_structural_summaries_for_nodes_controlled(
9035    store: &AtlasStore,
9036    nodes: &[Node],
9037    text_rows: &[TextIndexRow],
9038    symbols: Option<&SymbolBuildStage>,
9039    protected_purpose_paths: &HashSet<String>,
9040    max_workers: usize,
9041    control: &IndexWorkControl,
9042) -> Result<StructuralSummaryStage, CliError> {
9043    control.check(IndexWorkStage::TextIndex)?;
9044    let candidates = nodes
9045        .iter()
9046        .filter(|node| node.kind == NodeKind::File)
9047        .filter(|node| is_structural_summary_candidate(&node.path, node.language.as_deref()))
9048        .collect::<Vec<_>>();
9049    if candidates.is_empty() {
9050        return Ok(StructuralSummaryStage {
9051            report: StructuralSummaryReport::default(),
9052            changes: Vec::new(),
9053            retained_bytes: 0,
9054        });
9055    }
9056    let paths = candidates
9057        .iter()
9058        .map(|node| node.path.clone())
9059        .collect::<Vec<_>>();
9060    let indexed_nodes = store
9061        .load_nodes_by_paths(&paths)?
9062        .into_iter()
9063        .map(|indexed| (indexed.node.path.clone(), indexed))
9064        .collect::<HashMap<_, _>>();
9065    let symbol_counts = store.symbol_counts_for_paths(&paths)?;
9066    let text_by_path = text_rows
9067        .iter()
9068        .filter_map(|row| row.text.as_ref().map(|text| (text.path.as_str(), text)))
9069        .collect::<HashMap<_, _>>();
9070    let reason_by_path = text_rows
9071        .iter()
9072        .map(|row| (row.path.as_str(), row.reason))
9073        .collect::<HashMap<_, _>>();
9074    let mut staged_symbol_counts = HashMap::new();
9075    let mut staged_symbol_summaries = HashMap::new();
9076    let mut staged_structural_summaries = HashMap::new();
9077    if let Some(symbols) = symbols {
9078        for change in &symbols.changes {
9079            match change {
9080                SymbolProjectionChange::Parsed(parsed) => {
9081                    staged_symbol_counts.insert(parsed.path.as_str(), parsed.graph.symbols.len());
9082                    staged_symbol_summaries.insert(parsed.path.as_str(), parsed.summary.as_str());
9083                    if parsed.summary_is_structural {
9084                        staged_structural_summaries
9085                            .insert(parsed.path.as_str(), parsed.purpose_suggestion.is_some());
9086                    }
9087                }
9088                SymbolProjectionChange::Clear { path, .. } => {
9089                    staged_symbol_counts.insert(path.as_str(), 0);
9090                }
9091            }
9092        }
9093    }
9094    let mut report = StructuralSummaryReport {
9095        candidates: paths.len(),
9096        ..StructuralSummaryReport::default()
9097    };
9098    let worker_count = worker_count_for_work(candidates.len(), max_workers);
9099    let pool = ThreadPoolBuilder::new()
9100        .num_threads(worker_count)
9101        .build()
9102        .map_err(|source| {
9103            CliError::InvalidInput(format!("structural summary worker pool failed: {source}"))
9104        })?;
9105    let derivations = pool.install(|| {
9106        candidates
9107            .par_iter()
9108            .map(|node| -> Result<StructuralSummaryDerivation, CliError> {
9109                control.check(IndexWorkStage::TextIndex)?;
9110                let existing = indexed_nodes.get(&node.path);
9111                let max_bytes = source_input_byte_limit(
9112                    &node.path,
9113                    node.language.as_deref(),
9114                    node.size_bytes,
9115                    MAX_SYMBOL_FILE_BYTES,
9116                    IndexWorkStage::TextIndex,
9117                )?;
9118                if reason_by_path.get(node.path.as_str()) == Some(&TextIndexSkipReason::TooLarge)
9119                    || node
9120                        .size_bytes
9121                        .is_some_and(|size_bytes| size_bytes > max_bytes)
9122                {
9123                    return Ok(StructuralSummaryDerivation {
9124                        change: Some(StructuralSummaryChange::Clear {
9125                            path: node.path.clone(),
9126                        }),
9127                        cleared: 1,
9128                        too_large: 1,
9129                        retained_bytes: node.path.len() as u64,
9130                        ..StructuralSummaryDerivation::default()
9131                    });
9132                }
9133                let Some(text) = text_by_path.get(node.path.as_str()) else {
9134                    return Ok(StructuralSummaryDerivation {
9135                        change: Some(StructuralSummaryChange::Clear {
9136                            path: node.path.clone(),
9137                        }),
9138                        cleared: 1,
9139                        binary_or_non_utf8: usize::from(
9140                            reason_by_path.get(node.path.as_str())
9141                                == Some(&TextIndexSkipReason::BinaryOrNonUtf8),
9142                        ),
9143                        retained_bytes: node.path.len() as u64,
9144                        ..StructuralSummaryDerivation::default()
9145                    });
9146                };
9147                if let Some(purpose_suggested) = staged_structural_summaries.get(node.path.as_str())
9148                {
9149                    return Ok(StructuralSummaryDerivation {
9150                        summarized: 1,
9151                        purpose_suggestions: usize::from(*purpose_suggested),
9152                        ..StructuralSummaryDerivation::default()
9153                    });
9154                }
9155                let symbol_count = staged_symbol_counts
9156                    .get(node.path.as_str())
9157                    .copied()
9158                    .or_else(|| symbol_counts.get(node.path.as_str()).copied())
9159                    .unwrap_or_default();
9160                let effective_summary = staged_symbol_summaries
9161                    .get(node.path.as_str())
9162                    .copied()
9163                    .or_else(|| existing.and_then(|indexed| indexed.summary.as_deref()));
9164                if symbol_count > 0
9165                    && effective_summary.is_some_and(|summary| {
9166                        !summary.trim().is_empty() && !is_scanner_fallback_summary(summary)
9167                    })
9168                {
9169                    return Ok(StructuralSummaryDerivation::default());
9170                }
9171                let Some(summary) = structural_summary_for_path(
9172                    &node.path,
9173                    node.language.as_deref(),
9174                    &text.content,
9175                ) else {
9176                    return Ok(StructuralSummaryDerivation {
9177                        change: Some(StructuralSummaryChange::Clear {
9178                            path: node.path.clone(),
9179                        }),
9180                        cleared: 1,
9181                        retained_bytes: node.path.len() as u64,
9182                        ..StructuralSummaryDerivation::default()
9183                    });
9184                };
9185                let purpose_needs_suggestion = !protected_purpose_paths.contains(&node.path)
9186                    && existing.is_none_or(|indexed| {
9187                        matches!(
9188                            indexed.purpose.status,
9189                            PurposeStatus::Missing | PurposeStatus::Suggested
9190                        )
9191                    });
9192                let purpose_suggestion =
9193                    purpose_needs_suggestion.then(|| suggest_file_purpose(&node.path, &summary));
9194                let purpose_suggestions = usize::from(purpose_suggestion.is_some());
9195                let retained_bytes = (node.path.len() as u64)
9196                    .saturating_add(summary.len() as u64)
9197                    .saturating_add(
9198                        purpose_suggestion
9199                            .as_ref()
9200                            .map_or(0, |suggestion| suggestion.len() as u64),
9201                    );
9202                control.check(IndexWorkStage::TextIndex)?;
9203                Ok(StructuralSummaryDerivation {
9204                    change: Some(StructuralSummaryChange::Set {
9205                        path: node.path.clone(),
9206                        summary,
9207                        purpose_suggestion,
9208                    }),
9209                    summarized: 1,
9210                    purpose_suggestions,
9211                    retained_bytes,
9212                    ..StructuralSummaryDerivation::default()
9213                })
9214            })
9215            .collect::<Result<Vec<_>, CliError>>()
9216    })?;
9217    let mut changes = Vec::new();
9218    let mut retained_bytes = 0_u64;
9219    for derivation in derivations {
9220        report.summarized += derivation.summarized;
9221        report.cleared += derivation.cleared;
9222        report.too_large += derivation.too_large;
9223        report.binary_or_non_utf8 += derivation.binary_or_non_utf8;
9224        report.purpose_suggestions += derivation.purpose_suggestions;
9225        retained_bytes = retained_bytes.saturating_add(derivation.retained_bytes);
9226        if let Some(change) = derivation.change {
9227            changes.push(change);
9228        }
9229    }
9230    Ok(StructuralSummaryStage {
9231        report,
9232        changes,
9233        retained_bytes,
9234    })
9235}
9236
9237/// Apply prepared structural summaries inside the parent publication transaction.
9238fn apply_structural_summary_stage(
9239    store: &mut AtlasStore,
9240    staged: &StructuralSummaryStage,
9241    control: &IndexWorkControl,
9242) -> Result<(), CliError> {
9243    for change in &staged.changes {
9244        control.check(IndexWorkStage::Publication)?;
9245        match change {
9246            StructuralSummaryChange::Set {
9247                path,
9248                summary,
9249                purpose_suggestion,
9250            } => {
9251                store.set_node_summary(path, summary)?;
9252                if let Some(suggestion) = purpose_suggestion.as_deref() {
9253                    store.set_suggested_purpose(path, suggestion)?;
9254                }
9255            }
9256            StructuralSummaryChange::Clear { path } => store.clear_node_summary(path)?,
9257        }
9258    }
9259    control.check(IndexWorkStage::Publication)?;
9260    Ok(())
9261}
9262
9263/// Refresh the persisted text index for every scanned file node.
9264#[cfg(test)]
9265pub(crate) fn refresh_text_index_for_nodes(
9266    store: &mut AtlasStore,
9267    root: &Path,
9268    nodes: &[Node],
9269    options: TextIndexOptions,
9270) -> Result<TextIndexReport, CliError> {
9271    let control = standalone_index_work_control();
9272    Ok(
9273        refresh_text_index_for_nodes_with_rows_controlled(store, root, nodes, options, &control)?
9274            .report,
9275    )
9276}
9277
9278/// Refresh all text rows under one cancellation and staging-byte boundary.
9279#[cfg(test)]
9280pub(crate) fn refresh_text_index_for_nodes_with_rows(
9281    store: &mut AtlasStore,
9282    root: &Path,
9283    nodes: &[Node],
9284    options: TextIndexOptions,
9285) -> Result<TextIndexRefresh, CliError> {
9286    let control = standalone_index_work_control();
9287    refresh_text_index_for_nodes_with_rows_controlled(store, root, nodes, options, &control)
9288}
9289
9290/// Refresh all text rows under one cancellation and staging-byte boundary.
9291#[cfg(test)]
9292fn refresh_text_index_for_nodes_with_rows_controlled(
9293    store: &mut AtlasStore,
9294    root: &Path,
9295    nodes: &[Node],
9296    options: TextIndexOptions,
9297    control: &IndexWorkControl,
9298) -> Result<TextIndexRefresh, CliError> {
9299    let file_paths = nodes
9300        .iter()
9301        .filter(|node| node.kind == NodeKind::File)
9302        .map(|node| node.path.clone())
9303        .collect::<Vec<_>>();
9304    refresh_text_index_for_changed_paths_with_rows_controlled(
9305        store,
9306        root,
9307        &file_paths,
9308        nodes,
9309        options,
9310        control,
9311    )
9312}
9313
9314/// Refresh selected text rows under one cancellation and staging-byte boundary.
9315#[cfg(test)]
9316fn refresh_text_index_for_changed_paths_with_rows_controlled(
9317    store: &mut AtlasStore,
9318    root: &Path,
9319    considered_paths: &[String],
9320    nodes: &[Node],
9321    options: TextIndexOptions,
9322    control: &IndexWorkControl,
9323) -> Result<TextIndexRefresh, CliError> {
9324    let staged = stage_text_index_for_changed_paths_controlled(root, nodes, options, control)?;
9325    apply_text_index_stage(store, considered_paths, &staged, control)?;
9326    Ok(staged)
9327}
9328
9329/// Build selected persisted-text rows without acquiring the `SQLite` writer.
9330fn stage_text_index_for_changed_paths_controlled(
9331    root: &Path,
9332    nodes: &[Node],
9333    options: TextIndexOptions,
9334    control: &IndexWorkControl,
9335) -> Result<TextIndexRefresh, CliError> {
9336    control.check(IndexWorkStage::TextIndex)?;
9337    let text_rows = indexed_file_texts_for_nodes_controlled(root, nodes, options, control)?;
9338    let indexed = text_rows.iter().filter(|row| row.text.is_some()).count();
9339    let indexed_bytes = text_rows
9340        .iter()
9341        .filter_map(|row| row.text.as_ref())
9342        .map(|text| text.byte_count)
9343        .fold(0usize, usize::saturating_add);
9344    let file_candidates = nodes
9345        .iter()
9346        .filter(|node| node.kind == NodeKind::File)
9347        .count();
9348    let binary_or_non_utf8 = text_rows
9349        .iter()
9350        .filter(|row| row.reason == TextIndexSkipReason::BinaryOrNonUtf8)
9351        .count();
9352    let too_large = text_rows
9353        .iter()
9354        .filter(|row| row.reason == TextIndexSkipReason::TooLarge)
9355        .count();
9356    let report = TextIndexReport {
9357        candidates: file_candidates,
9358        indexed,
9359        binary_or_non_utf8,
9360        too_large,
9361        skipped: file_candidates.saturating_sub(indexed),
9362        max_bytes: options.max_bytes,
9363        bytes: indexed_bytes,
9364    };
9365    control.check(IndexWorkStage::TextIndex)?;
9366    Ok(TextIndexRefresh {
9367        report,
9368        rows: text_rows,
9369    })
9370}
9371
9372/// Apply prepared persisted-text rows inside the parent publication transaction.
9373fn apply_text_index_stage(
9374    store: &mut AtlasStore,
9375    considered_paths: &[String],
9376    staged: &TextIndexRefresh,
9377    control: &IndexWorkControl,
9378) -> Result<(), CliError> {
9379    let text_by_path = staged
9380        .rows
9381        .iter()
9382        .filter_map(|row| row.text.as_ref().map(|text| (text.path.as_str(), text)))
9383        .collect::<HashMap<_, _>>();
9384    for paths in considered_paths.chunks(PUBLICATION_TEXT_BATCH_SIZE) {
9385        control.check(IndexWorkStage::Publication)?;
9386        store.replace_file_texts_for_paths(
9387            paths,
9388            paths
9389                .iter()
9390                .filter_map(|path| text_by_path.get(path.as_str()).copied()),
9391        )?;
9392    }
9393    control.check(IndexWorkStage::Publication)?;
9394    Ok(())
9395}
9396
9397/// Build indexed text rows for UTF-8 scanned files with size caps.
9398#[cfg(test)]
9399pub(crate) fn indexed_file_texts_for_nodes(
9400    root: &Path,
9401    nodes: &[Node],
9402    options: TextIndexOptions,
9403) -> Result<Vec<TextIndexRow>, CliError> {
9404    let control = standalone_index_work_control();
9405    indexed_file_texts_for_nodes_controlled(root, nodes, options, &control)
9406}
9407
9408/// Build bounded UTF-8 text rows while observing cancellation between files.
9409fn indexed_file_texts_for_nodes_controlled(
9410    root: &Path,
9411    nodes: &[Node],
9412    options: TextIndexOptions,
9413    control: &IndexWorkControl,
9414) -> Result<Vec<TextIndexRow>, CliError> {
9415    indexed_file_texts_for_nodes_with_limit(root, nodes, options, MAX_STAGED_TEXT_BYTES, control)
9416}
9417
9418/// Build UTF-8 text rows under an explicit aggregate staging-byte limit.
9419fn indexed_file_texts_for_nodes_with_limit(
9420    root: &Path,
9421    nodes: &[Node],
9422    options: TextIndexOptions,
9423    max_staged_bytes: u64,
9424    control: &IndexWorkControl,
9425) -> Result<Vec<TextIndexRow>, CliError> {
9426    let mut rows = Vec::new();
9427    let mut staged_bytes = 0_u64;
9428    for node in nodes.iter().filter(|node| node.kind == NodeKind::File) {
9429        control.check(IndexWorkStage::TextIndex)?;
9430        let max_bytes = source_input_byte_limit(
9431            &node.path,
9432            node.language.as_deref(),
9433            node.size_bytes,
9434            options.max_bytes,
9435            IndexWorkStage::TextIndex,
9436        )?;
9437        if node
9438            .size_bytes
9439            .is_some_and(|size_bytes| size_bytes > max_bytes)
9440        {
9441            rows.push(TextIndexRow {
9442                path: node.path.clone(),
9443                text: None,
9444                reason: TextIndexSkipReason::TooLarge,
9445            });
9446            continue;
9447        }
9448        let remaining_staged_bytes = max_staged_bytes.saturating_sub(staged_bytes);
9449        let is_document = document_format_for_path(&node.path, node.language.as_deref()).is_some();
9450        if !is_document
9451            && node
9452                .size_bytes
9453                .is_some_and(|size_bytes| size_bytes > remaining_staged_bytes)
9454        {
9455            return Err(IndexWorkFailure::resource_limit(
9456                IndexWorkStage::TextIndex,
9457                IndexWorkResource::TextBytes,
9458                max_staged_bytes,
9459                staged_bytes.saturating_add(node.size_bytes.unwrap_or_default()),
9460            )
9461            .into());
9462        }
9463        let native_path = root.join(repo_path_to_native(&node.path));
9464        let read_limit = if is_document {
9465            max_bytes
9466        } else {
9467            max_bytes.min(remaining_staged_bytes)
9468        };
9469        let aggregate_limit_is_narrower = !is_document && remaining_staged_bytes <= max_bytes;
9470        let bytes = match read_source_bytes_controlled(
9471            &native_path,
9472            read_limit,
9473            IndexWorkStage::TextIndex,
9474            control,
9475        ) {
9476            Ok(bytes) => bytes,
9477            Err(SourceReadFailure::Io(source)) => {
9478                return Err(CliError::Io {
9479                    path: native_path,
9480                    source,
9481                });
9482            }
9483            Err(SourceReadFailure::IndexWork(failure)) => return Err(failure.into()),
9484            Err(SourceReadFailure::LimitExceeded { observed }) if aggregate_limit_is_narrower => {
9485                return Err(IndexWorkFailure::resource_limit(
9486                    IndexWorkStage::TextIndex,
9487                    IndexWorkResource::TextBytes,
9488                    max_staged_bytes,
9489                    staged_bytes.saturating_add(observed),
9490                )
9491                .into());
9492            }
9493            Err(SourceReadFailure::LimitExceeded { .. }) => {
9494                return Err(source_changed_during_derivation(root, &node.path));
9495            }
9496        };
9497        control.check(IndexWorkStage::TextIndex)?;
9498        let current_hash = blake3::hash(&bytes).to_hex().to_string();
9499        if node.content_hash.as_deref() != Some(current_hash.as_str()) {
9500            return Err(source_changed_during_derivation(root, &node.path));
9501        }
9502        let content = if is_document {
9503            extract_document_text_controlled(&bytes, &node.path, node.language.as_deref(), control)
9504                .map_err(|error| document_navigation_error(&node.path, error))?
9505                .text
9506        } else {
9507            let Ok(content) = String::from_utf8(bytes) else {
9508                rows.push(TextIndexRow {
9509                    path: node.path.clone(),
9510                    text: None,
9511                    reason: TextIndexSkipReason::BinaryOrNonUtf8,
9512                });
9513                continue;
9514            };
9515            content
9516        };
9517        let next_staged_bytes = staged_bytes.saturating_add(content.len() as u64);
9518        if next_staged_bytes > max_staged_bytes {
9519            return Err(IndexWorkFailure::resource_limit(
9520                IndexWorkStage::TextIndex,
9521                IndexWorkResource::TextBytes,
9522                max_staged_bytes,
9523                next_staged_bytes,
9524            )
9525            .into());
9526        }
9527        staged_bytes = next_staged_bytes;
9528        rows.push(TextIndexRow {
9529            path: node.path.clone(),
9530            reason: TextIndexSkipReason::Indexed,
9531            text: Some(IndexedFileText {
9532                path: node.path.clone(),
9533                content_hash: node.content_hash.clone(),
9534                byte_count: content.len(),
9535                line_count: content.lines().count(),
9536                content,
9537            }),
9538        });
9539    }
9540    Ok(rows)
9541}
9542
9543/// Load indexed file hashes for incremental refresh comparison.
9544fn indexed_file_hashes(
9545    store: &AtlasStore,
9546    control: &IndexWorkControl,
9547) -> Result<HashMap<String, String>, CliError> {
9548    let mut hashes = HashMap::new();
9549    store.visit_source_nodes_controlled(control, |node| {
9550        if node.kind == NodeKind::File
9551            && let Some(hash) = node.content_hash
9552        {
9553            hashes.insert(node.path, hash);
9554        }
9555        Ok(())
9556    })?;
9557    Ok(hashes)
9558}
9559
9560/// Load indexed file hashes for selected repository paths.
9561pub(crate) fn indexed_file_hashes_for_paths(
9562    store: &AtlasStore,
9563    paths: &HashSet<String>,
9564) -> Result<HashMap<String, String>, CliError> {
9565    let mut sorted_paths = paths.iter().cloned().collect::<Vec<_>>();
9566    sorted_paths.sort();
9567    Ok(store
9568        .load_nodes_by_paths(&sorted_paths)?
9569        .into_iter()
9570        .filter(|node| node.node.kind == NodeKind::File)
9571        .filter_map(|node| node.node.content_hash.map(|hash| (node.node.path, hash)))
9572        .collect::<HashMap<_, _>>())
9573}
9574
9575/// Return event paths in deterministic order.
9576pub(crate) fn sorted_watch_paths(paths: &HashSet<PathBuf>) -> Vec<PathBuf> {
9577    let mut paths = paths.iter().cloned().collect::<Vec<_>>();
9578    paths.sort();
9579    paths
9580}
9581
9582/// Normalize a deleted path if it belongs to the watched repository.
9583pub(crate) fn normalized_deleted_path(
9584    root: &Path,
9585    path: &Path,
9586) -> Result<Option<String>, CliError> {
9587    match normalize_repo_path(root, path) {
9588        Ok(path) => Ok(valid_watch_relative_path(path)),
9589        Err(projectatlas_core::CoreError::PathOutsideRoot { .. }) => {
9590            Ok(native_display_relative_path(root, path).and_then(valid_watch_relative_path))
9591        }
9592        Err(source) => Err(CliError::InvalidInput(source.to_string())),
9593    }
9594}
9595
9596/// Inspect and optionally remove legacy `.purpose` files.
9597pub(crate) fn strip_legacy_purpose(
9598    root: &Path,
9599    config_path: Option<&Path>,
9600    apply: bool,
9601    dry_run: bool,
9602    strip_source_headers: bool,
9603) -> Result<LegacyPurposeReport, CliError> {
9604    let root = root.canonicalize().map_err(|source| CliError::Io {
9605        path: root.to_path_buf(),
9606        source,
9607    })?;
9608    let scan_options = scan_options_for_root(config_path, &root)?;
9609    let nodes = scan_repo(&root, &scan_options)?;
9610    let effective_dry_run = dry_run || !apply;
9611    let purpose_files = indexed_purpose_files(&root, &nodes);
9612    let mut removed = 0;
9613    if !effective_dry_run {
9614        for path in &purpose_files {
9615            let native = root.join(repo_path_to_native(path));
9616            fs::remove_file(&native).map_err(|source| CliError::Io {
9617                path: native,
9618                source,
9619            })?;
9620            removed += 1;
9621        }
9622    }
9623    let source_header_candidates = if strip_source_headers {
9624        purpose_header_candidates(&root, &nodes)?
9625    } else {
9626        Vec::new()
9627    };
9628    Ok(LegacyPurposeReport {
9629        applied: !effective_dry_run,
9630        purpose_files_found: purpose_files.len(),
9631        purpose_files_removed: removed,
9632        source_header_candidates,
9633        purpose_files,
9634    })
9635}
9636
9637/// Collect `.purpose` files only from folders included in the normal index.
9638pub(crate) fn indexed_purpose_files(root: &Path, nodes: &[Node]) -> Vec<String> {
9639    let mut purpose_files = Vec::new();
9640    for node in nodes.iter().filter(|node| node.kind == NodeKind::Folder) {
9641        let relative = if node.path == "." {
9642            ".purpose".to_string()
9643        } else {
9644            format!("{}/.purpose", node.path)
9645        };
9646        let native = root.join(repo_path_to_native(&relative));
9647        if native.exists() {
9648            purpose_files.push(relative);
9649        }
9650    }
9651    purpose_files.sort();
9652    purpose_files
9653}
9654
9655/// Return source files that appear to start with legacy Purpose headers.
9656pub(crate) fn purpose_header_candidates(
9657    root: &Path,
9658    nodes: &[Node],
9659) -> Result<Vec<String>, CliError> {
9660    let mut candidates = Vec::new();
9661    for node in nodes
9662        .iter()
9663        .filter(|node| node.kind == NodeKind::File)
9664        .filter(|node| is_symbol_candidate(&node.path, node.language.as_deref()))
9665    {
9666        let path = root.join(repo_path_to_native(&node.path));
9667        let content = fs::read_to_string(&path).map_err(|source| CliError::Io { path, source })?;
9668        if content
9669            .lines()
9670            .take(3)
9671            .any(|line| line.trim_start().contains("Purpose:"))
9672        {
9673            candidates.push(node.path.clone());
9674        }
9675    }
9676    Ok(candidates)
9677}
9678
9679#[cfg(test)]
9680mod tests {
9681    use super::*;
9682    use projectatlas_core::graph::{
9683        DocumentTargetUnresolvedReason, EntitySelector, ExtendedRelationKind, GraphRelationKind,
9684        RelationResolution, RepositoryNodePath,
9685    };
9686    use projectatlas_db::{DbError, RepositoryGraphRelationQuery, WorktreeAlias};
9687    use std::error::Error;
9688    use std::fmt::Debug;
9689    use std::process::Command as StdCommand;
9690
9691    #[test]
9692    fn guarded_reset_restores_owned_files_or_retains_them_on_namespace_change()
9693    -> Result<(), Box<dyn Error>> {
9694        for replace_namespace in [false, true] {
9695            let temp = tempfile::tempdir()?;
9696            let root = temp.path().canonicalize()?;
9697            let directory = root.join(".projectatlas");
9698            fs::create_dir(&directory)?;
9699            let database = directory.join("projectatlas.db");
9700            let wal = db_sidecar_path(&database, "wal");
9701            fs::write(&database, b"original main")?;
9702            fs::write(&wal, b"original WAL")?;
9703            let retained = root.join("captured-state");
9704            let admission = preflight_managed_reset(&database, &root)?;
9705            let mut injected = false;
9706            let result =
9707                reset_index_files_with_revalidation(&database, false, admission.as_ref(), || {
9708                    if injected || database.exists() {
9709                        return Ok(());
9710                    }
9711                    injected = true;
9712                    if !replace_namespace {
9713                        return Err(CliError::InvalidInput(
9714                            "injected reset lifecycle failure".to_owned(),
9715                        ));
9716                    }
9717                    fs::rename(&directory, &retained)
9718                        .and_then(|()| fs::create_dir(&directory))
9719                        .and_then(|()| fs::write(&database, b"foreign main"))
9720                        .and_then(|()| fs::write(&wal, b"foreign WAL"))
9721                        .map_err(|source| CliError::Io {
9722                            path: directory.clone(),
9723                            source,
9724                        })
9725                });
9726            let error = result
9727                .err()
9728                .ok_or_else(|| io::Error::other("injected reset failure was ignored"))?;
9729            if replace_namespace {
9730                if !error.to_string().contains("staged reset recovery retained") {
9731                    return Err(io::Error::other(format!(
9732                        "namespace failure did not report retention: {error}"
9733                    ))
9734                    .into());
9735                }
9736                require_eq(
9737                    &fs::read(&database)?,
9738                    &b"foreign main".to_vec(),
9739                    "reset recovery modified replacement main",
9740                )?;
9741                require_eq(
9742                    &fs::read(&wal)?,
9743                    &b"foreign WAL".to_vec(),
9744                    "reset recovery modified replacement WAL",
9745                )?;
9746                require_eq(
9747                    &fs::read(retained.join("projectatlas.db-wal"))?,
9748                    &b"original WAL".to_vec(),
9749                    "reset lost unstaged WAL",
9750                )?;
9751                let recoveries = fs::read_dir(&retained)?.collect::<Result<Vec<_>, _>>()?;
9752                let recovery = recoveries
9753                    .iter()
9754                    .find(|entry| {
9755                        entry
9756                            .file_name()
9757                            .to_string_lossy()
9758                            .starts_with("reset-recovery-")
9759                    })
9760                    .ok_or_else(|| io::Error::other("reset did not retain its staged main"))?;
9761                let staged_files = fs::read_dir(recovery.path())?.collect::<Result<Vec<_>, _>>()?;
9762                require_eq(
9763                    &staged_files.len(),
9764                    &1,
9765                    "reset retained unexpected staged files",
9766                )?;
9767                require_eq(
9768                    &fs::read(staged_files[0].path())?,
9769                    &b"original main".to_vec(),
9770                    "reset lost staged main",
9771                )?;
9772            } else {
9773                if !error
9774                    .to_string()
9775                    .contains("injected reset lifecycle failure")
9776                {
9777                    return Err(
9778                        io::Error::other(format!("reset lost lifecycle failure: {error}")).into(),
9779                    );
9780                }
9781                require_eq(
9782                    &fs::read(&database)?,
9783                    &b"original main".to_vec(),
9784                    "reset did not restore owned main",
9785                )?;
9786                require_eq(
9787                    &fs::read(&wal)?,
9788                    &b"original WAL".to_vec(),
9789                    "reset modified unstaged WAL",
9790                )?;
9791                if fs::read_dir(&directory)?.count() != 2 {
9792                    return Err(io::Error::other("restored reset left recovery artifacts").into());
9793                }
9794            }
9795        }
9796        Ok(())
9797    }
9798
9799    /// Managed Windows spellings share one cohort; legacy recovery remains explicit.
9800    #[cfg(windows)]
9801    #[test]
9802    fn windows_managed_database_aliases_preserve_wal_recovery_and_reset()
9803    -> Result<(), Box<dyn Error>> {
9804        let short_path = |path: &Path| -> Result<PathBuf, Box<dyn Error>> {
9805            let output = StdCommand::new("powershell.exe")
9806                .args(["-NoProfile", "-NonInteractive", "-Command", "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); (New-Object -ComObject Scripting.FileSystemObject).GetFile($env:PROJECTATLAS_TEST_DB_PATH).ShortPath"])
9807                .env("PROJECTATLAS_TEST_DB_PATH", path).output()?;
9808            if !output.status.success() {
9809                return Err(
9810                    io::Error::other(format!("short database fixture failed: {output:?}")).into(),
9811                );
9812            }
9813            Ok(PathBuf::from(String::from_utf8(output.stdout)?.trim()))
9814        };
9815        let mut actual_aliases = 0;
9816        for sensitive in [false, true] {
9817            let temp = tempfile::tempdir()?;
9818            let root = temp.path().canonicalize()?;
9819            let directory = root.join(".projectatlas");
9820            fs::create_dir(&directory)?;
9821            let configured = StdCommand::new("fsutil.exe")
9822                .current_dir(&directory)
9823                .args([
9824                    "file",
9825                    "setCaseSensitiveInfo",
9826                    ".",
9827                    if sensitive { "enable" } else { "disable" },
9828                ])
9829                .output()?;
9830            if !configured.status.success() {
9831                return Err(io::Error::other(format!(
9832                    "database case fixture failed: {configured:?}"
9833                ))
9834                .into());
9835            }
9836            let database = directory.join("projectatlas.db");
9837            let normal_database = temp.path().join(".projectatlas/projectatlas.db");
9838            let config = directory.join("config.toml");
9839            fs::write(&config, "[project]\nroot = \".\"\n")?;
9840            // Admission must check SQLite's canonical sidecars, not db.-wal/db -wal.
9841            fs::write(&database, b"admission-only main")?;
9842            let wal = db_sidecar_path(&database, "wal");
9843            fs::write(&wal, b"outside WAL owner")?;
9844            let outside_wal = root.join("admission-outside-wal");
9845            fs::hard_link(&wal, &outside_wal)?;
9846            for suffix in [".", " "] {
9847                let selected = normal_database.with_file_name(format!("projectatlas.db{suffix}"));
9848                if preflight_managed_database(&selected, &root).is_ok() {
9849                    return Err(
9850                        io::Error::other("normal suffix bypassed canonical WAL ownership").into(),
9851                    );
9852                }
9853            }
9854            fs::remove_file(&wal)?;
9855            fs::remove_file(&outside_wal)?;
9856            fs::remove_file(&database)?;
9857            let mut canonical = open_atlas_store_for_project(&database, &root)?;
9858            canonical.upsert_scan_nodes(&[projectatlas_core::Node {
9859                path: "file.rs".to_owned(),
9860                kind: projectatlas_core::NodeKind::File,
9861                parent_path: None,
9862                extension: Some(".rs".to_owned()),
9863                language: Some("rust".to_owned()),
9864                size_bytes: Some(1),
9865                mtime_ns: None,
9866                content_hash: None,
9867            }])?;
9868            canonical.set_purpose(
9869                "file.rs",
9870                "canonical purpose",
9871                projectatlas_core::PurposeSource::Agent,
9872            )?;
9873            let identity = canonical.project_instance_id()?;
9874            let short = short_path(&normal_database)?;
9875            if short.file_name() == normal_database.file_name() {
9876                drop(canonical);
9877                continue;
9878            }
9879            actual_aliases += 1;
9880            let selected = open_existing_atlas_store_for_project(&short, &root)?;
9881            require_eq(
9882                &selected.authored_purpose_revision()?,
9883                &1,
9884                "alias did not see canonical committed state",
9885            )?;
9886            selected.set_purpose(
9887                "file.rs",
9888                "shared alias purpose",
9889                projectatlas_core::PurposeSource::Agent,
9890            )?;
9891            require_eq(
9892                &canonical.authored_purpose_revision()?,
9893                &2,
9894                "new managed opens formed separate WAL cohorts",
9895            )?;
9896            drop(selected);
9897            drop(canonical);
9898            let short_wal = db_sidecar_path(&short, "wal");
9899            let short_shm = db_sidecar_path(&short, "shm");
9900            if short_wal.exists() || short_shm.exists() {
9901                return Err(io::Error::other("managed alias created short sidecars").into());
9902            }
9903
9904            // A predecessor/operator cohort contains committed state not yet in main.
9905            let legacy = rusqlite::Connection::open(&short)?;
9906            legacy.execute_batch("PRAGMA wal_autocheckpoint=0; BEGIN IMMEDIATE; UPDATE metadata SET value='9' WHERE key='purpose.authored_revision'; UPDATE purposes SET purpose='pending legacy purpose'; COMMIT;")?;
9907            let main_before = fs::read(&database)?;
9908            let wal_before = fs::read(&short_wal)?;
9909            let shm_before = fs::read(&short_shm)?;
9910            for path in [&database, &short] {
9911                if open_atlas_store_for_project(path, &root).is_ok()
9912                    || open_existing_atlas_store_for_project(path, &root).is_ok()
9913                    || open_atlas_store_read_only_for_project(path, &root).is_ok()
9914                    || preflight_existing_project_binding(path, &root).is_ok()
9915                    || build_settings_report(path, Some(&config), OutputFormat::Json).is_ok()
9916                    || crate::build_root_report(path, Some(&config)).is_ok()
9917                    || run_init_bootstrap(
9918                        &root,
9919                        path,
9920                        Some(&config),
9921                        &InitBootstrapOptions {
9922                            no_scan: true,
9923                            force_rescan: false,
9924                            text_index_max_bytes: None,
9925                        },
9926                    )
9927                    .is_ok()
9928                {
9929                    return Err(io::Error::other(
9930                        "pending short cohort was silently opened or retargeted",
9931                    )
9932                    .into());
9933                }
9934            }
9935            require_eq(
9936                &fs::read(&database)?,
9937                &main_before,
9938                "refusal changed the main database",
9939            )?;
9940            require_eq(
9941                &fs::read(&short_wal)?,
9942                &wal_before,
9943                "refusal changed committed short WAL",
9944            )?;
9945            require_eq(
9946                &fs::read(&short_shm)?,
9947                &shm_before,
9948                "refusal changed short SHM",
9949            )?;
9950            // This models the error's external-tool recovery, using the original spelling.
9951            legacy.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
9952            drop(legacy);
9953            let recovered = open_existing_atlas_store_for_project(&short, &root)?;
9954            require_eq(
9955                &recovered.authored_purpose_revision()?,
9956                &9,
9957                "checkpointed authored state was lost",
9958            )?;
9959            require_eq(
9960                &recovered.project_instance_id()?,
9961                &identity,
9962                "cohort recovery changed project identity",
9963            )?;
9964            drop(recovered);
9965
9966            // Restore an owned consistent triplet to model crash-pending state with no live handles.
9967            let legacy = rusqlite::Connection::open(&short)?;
9968            legacy.execute_batch("PRAGMA wal_autocheckpoint=0; UPDATE metadata SET value='10' WHERE key='purpose.authored_revision';")?;
9969            let crash_main = fs::read(&database)?;
9970            let crash_wal = fs::read(&short_wal)?;
9971            let crash_shm = fs::read(&short_shm)?;
9972            drop(legacy);
9973            fs::write(&database, &crash_main)?;
9974            fs::write(&short_wal, &crash_wal)?;
9975            fs::write(&short_shm, &crash_shm)?;
9976            let outside_main = root.join("reset-outside-main");
9977            fs::hard_link(&database, &outside_main)?;
9978            let outside_wal = root.join("reset-outside-wal");
9979            fs::hard_link(&short_wal, &outside_wal)?;
9980            let admission = preflight_managed_reset(&short, &root)?;
9981            let preview = reset_index_files(&short, false, true, false, admission.as_ref())?;
9982            let alternate_leaf = short
9983                .file_name()
9984                .ok_or_else(|| io::Error::other("short database has no leaf"))?;
9985            let canonical_parent_wal = db_sidecar_path(&directory.join(alternate_leaf), "wal");
9986            if !preview
9987                .files
9988                .iter()
9989                .any(|file| file.path == lossless_native_path_display(&canonical_parent_wal))
9990                || !same_file::is_same_file(&canonical_parent_wal, &short_wal)?
9991            {
9992                return Err(io::Error::other(
9993                    "reset preview omitted proven alternate recovery state",
9994                )
9995                .into());
9996            }
9997            require_eq(
9998                &fs::read(&short_wal)?,
9999                &crash_wal,
10000                "reset preview changed pending state",
10001            )?;
10002            reset_index_files(&short, true, false, false, admission.as_ref())?;
10003            if database.exists() || short_wal.exists() || short_shm.exists() {
10004                return Err(
10005                    io::Error::other("explicit reset left a database cohort behind").into(),
10006                );
10007            }
10008            require_eq(
10009                &fs::read(&outside_main)?,
10010                &crash_main,
10011                "reset modified the outside main hardlink",
10012            )?;
10013            require_eq(
10014                &fs::read(&outside_wal)?,
10015                &crash_wal,
10016                "reset modified the outside WAL hardlink",
10017            )?;
10018            drop(admission);
10019
10020            // Identity repair must not create a short cohort ahead of the shared opener.
10021            let fresh = open_atlas_store_for_project(&database, &root)?;
10022            let identity = fresh.project_instance_id()?;
10023            drop(fresh);
10024            let repair = rusqlite::Connection::open(&database)?;
10025            repair.execute("DELETE FROM project_root_identity", [])?;
10026            drop(repair);
10027            let short = short_path(&normal_database)?;
10028            let observer = rusqlite::Connection::open(&database)?;
10029            let _: i64 =
10030                observer.query_row("SELECT count(*) FROM project_root_identity", [], |row| {
10031                    row.get(0)
10032                })?;
10033            run_init_bootstrap(
10034                &root,
10035                &short,
10036                Some(&config),
10037                &InitBootstrapOptions {
10038                    no_scan: true,
10039                    force_rescan: false,
10040                    text_index_max_bytes: None,
10041                },
10042            )?;
10043            if db_sidecar_path(&short, "wal").exists() || db_sidecar_path(&short, "shm").exists() {
10044                return Err(io::Error::other("identity repair created a short-name cohort").into());
10045            }
10046            require_eq(
10047                &open_existing_atlas_store_for_project(&short, &root)?.project_instance_id()?,
10048                &identity,
10049                "alias identity repair changed the instance",
10050            )?;
10051            drop(observer);
10052        }
10053        if actual_aliases == 0 && std::env::var_os("CI").is_some() {
10054            return Err(io::Error::other(
10055                "native Windows proof did not exercise a real 8.3 DB alias",
10056            )
10057            .into());
10058        }
10059        Ok(())
10060    }
10061
10062    fn run_git_fixture(command: &mut StdCommand) -> Result<(), Box<dyn Error>> {
10063        let output = command.output()?;
10064        if !output.status.success() {
10065            return Err(io::Error::other(format!(
10066                "Git fixture command failed: {}{}",
10067                String::from_utf8_lossy(&output.stdout),
10068                String::from_utf8_lossy(&output.stderr)
10069            ))
10070            .into());
10071        }
10072        Ok(())
10073    }
10074
10075    /// Downgrade a current fixture to the released schema-19 worktree shape.
10076    fn drop_native_worktree_identity_schema(
10077        connection: &rusqlite::Connection,
10078    ) -> rusqlite::Result<()> {
10079        connection.execute_batch(
10080            "DROP INDEX IF EXISTS idx_worktree_registrations_active_native_administrative_directory;
10081             DROP INDEX IF EXISTS idx_worktree_registrations_active_native_root;
10082             ALTER TABLE worktree_registrations DROP COLUMN git_common_directory_identity;
10083             ALTER TABLE worktree_registrations DROP COLUMN git_administrative_directory_identity;
10084             ALTER TABLE worktree_registrations DROP COLUMN last_root_identity;",
10085        )
10086    }
10087
10088    #[test]
10089    fn synchronization_control_identity_rejects_replacement_without_caller_identity()
10090    -> Result<(), Box<dyn Error>> {
10091        let temp = tempfile::tempdir()?;
10092        let root = temp.path().join("control");
10093        let state = root.join(".projectatlas");
10094        let database = state.join("projectatlas.db");
10095        fs::create_dir_all(&state)?;
10096        let original = AtlasStore::open_for_project(&database, &root)?;
10097        let captured = require_synchronization_control_identity(&original, None)?;
10098        require_eq(
10099            &require_synchronization_control_identity(&original, Some(captured))?,
10100            &captured,
10101            "stable control identity",
10102        )?;
10103        drop(original);
10104
10105        fs::rename(&state, root.join(".projectatlas-captured-control"))?;
10106        fs::create_dir(&state)?;
10107        let replacement = AtlasStore::open_for_project(&database, &root)?;
10108        let rejected = require_synchronization_control_identity(&replacement, Some(captured));
10109        require_eq(
10110            &rejected
10111                .as_ref()
10112                .is_err_and(|error| error.to_string().contains("control atlas identity changed")),
10113            &true,
10114            "replacement control identity rejection",
10115        )
10116    }
10117
10118    #[cfg(windows)]
10119    #[test]
10120    fn default_mcp_project_root_recovers_custom_predecessor_candidate() -> Result<(), Box<dyn Error>>
10121    {
10122        let temp = tempfile::tempdir()?;
10123        let root = temp.path().join("legacy-project");
10124        let database = temp.path().join("custom-projectatlas.db");
10125        fs::create_dir(&root)?;
10126        drop(AtlasStore::open_for_project(&database, &root)?);
10127        {
10128            let connection = rusqlite::Connection::open(&database)?;
10129            drop_native_worktree_identity_schema(&connection)?;
10130            connection.execute_batch(
10131                "DROP TABLE project_root_identity;
10132                 DROP TABLE IF EXISTS graph_identity_rejections;
10133                 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
10134            )?;
10135        }
10136
10137        let resolved = default_mcp_project_root(&database, None)?;
10138        require_eq(
10139            &resolved,
10140            &canonical_source_project_root(&root)?,
10141            "custom predecessor root recovery",
10142        )?;
10143        drop(AtlasStore::open_for_project(&database, &root)?);
10144        Ok(())
10145    }
10146
10147    #[test]
10148    fn init_rejects_current_wrong_root_before_project_writes() -> Result<(), Box<dyn Error>> {
10149        let temp = tempfile::tempdir()?;
10150        let selected_root = temp.path().join("selected-root");
10151        let bound_root = temp.path().join("bound-root");
10152        let database = temp.path().join("external-projectatlas.db");
10153        let config_path = selected_root.join("external-config/config.toml");
10154        fs::create_dir_all(&selected_root)?;
10155        fs::create_dir_all(&bound_root)?;
10156        let persisted_identity = {
10157            let store = AtlasStore::open_for_project(&database, &bound_root)?;
10158            store.project_root_identity()?
10159        };
10160        drop(AtlasStore::open_read_only_for_project(
10161            &database,
10162            &bound_root,
10163        )?);
10164        let _ = read_project_root_identity_read_only(&database)?;
10165        let database_before = fs::read(&database)?;
10166        let sidecars_before = ["wal", "shm", "journal"]
10167            .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
10168        let selected_project_dir = selected_root.join(".projectatlas");
10169        let config_parent = config_path
10170            .parent()
10171            .ok_or_else(|| io::Error::other("selected config has no parent"))?;
10172
10173        let result = run_init_bootstrap(
10174            &selected_root,
10175            &database,
10176            Some(&config_path),
10177            &InitBootstrapOptions {
10178                no_scan: true,
10179                force_rescan: false,
10180                text_index_max_bytes: None,
10181            },
10182        );
10183        require_eq(
10184            &matches!(result, Err(CliError::ProjectMismatch(_))),
10185            &true,
10186            "current wrong-root init did not return a typed project mismatch",
10187        )?;
10188        require_eq(
10189            &(!selected_project_dir.exists() && !config_parent.exists() && !config_path.exists()),
10190            &true,
10191            "current wrong-root init created selected project or config state",
10192        )?;
10193        require_eq(
10194            &(fs::read(&database)? == database_before
10195                && ["wal", "shm", "journal"]
10196                    .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok())
10197                    == sidecars_before),
10198            &true,
10199            "current wrong-root init changed database or sidecar state",
10200        )?;
10201        let reopened = AtlasStore::open_read_only_for_project(&database, &bound_root)?;
10202        require_eq(
10203            &reopened.project_root_identity()?,
10204            &persisted_identity,
10205            "current binding after wrong-root init",
10206        )?;
10207        Ok(())
10208    }
10209
10210    #[cfg(any(unix, windows))]
10211    #[test]
10212    fn init_admits_legacy_config_before_repairing_missing_root_identity()
10213    -> Result<(), Box<dyn Error>> {
10214        let temp = tempfile::tempdir()?;
10215        let root = temp.path().join("legacy-admission-root");
10216        let database = temp.path().join("legacy-admission.db");
10217        let outside = temp.path().join("missing-legacy-target.toml");
10218        fs::create_dir(&root)?;
10219        let original_identity = {
10220            let store = AtlasStore::open_for_project(&database, &root)?;
10221            store.project_instance_id()?
10222        };
10223        let connection = rusqlite::Connection::open(&database)?;
10224        connection.execute("DELETE FROM project_root_identity", [])?;
10225        drop(connection);
10226        let config = root.join("projectatlas.toml");
10227        if !create_document_target_symlink(&outside, &config)? {
10228            return Ok(());
10229        }
10230        let database_before = fs::read(&database)?;
10231        let sidecars_before = ["wal", "shm", "journal"]
10232            .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
10233        let result = run_init_bootstrap(
10234            &root,
10235            &database,
10236            Some(&config),
10237            &InitBootstrapOptions {
10238                no_scan: true,
10239                force_rescan: false,
10240                text_index_max_bytes: None,
10241            },
10242        );
10243        require_eq(
10244            &result.is_err(),
10245            &true,
10246            "dangling managed legacy config was admitted",
10247        )?;
10248        require_eq(
10249            &(fs::read(&database)? == database_before),
10250            &true,
10251            "config refusal happened after database identity repair",
10252        )?;
10253        require_eq(
10254            &["wal", "shm", "journal"]
10255                .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok()),
10256            &sidecars_before,
10257            "config refusal changed SQLite sidecars",
10258        )?;
10259        require_eq(
10260            &(!outside.exists() && !root.join(".projectatlas").exists()),
10261            &true,
10262            "config refusal created generated state",
10263        )?;
10264        fs::remove_file(&config)?;
10265        let retry = run_init_bootstrap(
10266            &root,
10267            &database,
10268            Some(&config),
10269            &InitBootstrapOptions {
10270                no_scan: true,
10271                force_rescan: false,
10272                text_index_max_bytes: None,
10273            },
10274        )?;
10275        let reopened = AtlasStore::open_for_project(&database, &root)?;
10276        require_eq(
10277            &(retry.ok && config.is_file()),
10278            &true,
10279            "restored legacy config could not initialize",
10280        )?;
10281        require_eq(
10282            &reopened.project_instance_id()?,
10283            &original_identity,
10284            "supported retry changed the project identity",
10285        )?;
10286        require_eq(
10287            &reopened.project_root_identity()?.is_some(),
10288            &true,
10289            "supported retry did not repair the native root identity",
10290        )?;
10291        Ok(())
10292    }
10293
10294    #[test]
10295    fn init_repairs_current_missing_root_identity_from_bound_metadata() -> Result<(), Box<dyn Error>>
10296    {
10297        let temp = tempfile::tempdir()?;
10298        let root = temp.path().join("incomplete-binding-root");
10299        let database = temp.path().join("incomplete-binding.db");
10300        fs::create_dir(&root)?;
10301        let project_instance_id = {
10302            let store = AtlasStore::open_for_project(&database, &root)?;
10303            store
10304                .project_instance_id()?
10305                .ok_or_else(|| io::Error::other("incomplete binding project identity is missing"))?
10306        };
10307        let connection = rusqlite::Connection::open(&database)?;
10308        connection.execute("DELETE FROM project_root_identity", [])?;
10309        drop(connection);
10310
10311        let ordinary_error = AtlasStore::open_for_project(&database, &root)
10312            .err()
10313            .ok_or_else(|| io::Error::other("ordinary open repaired incomplete binding"))?;
10314        require_eq(
10315            &matches!(ordinary_error, DbError::ProjectRootIdentityMissing),
10316            &true,
10317            "ordinary open returned the wrong incomplete-binding error",
10318        )?;
10319
10320        let wrong_root = temp.path().join("incomplete-binding-wrong-root");
10321        fs::create_dir(&wrong_root)?;
10322        let database_before = fs::read(&database)?;
10323        let sidecars_before = ["wal", "shm", "journal"]
10324            .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
10325        let wrong_result = run_init_bootstrap(
10326            &wrong_root,
10327            &database,
10328            None,
10329            &InitBootstrapOptions {
10330                no_scan: true,
10331                force_rescan: false,
10332                text_index_max_bytes: None,
10333            },
10334        );
10335        require_eq(
10336            &matches!(wrong_result, Err(CliError::ProjectMismatch(_))),
10337            &true,
10338            "incomplete-binding init accepted a different root",
10339        )?;
10340        require_eq(
10341            &fs::read(&database)?,
10342            &database_before,
10343            "wrong-root incomplete-binding init changed database bytes",
10344        )?;
10345        require_eq(
10346            &["wal", "shm", "journal"]
10347                .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok()),
10348            &sidecars_before,
10349            "wrong-root incomplete-binding init changed SQLite sidecars",
10350        )?;
10351
10352        let report = run_init_bootstrap(
10353            &root,
10354            &database,
10355            None,
10356            &InitBootstrapOptions {
10357                no_scan: true,
10358                force_rescan: false,
10359                text_index_max_bytes: None,
10360            },
10361        )?;
10362        require_eq(
10363            &report.ok,
10364            &true,
10365            "explicit init did not repair the incomplete binding",
10366        )?;
10367
10368        let reopened = AtlasStore::open_for_project(&database, &root)?;
10369        require_eq(
10370            &reopened.project_instance_id()?,
10371            &Some(project_instance_id),
10372            "explicit init changed the project identity while repairing the root",
10373        )?;
10374        require_eq(
10375            &reopened.project_root_identity()?,
10376            &Some(projectatlas_core::CanonicalProjectRoot::from_path(&root)?),
10377            "explicit init did not restore the native root identity",
10378        )?;
10379        Ok(())
10380    }
10381
10382    #[cfg(windows)]
10383    #[test]
10384    fn init_rejects_predecessor_wrong_root_before_project_writes() -> Result<(), Box<dyn Error>> {
10385        let temp = tempfile::tempdir()?;
10386        let selected_root = temp.path().join("selected-predecessor-root");
10387        let bound_root = temp.path().join("bound-predecessor-root");
10388        let database = temp.path().join("external-predecessor.db");
10389        let config_path = selected_root.join("external-config/config.toml");
10390        fs::create_dir_all(&selected_root)?;
10391        fs::create_dir_all(&bound_root)?;
10392        drop(AtlasStore::open_for_project(&database, &bound_root)?);
10393        {
10394            let connection = rusqlite::Connection::open(&database)?;
10395            drop_native_worktree_identity_schema(&connection)?;
10396            connection.execute_batch(
10397                "DROP TABLE project_root_identity;
10398                 DROP TABLE IF EXISTS graph_identity_rejections;
10399                 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
10400            )?;
10401        }
10402        read_legacy_project_root_candidate_read_only(&database)?;
10403        let database_before = fs::read(&database)?;
10404        let sidecars_before = ["wal", "shm", "journal"]
10405            .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
10406        let selected_project_dir = selected_root.join(".projectatlas");
10407        let config_parent = config_path
10408            .parent()
10409            .ok_or_else(|| io::Error::other("selected predecessor config has no parent"))?;
10410
10411        let result = run_init_bootstrap(
10412            &selected_root,
10413            &database,
10414            Some(&config_path),
10415            &InitBootstrapOptions {
10416                no_scan: true,
10417                force_rescan: false,
10418                text_index_max_bytes: None,
10419            },
10420        );
10421        require_eq(
10422            &matches!(result, Err(CliError::ProjectMismatch(_))),
10423            &true,
10424            "predecessor wrong-root init did not return a typed project mismatch",
10425        )?;
10426        require_eq(
10427            &(!selected_project_dir.exists() && !config_parent.exists() && !config_path.exists()),
10428            &true,
10429            "predecessor wrong-root init created selected project or config state",
10430        )?;
10431        require_eq(
10432            &(fs::read(&database)? == database_before
10433                && ["wal", "shm", "journal"]
10434                    .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok())
10435                    == sidecars_before),
10436            &true,
10437            "predecessor wrong-root init changed database or sidecar state",
10438        )?;
10439        Ok(())
10440    }
10441
10442    #[cfg(unix)]
10443    #[test]
10444    fn runtime_rejects_non_authoritative_predecessor_before_config_discovery()
10445    -> Result<(), Box<dyn Error>> {
10446        fn is_ambiguous_predecessor(error: &CliError) -> bool {
10447            matches!(error, CliError::Db(DbError::ProjectRootIdentityMissing))
10448        }
10449
10450        use std::ffi::OsString;
10451        use std::os::unix::ffi::OsStringExt;
10452
10453        let temp = tempfile::tempdir()?;
10454        let raw_root = temp
10455            .path()
10456            .join(OsString::from_vec(b"runtime-repo\\name".to_vec()));
10457        let replacement_root = temp.path().join("runtime-repo/name");
10458        let database = temp.path().join("runtime-custom-predecessor.db");
10459        fs::create_dir_all(&raw_root)?;
10460        drop(AtlasStore::open_for_project(&database, &raw_root)?);
10461        {
10462            let connection = rusqlite::Connection::open(&database)?;
10463            drop_native_worktree_identity_schema(&connection)?;
10464            connection.execute_batch(
10465                "DROP TABLE project_root_identity;
10466                 DROP TABLE IF EXISTS graph_identity_rejections;
10467                 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
10468            )?;
10469            connection.execute(
10470                "INSERT INTO metadata(key, value) VALUES('project_root', ?1)
10471                 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
10472                [replacement_root.to_string_lossy().into_owned()],
10473            )?;
10474        }
10475        let replacement_config = replacement_root.join(".projectatlas/config.toml");
10476        fs::create_dir_all(
10477            replacement_config
10478                .parent()
10479                .ok_or_else(|| io::Error::other("replacement config has no parent"))?,
10480        )?;
10481        fs::write(
10482            &replacement_config,
10483            format!(
10484                "[project]\nroot = {}\n",
10485                serde_json::to_string(&replacement_root.to_string_lossy())?
10486            ),
10487        )?;
10488        let replacement_config_before = fs::read(&replacement_config)?;
10489        require_condition(
10490            matches!(
10491                read_legacy_project_root_candidate_read_only(&database),
10492                Err(DbError::ProjectRootIdentityMissing)
10493            ),
10494            "non-authoritative predecessor candidate was exposed",
10495        )?;
10496        let database_before = fs::read(&database)?;
10497        let sidecars_before = ["wal", "shm", "journal"]
10498            .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
10499
10500        require_condition(
10501            default_mcp_project_root(&database, None)
10502                .is_err_and(|error| is_ambiguous_predecessor(&error)),
10503            "default MCP discovery admitted an ambiguous predecessor",
10504        )?;
10505        require_condition(
10506            default_cli_project_root(&database, None, false)
10507                .is_err_and(|error| is_ambiguous_predecessor(&error)),
10508            "default CLI discovery admitted an ambiguous predecessor",
10509        )?;
10510        require_condition(
10511            resolved_mcp_config_path(&database, None)
10512                .is_err_and(|error| is_ambiguous_predecessor(&error)),
10513            "MCP config discovery admitted an ambiguous predecessor",
10514        )?;
10515        require_condition(
10516            resolved_mcp_config_path(&database, Some(&replacement_config))
10517                .is_err_and(|error| is_ambiguous_predecessor(&error)),
10518            "explicit MCP config bypassed ambiguous predecessor admission",
10519        )?;
10520        require_condition(
10521            crate::build_harness_mcp_config_report(
10522                crate::HarnessConfig::McpJson,
10523                "ambiguous-predecessor",
10524                &database,
10525                None,
10526                false,
10527            )
10528            .is_err_and(|error| is_ambiguous_predecessor(&error)),
10529            "generated MCP config admitted an ambiguous predecessor",
10530        )?;
10531        require_condition(
10532            build_settings_report(&database, None, OutputFormat::Json)
10533                .is_err_and(|error| is_ambiguous_predecessor(&error)),
10534            "settings discovery admitted an ambiguous predecessor",
10535        )?;
10536        require_condition(
10537            fs::read(&replacement_config)? == replacement_config_before,
10538            "ambiguous predecessor discovery changed the replacement config",
10539        )?;
10540        require_condition(
10541            fs::read(&database)? == database_before
10542                && ["wal", "shm", "journal"]
10543                    .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok())
10544                    == sidecars_before,
10545            "ambiguous predecessor discovery changed database or sidecars",
10546        )?;
10547        Ok(())
10548    }
10549
10550    #[test]
10551    fn synchronized_repository_read_holds_catalog_writer_exclusion() -> Result<(), Box<dyn Error>> {
10552        let temp = tempfile::tempdir()?;
10553        let root = temp.path().join("control");
10554        let database = root.join(".projectatlas/projectatlas.db");
10555        fs::create_dir_all(
10556            database
10557                .parent()
10558                .ok_or_else(|| io::Error::other("control database has no parent"))?,
10559        )?;
10560        let control = AtlasStore::open_for_project(&database, &root)?;
10561        let control_project = control
10562            .project_instance_id()?
10563            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
10564        let writer_blocked = std::cell::Cell::new(false);
10565
10566        let overview = synchronize_registered_worktree_usage_with_catalog_validation(
10567            &database,
10568            &root,
10569            Some(control_project),
10570            |_, _| Ok(()),
10571            || Ok(()),
10572            |reader| {
10573                let contender = rusqlite::Connection::open(&database).map_err(DbError::from)?;
10574                contender
10575                    .busy_timeout(Duration::ZERO)
10576                    .map_err(DbError::from)?;
10577                match contender.execute_batch("BEGIN IMMEDIATE") {
10578                    Err(rusqlite::Error::SqliteFailure(code, _))
10579                        if matches!(
10580                            code.code,
10581                            rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
10582                        ) =>
10583                    {
10584                        writer_blocked.set(true);
10585                    }
10586                    Ok(()) => contender.execute_batch("ROLLBACK").map_err(DbError::from)?,
10587                    Err(error) => return Err(DbError::from(error)),
10588                }
10589                reader.repository_token_overview()
10590            },
10591        )?;
10592        require_eq(
10593            &writer_blocked.get(),
10594            &true,
10595            "repository aggregate read writer exclusion",
10596        )?;
10597        require_eq(&overview.calls, &0, "empty synchronized repository report")
10598    }
10599
10600    #[test]
10601    fn synchronization_rejects_a_registration_committed_after_catalog_capture()
10602    -> Result<(), Box<dyn Error>> {
10603        let temp = tempfile::tempdir()?;
10604        let root = temp.path().join("control");
10605        let database = root.join(".projectatlas/projectatlas.db");
10606        let common = temp.path().join("common.git");
10607        let admin = common.join("worktrees/added");
10608        let target = temp.path().join("added");
10609        for path in [&root, &admin, &target] {
10610            fs::create_dir_all(path)?;
10611        }
10612        fs::create_dir_all(
10613            database
10614                .parent()
10615                .ok_or_else(|| io::Error::other("control database has no parent"))?,
10616        )?;
10617        let control = AtlasStore::open_for_project(&database, &root)?;
10618        let control_project = control
10619            .project_instance_id()?
10620            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
10621        let alias = WorktreeAlias::parse("added")?;
10622
10623        let result = synchronize_registered_worktree_usage_with_catalog_validation(
10624            &database,
10625            &root,
10626            Some(control_project),
10627            |_, _| Ok(()),
10628            || {
10629                control.register_worktree(
10630                    &alias,
10631                    &common,
10632                    &admin,
10633                    &"01".repeat(32),
10634                    &target,
10635                    None,
10636                    1,
10637                )?;
10638                Ok(())
10639            },
10640            |_| Ok(()),
10641        );
10642        require_eq(
10643            &result.as_ref().is_err_and(|error| {
10644                error
10645                    .to_string()
10646                    .contains("catalog changed during aggregate synchronization")
10647            }),
10648            &true,
10649            "concurrent registration catalog rejection",
10650        )?;
10651        require_eq(
10652            &control.worktree_registration(&alias)?.registration_id,
10653            &1,
10654            "concurrently committed registration",
10655        )
10656    }
10657
10658    #[test]
10659    fn unbound_worktree_synchronization_revalidates_git_lifecycle_before_binding()
10660    -> Result<(), Box<dyn Error>> {
10661        let temp = tempfile::tempdir()?;
10662        let primary = temp.path().join("primary");
10663        let linked = temp.path().join("linked");
10664        fs::create_dir(&primary)?;
10665        run_git_fixture(StdCommand::new("git").current_dir(&primary).arg("init"))?;
10666        for (key, value) in [
10667            ("user.name", "ProjectAtlas Test"),
10668            ("user.email", "projectatlas@example.invalid"),
10669            ("commit.gpgsign", "false"),
10670            ("core.autocrlf", "false"),
10671        ] {
10672            run_git_fixture(
10673                StdCommand::new("git")
10674                    .current_dir(&primary)
10675                    .args(["config", key, value]),
10676            )?;
10677        }
10678        fs::write(primary.join("lib.rs"), "pub fn primary() {}\n")?;
10679        run_git_fixture(
10680            StdCommand::new("git")
10681                .current_dir(&primary)
10682                .args(["add", "."]),
10683        )?;
10684        run_git_fixture(
10685            StdCommand::new("git")
10686                .current_dir(&primary)
10687                .args(["commit", "-m", "fixture"]),
10688        )?;
10689        run_git_fixture(
10690            StdCommand::new("git")
10691                .current_dir(&primary)
10692                .args(["worktree", "add", "-b", "captured"])
10693                .arg(&linked),
10694        )?;
10695
10696        let RepositoryStructure::Git(repository) =
10697            projectatlas_fs::worktree::discover_repository_structure(&primary)?
10698        else {
10699            return Err(io::Error::other("Git fixture was not discovered").into());
10700        };
10701        let root = linked.canonicalize()?;
10702        let entry = repository
10703            .worktrees
10704            .iter()
10705            .find(|entry| match &entry.state {
10706                GitWorktreeState::Active {
10707                    root: candidate, ..
10708                } => candidate == &root,
10709                GitWorktreeState::Missing { .. } | GitWorktreeState::Invalid { .. } => false,
10710            })
10711            .ok_or_else(|| io::Error::other("linked fixture was not discovered"))?;
10712        let administrative_directory = entry.administrative_directory.clone();
10713        let administrative_identity = git_administrative_identity(&administrative_directory)?;
10714        let alias = WorktreeAlias::parse("captured")?;
10715        let control_database = primary.join(".projectatlas/projectatlas.db");
10716        fs::create_dir_all(primary.join(".projectatlas"))?;
10717        let control = AtlasStore::open_for_project(&control_database, &primary)?;
10718        let registration = control.register_worktree(
10719            &alias,
10720            &repository.common_directory,
10721            &administrative_directory,
10722            &administrative_identity,
10723            &root,
10724            None,
10725            1,
10726        )?;
10727        require_registered_worktree_lifecycle(&registration, &root)?;
10728
10729        let target_database = root.join(".projectatlas/projectatlas.db");
10730        fs::create_dir_all(root.join(".projectatlas"))?;
10731        let target = AtlasStore::open_for_project(&target_database, &root)?;
10732        target.record_usage(&usage_from_text(
10733            "captured",
10734            "atlas_overview",
10735            None,
10736            None,
10737            "pub fn captured() {}",
10738            "repository overview",
10739        ))?;
10740        let snapshot = target.export_worktree_usage_snapshot()?;
10741        drop(target);
10742
10743        let preserved_target = root.join(".projectatlas-snapshot-race");
10744        let replacement_project = std::cell::Cell::new(None);
10745        let replaced_database = synchronize_registered_worktree_usage_with_catalog_validation(
10746            &control_database,
10747            &primary,
10748            control.project_instance_id()?,
10749            |captured, captured_root| {
10750                require_eq(
10751                    &captured.registration_id,
10752                    &registration.registration_id,
10753                    "snapshot-race registration",
10754                )
10755                .map_err(|error| CliError::InvalidInput(error.to_string()))?;
10756                if captured_root != root.as_path() {
10757                    return Err(CliError::InvalidInput(
10758                        "snapshot-race root changed".to_string(),
10759                    ));
10760                }
10761                fs::rename(root.join(".projectatlas"), &preserved_target)?;
10762                fs::create_dir(root.join(".projectatlas"))?;
10763                let replacement = AtlasStore::open_for_project(&target_database, &root)?;
10764                replacement_project.set(replacement.project_instance_id()?);
10765                Ok(())
10766            },
10767            || Ok(()),
10768            |_| Ok(()),
10769        );
10770        require_eq(
10771            &replaced_database.as_ref().is_err_and(|error| {
10772                error
10773                    .to_string()
10774                    .contains("atlas changed after its usage snapshot was captured")
10775            }),
10776            &true,
10777            "same-lifecycle database replacement rejection",
10778        )?;
10779        require_eq(
10780            &(replacement_project.get().is_some()
10781                && replacement_project.get() != Some(snapshot.project_instance_id())),
10782            &true,
10783            "snapshot-race replacement identity",
10784        )?;
10785        let stored = control.worktree_registration(&alias)?;
10786        require_eq(
10787            &(stored.project_instance_id.is_none()
10788                && stored.accepted_telemetry_revision == 0
10789                && control.registered_worktree_token_overview(&alias)?.calls == 0),
10790            &true,
10791            "database replacement rollback state",
10792        )?;
10793        fs::remove_dir_all(root.join(".projectatlas"))?;
10794        fs::rename(&preserved_target, root.join(".projectatlas"))?;
10795
10796        run_git_fixture(
10797            StdCommand::new("git")
10798                .current_dir(&primary)
10799                .args(["worktree", "remove", "--force"])
10800                .arg(&linked),
10801        )?;
10802        run_git_fixture(
10803            StdCommand::new("git")
10804                .current_dir(&primary)
10805                .args(["worktree", "add", "-b", "replacement"])
10806                .arg(&linked),
10807        )?;
10808        fs::create_dir_all(root.join(".projectatlas"))?;
10809        let replacement = AtlasStore::open_for_project(&target_database, &root)?;
10810        let replacement_project = replacement
10811            .project_instance_id()?
10812            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
10813        require_eq(
10814            &(replacement_project != snapshot.project_instance_id()),
10815            &true,
10816            "replacement worktree atlas identity",
10817        )?;
10818        drop(replacement);
10819
10820        let rejected = require_registered_worktree_lifecycle(&registration, &root);
10821        require_eq(
10822            &rejected.as_ref().is_err_and(|error| {
10823                error
10824                    .to_string()
10825                    .contains("administrative lifecycle changed")
10826            }),
10827            &true,
10828            "replacement Git lifecycle rejection",
10829        )?;
10830        let stored = control.worktree_registration(&alias)?;
10831        require_eq(
10832            &stored.project_instance_id,
10833            &None,
10834            "failed revalidation project binding",
10835        )?;
10836        require_eq(
10837            &stored.accepted_telemetry_revision,
10838            &0,
10839            "failed revalidation telemetry revision",
10840        )?;
10841        require_eq(
10842            &control.registered_worktree_token_overview(&alias)?.calls,
10843            &0,
10844            "failed revalidation token totals",
10845        )?;
10846
10847        control.retire_worktree(registration.registration_id, &alias, 2)?;
10848        let RepositoryStructure::Git(replacement_repository) =
10849            projectatlas_fs::worktree::discover_repository_structure(&primary)?
10850        else {
10851            return Err(io::Error::other("replacement Git fixture was not discovered").into());
10852        };
10853        let replacement_entry = replacement_repository
10854            .worktrees
10855            .iter()
10856            .find(|entry| match &entry.state {
10857                GitWorktreeState::Active {
10858                    root: candidate, ..
10859                } => candidate == &root,
10860                GitWorktreeState::Missing { .. } | GitWorktreeState::Invalid { .. } => false,
10861            })
10862            .ok_or_else(|| io::Error::other("replacement worktree was not discovered"))?;
10863        let replacement_alias = WorktreeAlias::parse("replacement")?;
10864        let replacement_registration = control.register_worktree(
10865            &replacement_alias,
10866            &replacement_repository.common_directory,
10867            &replacement_entry.administrative_directory,
10868            &git_administrative_identity(&replacement_entry.administrative_directory)?,
10869            &root,
10870            None,
10871            3,
10872        )?;
10873        let preserved_target = root.join(".projectatlas-concurrent-binding");
10874        fs::rename(root.join(".projectatlas"), &preserved_target)?;
10875        let concurrent_binding = synchronize_registered_worktree_usage_with_catalog_validation(
10876            &control_database,
10877            &primary,
10878            control.project_instance_id()?,
10879            |_, _| Ok(()),
10880            || {
10881                fs::rename(&preserved_target, root.join(".projectatlas"))?;
10882                control.bind_worktree_project(
10883                    replacement_registration.registration_id,
10884                    &replacement_alias,
10885                    &root,
10886                    replacement_project,
10887                )?;
10888                Ok(())
10889            },
10890            |_| Ok(()),
10891        );
10892        require_eq(
10893            &concurrent_binding.as_ref().is_err_and(|error| {
10894                error
10895                    .to_string()
10896                    .contains("catalog changed during aggregate synchronization")
10897            }),
10898            &true,
10899            "concurrent unbound-to-bound catalog rejection",
10900        )?;
10901        require_eq(
10902            &control
10903                .worktree_registration(&replacement_alias)?
10904                .project_instance_id,
10905            &Some(replacement_project),
10906            "concurrently committed project binding",
10907        )
10908    }
10909
10910    #[test]
10911    fn worker_pools_respect_work_cardinality_and_runtime_ceiling() {
10912        for (work_items, max_workers, expected) in [
10913            (0, 16, 0),
10914            (1, 16, 1),
10915            (8, 16, 8),
10916            (64, 16, 16),
10917            (64, usize::MAX, INDEX_WORKER_SAFE_CEILING),
10918            (8, 0, 1),
10919        ] {
10920            assert_eq!(
10921                worker_count_for_work(work_items, max_workers),
10922                expected,
10923                "work_items={work_items}, max_workers={max_workers}"
10924            );
10925        }
10926    }
10927
10928    #[test]
10929    fn staged_classifications_prefer_registry_then_bounded_text_evidence() {
10930        let file = |path: &str, language: Option<&str>| Node {
10931            path: path.to_string(),
10932            kind: NodeKind::File,
10933            parent_path: None,
10934            extension: None,
10935            language: language.map(str::to_string),
10936            size_bytes: Some(1),
10937            mtime_ns: Some(1),
10938            content_hash: Some("hash".to_string()),
10939        };
10940        let nodes = vec![
10941            file("docs/guide.md", Some("markdown")),
10942            file("notes", None),
10943            file("blob", None),
10944        ];
10945        let rows = vec![
10946            TextIndexRow {
10947                path: "docs/guide.md".to_string(),
10948                text: None,
10949                reason: TextIndexSkipReason::BinaryOrNonUtf8,
10950            },
10951            TextIndexRow {
10952                path: "notes".to_string(),
10953                text: Some(IndexedFileText {
10954                    path: "notes".to_string(),
10955                    content_hash: Some("hash".to_string()),
10956                    byte_count: 1,
10957                    line_count: 1,
10958                    content: "x".to_string(),
10959                }),
10960                reason: TextIndexSkipReason::Indexed,
10961            },
10962            TextIndexRow {
10963                path: "blob".to_string(),
10964                text: None,
10965                reason: TextIndexSkipReason::BinaryOrNonUtf8,
10966            },
10967        ];
10968
10969        assert_eq!(
10970            stage_file_content_classifications(&nodes, &rows),
10971            vec![
10972                FileContentClassification {
10973                    path: "docs/guide.md".to_string(),
10974                    classification:
10975                        projectatlas_core::language::ContentClassification::Documentation,
10976                },
10977                FileContentClassification {
10978                    path: "notes".to_string(),
10979                    classification: projectatlas_core::language::ContentClassification::OtherText,
10980                },
10981                FileContentClassification {
10982                    path: "blob".to_string(),
10983                    classification: projectatlas_core::language::ContentClassification::Opaque,
10984                },
10985            ]
10986        );
10987    }
10988
10989    #[test]
10990    fn classified_navigation_capability_is_closed_and_directional() {
10991        let report = classified_navigation_capabilities();
10992        assert_eq!(
10993            report.classifications,
10994            [
10995                ContentClassification::Source,
10996                ContentClassification::Documentation,
10997                ContentClassification::ConfigurationData,
10998                ContentClassification::OtherText,
10999                ContentClassification::Opaque,
11000            ]
11001        );
11002        assert_eq!(
11003            report.selections,
11004            [
11005                ContentSelection::Source,
11006                ContentSelection::Documentation,
11007                ContentSelection::Both,
11008            ]
11009        );
11010        assert_eq!(report.document_relation, "extended:documents");
11011        assert_eq!(report.inbound_document_view, "documented_by");
11012    }
11013
11014    #[test]
11015    fn settings_publication_identity_rejects_mixed_snapshots() {
11016        let fingerprint = "a".repeat(64);
11017        let diagnostic = DatabasePublicationReport {
11018            state: IndexPublicationState::Complete,
11019            contract_fingerprint: Some(fingerprint.clone()),
11020            contract_fingerprint_state: DatabasePublicationContractState::Valid,
11021            generation: IndexGeneration::new(4),
11022        };
11023        let matching = IndexPublication {
11024            state: IndexPublicationState::Complete,
11025            contract_fingerprint: Some(fingerprint),
11026            generation: IndexGeneration::new(4),
11027        };
11028        assert!(settings_publication_matches(
11029            Some(&diagnostic),
11030            Some(&matching)
11031        ));
11032
11033        let next_generation = IndexPublication {
11034            generation: IndexGeneration::new(5),
11035            ..matching.clone()
11036        };
11037        assert!(!settings_publication_matches(
11038            Some(&diagnostic),
11039            Some(&next_generation)
11040        ));
11041
11042        let invalid = DatabasePublicationReport {
11043            contract_fingerprint: None,
11044            contract_fingerprint_state: DatabasePublicationContractState::Invalid,
11045            ..diagnostic
11046        };
11047        assert!(!settings_publication_matches(
11048            Some(&invalid),
11049            Some(&matching)
11050        ));
11051    }
11052
11053    #[cfg(unix)]
11054    #[test]
11055    fn settings_report_uses_native_db_identity_when_display_is_unavailable()
11056    -> Result<(), Box<dyn Error>> {
11057        use std::ffi::OsString;
11058        use std::os::unix::ffi::OsStringExt;
11059
11060        let temp = tempfile::tempdir()?;
11061        let raw_name = OsString::from_vec(vec![b'r', b'o', b'o', b't', 0x80]);
11062        let root = temp.path().join(&raw_name);
11063        let database = root.join(".projectatlas/projectatlas.db");
11064        fs::create_dir_all(
11065            database
11066                .parent()
11067                .ok_or_else(|| io::Error::other("raw-root database has no parent"))?,
11068        )?;
11069        drop(AtlasStore::open_for_project(&database, &root)?);
11070
11071        let report = build_settings_report(&database, None, OutputFormat::Json)?;
11072        require_eq(
11073            &report.root_detection_source,
11074            &"db".to_string(),
11075            "native database root detection source",
11076        )?;
11077        require_eq(
11078            &report.repo_root,
11079            &None,
11080            "unavailable native database root display",
11081        )?;
11082        let serialized = serde_json::to_string(&report)?;
11083        require_eq(
11084            &serialized.contains('\u{fffd}'),
11085            &false,
11086            "serialized settings fabricated a replacement root",
11087        )?;
11088        Ok(())
11089    }
11090
11091    #[cfg(windows)]
11092    #[test]
11093    fn lossless_native_path_display_preserves_absolute_volume_guid_paths()
11094    -> Result<(), Box<dyn Error>> {
11095        let volume = r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\repo\file";
11096        let volume_display = lossless_native_path_display(Path::new(volume))
11097            .ok_or_else(|| io::Error::other("volume-GUID path was not UTF-8"))?;
11098        require_eq(
11099            &volume_display,
11100            &volume.to_string(),
11101            "lossless volume-GUID display",
11102        )?;
11103        require_eq(
11104            &Path::new(&volume_display).is_absolute(),
11105            &true,
11106            "volume-GUID display remained absolute",
11107        )?;
11108
11109        let drive = lossless_native_path_display(Path::new(r"\\?\C:\repo\file"))
11110            .ok_or_else(|| io::Error::other("extended drive path was not UTF-8"))?;
11111        require_eq(
11112            &drive,
11113            &"C:/repo/file".to_string(),
11114            "normalized extended drive display",
11115        )?;
11116        require_eq(
11117            &Path::new(&drive).is_absolute(),
11118            &true,
11119            "extended drive display remained absolute",
11120        )?;
11121
11122        let unc = lossless_native_path_display(Path::new(r"\\?\UNC\server\share\repo\file"))
11123            .ok_or_else(|| io::Error::other("extended UNC path was not UTF-8"))?;
11124        require_eq(
11125            &unc,
11126            &"//server/share/repo/file".to_string(),
11127            "normalized extended UNC display",
11128        )?;
11129        require_eq(
11130            &Path::new(&unc).is_absolute(),
11131            &true,
11132            "extended UNC display remained absolute",
11133        )?;
11134        Ok(())
11135    }
11136
11137    #[cfg(windows)]
11138    #[test]
11139    fn settings_report_preserves_verbatim_native_root_projection() -> Result<(), Box<dyn Error>> {
11140        let temp = tempfile::tempdir()?;
11141        let base = temp
11142            .path()
11143            .to_str()
11144            .ok_or("temporary directory was not UTF-8")?;
11145        let long_component = "a".repeat(220);
11146        let root = PathBuf::from(format!(r"\\?\{base}\{long_component}"));
11147        fs::create_dir(&root)?;
11148        let database = root.join(".projectatlas/projectatlas.db");
11149        fs::create_dir_all(
11150            database
11151                .parent()
11152                .ok_or("verbatim settings database has no parent")?,
11153        )?;
11154        drop(AtlasStore::open_for_project(&database, &root)?);
11155
11156        let config = temp.path().join("verbatim-settings-config.toml");
11157        fs::write(
11158            &config,
11159            format!(
11160                "[project]\nroot = {}\n",
11161                serde_json::to_string(
11162                    &root
11163                        .to_str()
11164                        .ok_or("verbatim root was not UTF-8")?
11165                        .to_owned()
11166                )?
11167            ),
11168        )?;
11169        let expected = CanonicalProjectRoot::from_path(&root)?.display_string()?;
11170        if !expected.starts_with(r"\\?\") {
11171            return Err("verbatim root lost its extended prefix".into());
11172        }
11173
11174        let report = build_settings_report(&database, Some(&config), OutputFormat::Json)?;
11175        require_eq(
11176            &report.repo_root,
11177            &Some(expected.clone()),
11178            "JSON settings verbatim root",
11179        )?;
11180        let json = serde_json::to_value(&report)?;
11181        require_eq(
11182            &json.get("repo_root"),
11183            &Some(&Value::String(expected.clone())),
11184            "JSON settings verbatim root field",
11185        )?;
11186        let toon = crate::render_settings_report(&report);
11187        let toon_value: Value = toon_format::decode_default(&toon)?;
11188        require_eq(
11189            &toon_value.pointer("/settings/repo_root"),
11190            &Some(&Value::String(expected)),
11191            "TOON settings verbatim root field",
11192        )?;
11193        Ok(())
11194    }
11195
11196    #[cfg(windows)]
11197    #[test]
11198    fn settings_report_accepts_case_only_root_rename() -> Result<(), Box<dyn Error>> {
11199        let temp = tempfile::tempdir()?;
11200        let original = temp.path().join("CaseOnlyRoot");
11201        let staging = temp.path().join("CaseOnlyRootStaging");
11202        let renamed = temp.path().join("caseonlyroot");
11203        fs::create_dir(&original)?;
11204        let database = original.join(".projectatlas/projectatlas.db");
11205        fs::create_dir_all(
11206            database
11207                .parent()
11208                .ok_or_else(|| io::Error::other("case-only database has no parent"))?,
11209        )?;
11210        drop(AtlasStore::open_for_project(&database, &original)?);
11211
11212        fs::rename(&original, &staging)?;
11213        fs::rename(&staging, &renamed)?;
11214        let renamed_database = renamed.join(".projectatlas/projectatlas.db");
11215        let config = temp.path().join("case-only-config.toml");
11216        fs::write(
11217            &config,
11218            format!(
11219                "[project]\nroot = {}\n",
11220                serde_json::to_string(&renamed.to_string_lossy())?
11221            ),
11222        )?;
11223
11224        let report = build_settings_report(&renamed_database, Some(&config), OutputFormat::Json)?;
11225        require_eq(
11226            &report.root_verified,
11227            &true,
11228            "settings accepted case-only root rename",
11229        )?;
11230        require_eq(
11231            &report.root_mismatches.is_empty(),
11232            &true,
11233            "case-only root rename mismatch diagnostics",
11234        )?;
11235        let json = serde_json::to_value(&report)?;
11236        require_eq(
11237            &json.get("root_verified"),
11238            &Some(&Value::Bool(true)),
11239            "JSON settings root verification",
11240        )?;
11241        let toon = crate::render_settings_report(&report);
11242        let toon_value: Value = toon_format::decode_default(&toon)?;
11243        require_eq(
11244            &toon_value.pointer("/settings/root_verified"),
11245            &Some(&Value::Bool(true)),
11246            "TOON settings root verification",
11247        )?;
11248        Ok(())
11249    }
11250
11251    #[cfg(windows)]
11252    #[test]
11253    fn settings_report_rejects_case_sensitive_sibling() -> Result<(), Box<dyn Error>> {
11254        let temp = tempfile::tempdir()?;
11255        let parent = temp.path().join("case-sensitive-parent");
11256        fs::create_dir(&parent)?;
11257        let enabled = StdCommand::new("fsutil")
11258            .args(["file", "SetCaseSensitiveInfo"])
11259            .arg(&parent)
11260            .arg("enable")
11261            .status()
11262            .is_ok_and(|status| status.success());
11263        if !enabled {
11264            return Ok(());
11265        }
11266
11267        let stored_root = parent.join("Repo");
11268        let selected_root = parent.join("repo");
11269        fs::create_dir(&stored_root)?;
11270        fs::create_dir(&selected_root)?;
11271        let database = stored_root.join(".projectatlas/projectatlas.db");
11272        fs::create_dir_all(
11273            database
11274                .parent()
11275                .ok_or_else(|| io::Error::other("case-sensitive database has no parent"))?,
11276        )?;
11277        drop(AtlasStore::open_for_project(&database, &stored_root)?);
11278        let config = temp.path().join("case-sensitive-config.toml");
11279        fs::write(
11280            &config,
11281            format!(
11282                "[project]\nroot = {}\n",
11283                serde_json::to_string(&selected_root.to_string_lossy())?
11284            ),
11285        )?;
11286
11287        let report = build_settings_report(&database, Some(&config), OutputFormat::Json)?;
11288        require_eq(
11289            &report.root_verified,
11290            &false,
11291            "settings rejected case-sensitive sibling",
11292        )?;
11293        require_eq(
11294            &report.root_mismatches.is_empty(),
11295            &false,
11296            "case-sensitive sibling mismatch diagnostics",
11297        )?;
11298        let json = serde_json::to_value(&report)?;
11299        require_eq(
11300            &json.get("root_verified"),
11301            &Some(&Value::Bool(false)),
11302            "JSON case-sensitive sibling verification",
11303        )?;
11304        let toon = crate::render_settings_report(&report);
11305        let toon_value: Value = toon_format::decode_default(&toon)?;
11306        require_eq(
11307            &toon_value.pointer("/settings/root_verified"),
11308            &Some(&Value::Bool(false)),
11309            "TOON case-sensitive sibling verification",
11310        )?;
11311        Ok(())
11312    }
11313
11314    #[cfg(windows)]
11315    #[test]
11316    fn freshness_and_symbol_build_revalidate_case_only_root_without_sibling_mutation()
11317    -> Result<(), Box<dyn Error>> {
11318        let temp = tempfile::tempdir()?;
11319        let original = temp.path().join("CaseOnlyRuntimeRoot");
11320        let staging = temp.path().join("CaseOnlyRuntimeRootStaging");
11321        let renamed = temp.path().join("caseonlyruntimeroot");
11322        fs::create_dir(&original)?;
11323        fs::write(original.join("lib.rs"), "pub fn runtime_root() {}\n")?;
11324        let database = original.join(".projectatlas/projectatlas.db");
11325        fs::create_dir_all(
11326            database
11327                .parent()
11328                .ok_or_else(|| io::Error::other("case-only runtime database has no parent"))?,
11329        )?;
11330        let original_plan = ScanRuntimePlan::for_path(None, &original, None)?;
11331        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
11332        let mut original_store = open_atlas_store_for_project(&database, &original)?;
11333        run_scan_pipeline(&mut original_store, &original_plan, &symbol_options)?;
11334        let initial_project = original_store
11335            .project_instance_id()?
11336            .ok_or_else(|| io::Error::other("case-only runtime project identity is missing"))?;
11337        drop(original_store);
11338
11339        fs::rename(&original, &staging)?;
11340        fs::rename(&staging, &renamed)?;
11341        let renamed_database = renamed.join(".projectatlas/projectatlas.db");
11342        let fresh = open_fresh_atlas_store_for_project(&renamed_database, &renamed, None)?;
11343        require_eq(
11344            &fresh.project_instance_id()?,
11345            &Some(initial_project),
11346            "fresh read after case-only root rename",
11347        )?;
11348        drop(fresh);
11349
11350        let renamed_plan = ScanRuntimePlan::for_path(None, &renamed, None)?;
11351        let mut renamed_store = open_atlas_store_for_project(&renamed_database, &renamed)?;
11352        run_symbol_build_pipeline(&mut renamed_store, &renamed_plan, &symbol_options, None)?;
11353        require_eq(
11354            &renamed_store.project_instance_id()?,
11355            &Some(initial_project),
11356            "symbol build after case-only root rename",
11357        )?;
11358        drop(renamed_store);
11359
11360        let case_sensitive_parent = temp.path().join("runtime-case-sensitive-parent");
11361        fs::create_dir(&case_sensitive_parent)?;
11362        let enabled = StdCommand::new("fsutil")
11363            .args(["file", "SetCaseSensitiveInfo"])
11364            .arg(&case_sensitive_parent)
11365            .arg("enable")
11366            .status()
11367            .is_ok_and(|status| status.success());
11368        if !enabled {
11369            return Ok(());
11370        }
11371        let stored_root = case_sensitive_parent.join("Repo");
11372        let selected_root = case_sensitive_parent.join("repo");
11373        fs::create_dir(&stored_root)?;
11374        fs::create_dir(&selected_root)?;
11375        fs::write(stored_root.join("lib.rs"), "pub fn stored_root() {}\n")?;
11376        let sibling_database = stored_root.join(".projectatlas/projectatlas.db");
11377        fs::create_dir_all(
11378            sibling_database
11379                .parent()
11380                .ok_or_else(|| io::Error::other("case-sensitive runtime database has no parent"))?,
11381        )?;
11382        let sibling_plan = ScanRuntimePlan::for_path(None, &stored_root, None)?;
11383        let mut sibling_store = open_atlas_store_for_project(&sibling_database, &stored_root)?;
11384        run_scan_pipeline(&mut sibling_store, &sibling_plan, &symbol_options)?;
11385        drop(sibling_store);
11386
11387        let sidecar_bytes = |path: &Path| -> Result<Vec<Option<Vec<u8>>>, Box<dyn Error>> {
11388            [
11389                path.to_path_buf(),
11390                db_sidecar_path(path, "wal"),
11391                db_sidecar_path(path, "shm"),
11392                db_sidecar_path(path, "journal"),
11393            ]
11394            .into_iter()
11395            .map(|candidate| match fs::read(candidate) {
11396                Ok(bytes) => Ok(Some(bytes)),
11397                Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
11398                Err(error) => Err(error.into()),
11399            })
11400            .collect()
11401        };
11402        let durable_state = |path: &Path| -> Result<
11403            (
11404                Option<CanonicalProjectRoot>,
11405                Option<ProjectInstanceId>,
11406                Option<IndexPublication>,
11407            ),
11408            Box<dyn Error>,
11409        > {
11410            let store = AtlasStore::open_read_only(path)?;
11411            Ok((
11412                store.project_root_identity()?,
11413                store.project_instance_id()?,
11414                store.index_publication()?,
11415            ))
11416        };
11417
11418        drop(AtlasStore::open_read_only(&sibling_database)?);
11419        let before_sidecars = sidecar_bytes(&sibling_database)?;
11420        let before_state = durable_state(&sibling_database)?;
11421        let Err(CliError::ProjectMismatch(_)) =
11422            open_fresh_atlas_store_for_project(&sibling_database, &selected_root, None)
11423        else {
11424            return Err(io::Error::other("case-sensitive sibling was admitted").into());
11425        };
11426        let after_sidecars = sidecar_bytes(&sibling_database)?;
11427        let after_state = durable_state(&sibling_database)?;
11428        require_eq(
11429            &after_sidecars,
11430            &before_sidecars,
11431            "case-sensitive sibling sidecar state",
11432        )?;
11433        require_eq(
11434            &after_state,
11435            &before_state,
11436            "case-sensitive sibling durable state",
11437        )?;
11438        Ok(())
11439    }
11440
11441    #[test]
11442    fn registered_embedded_hosts_admit_native_symbol_work() {
11443        for capability in projectatlas_core::language::language_documentation_rows()
11444            .iter()
11445            .filter(|capability| capability.embedded_language.is_some())
11446        {
11447            assert!(
11448                is_symbol_candidate("selected.host", Some(capability.id)),
11449                "{}",
11450                capability.id
11451            );
11452        }
11453        for path in [
11454            "page.html",
11455            "page.htm",
11456            "page.HTML",
11457            "View.vue",
11458            "View.svelte",
11459        ] {
11460            assert!(is_symbol_candidate(path, None), "{path}");
11461            assert!(
11462                !is_symbol_candidate(path, Some("toon")),
11463                "explicit language overrides host extension"
11464            );
11465        }
11466        assert!(!is_symbol_candidate("page.css", Some("css")));
11467        assert!(!is_symbol_candidate("page.xml", Some("xml")));
11468    }
11469
11470    #[test]
11471    fn supplied_language_controls_symbol_candidate_owner() {
11472        for path in ["Cargo.toml", "src/App.vue", "scripts/Get-Atlas.ps1"] {
11473            assert!(!is_symbol_candidate(path, Some("toon")), "{path}");
11474        }
11475        assert!(is_symbol_candidate("data/report.toon", Some("rust")));
11476        assert!(is_symbol_candidate(
11477            "data/report.toon",
11478            Some("cargo-manifest")
11479        ));
11480        assert!(is_symbol_candidate("data/report.toon", Some("vue")));
11481        assert!(is_symbol_candidate("data/report.toon", Some("powershell")));
11482        assert!(!is_symbol_candidate("data/report.toon", Some("toon")));
11483    }
11484
11485    #[test]
11486    fn optional_catalog_symbol_work_requires_effective_admission() {
11487        assert!(is_symbol_candidate("scripts/report.awk", Some("awk")));
11488        assert!(!is_symbol_candidate_for_admission(
11489            "scripts/report.awk",
11490            Some("awk"),
11491            false,
11492        ));
11493        assert!(is_symbol_candidate_for_admission(
11494            "scripts/report.awk",
11495            Some("awk"),
11496            true,
11497        ));
11498        assert!(is_symbol_candidate_for_admission(
11499            "src/lib.rs",
11500            Some("rust"),
11501            false,
11502        ));
11503    }
11504
11505    #[test]
11506    fn missing_language_preserves_specialized_symbol_candidate_inference() {
11507        for path in [
11508            "Cargo.toml",
11509            "Cargo.lock",
11510            "src/App.vue",
11511            "src/App.VUE",
11512            "scripts/Get-Atlas.ps1",
11513            "scripts/Atlas.psm1",
11514            "scripts/Atlas.psd1",
11515        ] {
11516            assert!(is_symbol_candidate(path, None), "{path}");
11517        }
11518        assert!(!is_symbol_candidate("data/report.toon", None));
11519    }
11520
11521    #[test]
11522    fn rejected_database_location_does_not_create_or_change_database() -> Result<(), Box<dyn Error>>
11523    {
11524        let temp = tempfile::tempdir()?;
11525        let root = temp.path().join("repository");
11526        fs::create_dir_all(&root)?;
11527
11528        for uncertain in [false, true] {
11529            let database_parent = temp
11530                .path()
11531                .join(if uncertain {
11532                    "uncertain"
11533                } else {
11534                    "unsupported"
11535                })
11536                .join("nested");
11537            let database = database_parent.join("projectatlas.db");
11538            let rejected_path = database.clone();
11539            let result = open_atlas_store_for_project_with_location_validator(
11540                &database,
11541                &root,
11542                move |_path| {
11543                    if uncertain {
11544                        Err(projectatlas_db::DbError::DatabaseFilesystemUncertain {
11545                            path: rejected_path,
11546                            mount_point: None,
11547                            filesystem_type: None,
11548                            reason: "injected filesystem uncertainty".to_string(),
11549                        })
11550                    } else {
11551                        Err(projectatlas_db::DbError::DatabaseFilesystemUnsupported {
11552                            path: rejected_path,
11553                            mount_point: None,
11554                            filesystem_type: Some("nfs".to_string()),
11555                        })
11556                    }
11557                },
11558            );
11559            let rejected = matches!(
11560                result,
11561                Err(CliError::Db(
11562                    projectatlas_db::DbError::DatabaseFilesystemUnsupported { .. }
11563                        | projectatlas_db::DbError::DatabaseFilesystemUncertain { .. }
11564                ))
11565            );
11566            require_eq(&rejected, &true, "typed location rejection")?;
11567            require_eq(
11568                &database_parent.exists(),
11569                &false,
11570                "rejected database parent absence",
11571            )?;
11572            require_eq(&database.exists(), &false, "rejected database absence")?;
11573
11574            let existing_database = temp.path().join(if uncertain {
11575                "uncertain-existing.db"
11576            } else {
11577                "unsupported-existing.db"
11578            });
11579            let original_bytes = b"existing database bytes stay untouched";
11580            fs::write(&existing_database, original_bytes)?;
11581            let rejected_path = existing_database.clone();
11582            let existing_result = open_atlas_store_for_project_with_location_validator(
11583                &existing_database,
11584                &root,
11585                move |_path| {
11586                    if uncertain {
11587                        Err(projectatlas_db::DbError::DatabaseFilesystemUncertain {
11588                            path: rejected_path,
11589                            mount_point: None,
11590                            filesystem_type: None,
11591                            reason: "injected filesystem uncertainty".to_string(),
11592                        })
11593                    } else {
11594                        Err(projectatlas_db::DbError::DatabaseFilesystemUnsupported {
11595                            path: rejected_path,
11596                            mount_point: None,
11597                            filesystem_type: Some("nfs".to_string()),
11598                        })
11599                    }
11600                },
11601            );
11602            require_eq(
11603                &matches!(
11604                    existing_result,
11605                    Err(CliError::Db(
11606                        projectatlas_db::DbError::DatabaseFilesystemUnsupported { .. }
11607                            | projectatlas_db::DbError::DatabaseFilesystemUncertain { .. }
11608                    ))
11609                ),
11610                &true,
11611                "typed existing location rejection",
11612            )?;
11613            require_eq(
11614                &fs::read(&existing_database)?,
11615                &original_bytes.to_vec(),
11616                "rejected existing database bytes",
11617            )?;
11618        }
11619        Ok(())
11620    }
11621
11622    #[test]
11623    fn operation_deadline_preserves_default_ceiling_and_fail_closed_bounds() {
11624        for (timeout_seconds, expected) in [
11625            (None, DEFAULT_INDEX_WORK_TIMEOUT),
11626            (Some(0), Duration::ZERO),
11627            (Some(1), Duration::from_secs(1)),
11628            (
11629                Some(DEFAULT_INDEX_WORK_TIMEOUT.as_secs() + 1),
11630                DEFAULT_INDEX_WORK_TIMEOUT,
11631            ),
11632            (Some(u64::MAX), Duration::ZERO),
11633        ] {
11634            let options = SymbolBuildOptions::new(1_024, Some(1), timeout_seconds);
11635            let control = index_work_control(&options);
11636            assert_eq!(
11637                control
11638                    .deadline()
11639                    .map(|deadline| deadline.duration_since(control.started_at())),
11640                Some(expected)
11641            );
11642        }
11643    }
11644
11645    #[test]
11646    fn oversized_source_delta_retains_exact_counts_without_partial_refresh_paths()
11647    -> Result<(), Box<dyn Error>> {
11648        let temp = tempfile::tempdir()?;
11649        let mut delta = SourceDelta::default();
11650        for index in 0..=MAX_INCREMENTAL_CHANGED_PATHS {
11651            delta.record(
11652                Some(&Node {
11653                    path: format!("file_{index:08}.rs"),
11654                    kind: NodeKind::File,
11655                    parent_path: None,
11656                    extension: None,
11657                    language: None,
11658                    size_bytes: Some(1),
11659                    mtime_ns: None,
11660                    content_hash: None,
11661                }),
11662                None,
11663            );
11664        }
11665        require_eq(
11666            &delta.paths.len(),
11667            &MAX_INCREMENTAL_CHANGED_PATHS,
11668            "bounded retained paths",
11669        )?;
11670        let plan = delta
11671            .finish(temp.path())
11672            .ok_or_else(|| io::Error::other("oversized change set disappeared"))?;
11673        require_eq(
11674            &plan.report.changed,
11675            &(MAX_INCREMENTAL_CHANGED_PATHS + 1),
11676            "complete change count",
11677        )?;
11678        require_eq(
11679            &plan.report.added,
11680            &plan.report.changed,
11681            "complete addition count",
11682        )?;
11683        require_eq(
11684            &plan.report.sample_paths.len(),
11685            &INDEX_FRESHNESS_SAMPLE_LIMIT,
11686            "bounded ordered sample",
11687        )?;
11688        require_eq(
11689            &plan.paths_complete,
11690            &false,
11691            "incomplete path retention is explicit",
11692        )?;
11693        require_eq(
11694            &plan.requires_full_scan,
11695            &true,
11696            "complete discovery selected",
11697        )?;
11698        require_eq(
11699            &plan.paths.is_empty(),
11700            &true,
11701            "partial delta cannot be used for publication",
11702        )?;
11703        Ok(())
11704    }
11705
11706    #[test]
11707    fn normal_read_refresh_delta_enforces_path_and_byte_budgets() -> Result<(), Box<dyn Error>> {
11708        let temp = tempfile::tempdir()?;
11709        let node = |path: String, size_bytes: u64| Node {
11710            path,
11711            kind: NodeKind::File,
11712            parent_path: None,
11713            extension: Some(".rs".to_string()),
11714            language: Some("rust".to_string()),
11715            size_bytes: Some(size_bytes),
11716            mtime_ns: Some(1),
11717            content_hash: Some("current-content".to_string()),
11718        };
11719        let path_bounded_nodes = (0..=NORMAL_READ_REFRESH_MAX_PATHS)
11720            .map(|index| node(format!("src/file_{index}.rs"), 1))
11721            .collect::<Vec<_>>();
11722        let path_delta = source_node_delta(temp.path(), &path_bounded_nodes, &[])
11723            .ok_or_else(|| io::Error::other("path-bounded freshness delta was missing"))?;
11724        require_eq(
11725            &path_delta.report.scope,
11726            &IndexRefreshScope::Full,
11727            "path-bounded refresh scope",
11728        )?;
11729        require_eq(
11730            &path_delta.report.changed,
11731            &(NORMAL_READ_REFRESH_MAX_PATHS + 1),
11732            "path-bounded change count",
11733        )?;
11734        require_eq(
11735            &path_delta.report.sample_paths.len(),
11736            &INDEX_FRESHNESS_SAMPLE_LIMIT,
11737            "bounded freshness sample",
11738        )?;
11739
11740        let byte_bounded_nodes = vec![node(
11741            "src/large.rs".to_string(),
11742            NORMAL_READ_REFRESH_MAX_BYTES + 1,
11743        )];
11744        let byte_delta = source_node_delta(temp.path(), &byte_bounded_nodes, &[])
11745            .ok_or_else(|| io::Error::other("byte-bounded freshness delta was missing"))?;
11746        require_eq(
11747            &byte_delta.report.scope,
11748            &IndexRefreshScope::Full,
11749            "byte-bounded refresh scope",
11750        )?;
11751        Ok(())
11752    }
11753
11754    #[test]
11755    fn controlled_freshness_plan_uses_the_callers_cancellation() -> Result<(), Box<dyn Error>> {
11756        let temp = tempfile::tempdir()?;
11757        let root = temp.path().join("repo");
11758        fs::create_dir(&root)?;
11759        fs::write(root.join("lib.rs"), "pub fn indexed() {}\n")?;
11760        let db_path = root.join(".projectatlas").join("projectatlas.db");
11761        let plan = ScanRuntimePlan::for_path(None, &root, None)?;
11762        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
11763        let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
11764        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
11765        drop(store);
11766
11767        let invalid_config = root.join("invalid-config.toml");
11768        fs::write(&invalid_config, "[invalid")?;
11769        let control = IndexWorkControl::new(IndexCancellation::new(), None);
11770        control.cancel();
11771        let result = open_fresh_atlas_store_for_project_controlled(
11772            &db_path,
11773            &root,
11774            Some(&invalid_config),
11775            &control,
11776        );
11777        if !matches!(
11778            result,
11779            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
11780                stage: IndexWorkStage::Publication,
11781            }))
11782        ) {
11783            return Err(io::Error::other(
11784                "controlled freshness parsed policy outside the caller cancellation boundary",
11785            )
11786            .into());
11787        }
11788        Ok(())
11789    }
11790
11791    #[test]
11792    fn automatic_read_refresh_returns_typed_state_when_writer_is_unavailable()
11793    -> Result<(), Box<dyn Error>> {
11794        let temp = tempfile::tempdir()?;
11795        let root = temp.path().join("repo");
11796        fs::create_dir(&root)?;
11797        let source_path = root.join("lib.rs");
11798        let indexed_source = "pub fn indexed() {}\n";
11799        let current_source = "pub fn current() {}\n";
11800        fs::write(&source_path, indexed_source)?;
11801        let db_path = root.join(".projectatlas").join("projectatlas.db");
11802        let plan = ScanRuntimePlan::for_path(None, &root, None)?;
11803        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
11804        let mut initial_store = open_atlas_store_for_project(&db_path, &plan.root)?;
11805        run_scan_pipeline(&mut initial_store, &plan, &symbol_options)?;
11806        let initial_generation = initial_store
11807            .index_publication()?
11808            .ok_or_else(|| io::Error::other("initial publication missing"))?
11809            .generation;
11810        drop(initial_store);
11811
11812        fs::write(&source_path, current_source)?;
11813        let mut blocking_writer = open_atlas_store_for_project(&db_path, &plan.root)?;
11814        let publication =
11815            blocking_writer.begin_index_publication(&plan.publication_contract_fingerprint())?;
11816        let refresh = open_fresh_atlas_store_for_project(&db_path, &plan.root, None);
11817        let Err(CliError::RefreshRequired(report)) = refresh else {
11818            return Err(io::Error::other(
11819                "contended automatic refresh did not return typed refresh_required",
11820            )
11821            .into());
11822        };
11823        require_eq(
11824            &report.scope,
11825            &IndexRefreshScope::Incremental,
11826            "contended automatic refresh scope",
11827        )?;
11828        require_eq(
11829            &report.reason,
11830            &IndexRefreshReason::SourceChanged,
11831            "contended automatic refresh reason",
11832        )?;
11833
11834        let last_valid = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
11835        require_eq(
11836            &last_valid
11837                .index_publication()?
11838                .ok_or_else(|| io::Error::other("last-valid publication missing"))?
11839                .generation,
11840            &initial_generation,
11841            "last-valid generation during contention",
11842        )?;
11843        require_eq(
11844            &last_valid
11845                .load_file_text("lib.rs")?
11846                .ok_or_else(|| io::Error::other("last-valid source text missing"))?
11847                .content,
11848            &indexed_source.to_string(),
11849            "last-valid source during contention",
11850        )?;
11851        drop(last_valid);
11852        drop(publication);
11853
11854        let repaired = open_fresh_atlas_store_for_project(&db_path, &plan.root, None)?;
11855        require_eq(
11856            &repaired
11857                .index_publication()?
11858                .ok_or_else(|| io::Error::other("repaired publication missing"))?
11859                .generation,
11860            &initial_generation
11861                .checked_next()
11862                .ok_or_else(|| io::Error::other("test generation overflow"))?,
11863            "repaired generation after contention",
11864        )?;
11865        require_eq(
11866            &repaired
11867                .load_file_text("lib.rs")?
11868                .ok_or_else(|| io::Error::other("repaired source text missing"))?
11869                .content,
11870            &current_source.to_string(),
11871            "current source after contention",
11872        )?;
11873        Ok(())
11874    }
11875
11876    #[test]
11877    fn fallback_retains_actual_source_provenance() -> Result<(), Box<dyn Error>> {
11878        for (language, content, parser) in [
11879            ("rust", "fn recovered() {}", ParserKind::TreeSitter),
11880            ("rust", "def recovered(): pass", ParserKind::Fallback),
11881            (
11882                "php",
11883                "<?php function recovered() {}",
11884                ParserKind::TreeSitter,
11885            ),
11886            ("php", "<?php\ndef recovered(): pass", ParserKind::Fallback),
11887        ] {
11888            let job = SymbolParseJob {
11889                path: format!("src/recovered.{language}"),
11890                native_path: PathBuf::new(),
11891                expected_content_hash: String::new(),
11892                language: Some(language.to_string()),
11893                fallback_summary: None,
11894                purpose_needs_suggestion: false,
11895            };
11896            let SymbolParseOutcome::Parsed(parsed) = parse_admitted_symbol_job(
11897                &job,
11898                content,
11899                None,
11900                &SymbolBuildOptions::new(1_024, Some(1), None),
11901                &standalone_index_work_control(),
11902            ) else {
11903                return Err(io::Error::other("source provenance fixture did not parse").into());
11904            };
11905            require_eq(&parsed.graph.parser, &parser, "observed fact parser")?;
11906            require_eq(&parsed.source_parser, &parser, "observed source parser")?;
11907            require_eq(
11908                &parsed
11909                    .graph
11910                    .symbols
11911                    .iter()
11912                    .any(|symbol| symbol.name == "recovered"),
11913                &true,
11914                "recovered declaration",
11915            )?;
11916        }
11917        Ok(())
11918    }
11919
11920    #[test]
11921    fn partial_php_facts_keep_tree_sitter_source_provenance() -> Result<(), Box<dyn Error>> {
11922        let content = "<?php function run(): void { $callable(); helper(); }";
11923        let job = SymbolParseJob {
11924            path: "src/dynamic.php".to_string(),
11925            native_path: PathBuf::new(),
11926            expected_content_hash: String::new(),
11927            language: Some("php".to_string()),
11928            fallback_summary: None,
11929            purpose_needs_suggestion: false,
11930        };
11931        let SymbolParseOutcome::Parsed(parsed) = parse_admitted_symbol_job(
11932            &job,
11933            content,
11934            None,
11935            &SymbolBuildOptions::new(1_024, Some(1), None),
11936            &standalone_index_work_control(),
11937        ) else {
11938            return Err(io::Error::other("partial PHP fixture did not parse").into());
11939        };
11940        require_eq(
11941            &parsed.graph.parser,
11942            &ParserKind::Fallback,
11943            "partial PHP fact parser",
11944        )?;
11945        require_eq(
11946            &parsed.source_parser,
11947            &ParserKind::TreeSitter,
11948            "partial PHP source parser",
11949        )?;
11950        require_eq(
11951            &parsed
11952                .graph
11953                .symbols
11954                .iter()
11955                .any(|symbol| symbol.name == "run"),
11956            &true,
11957            "partial PHP recovered symbol",
11958        )?;
11959        require_eq(
11960            &parsed.graph.relations.iter().any(|relation| {
11961                relation.kind == RelationKind::Calls && relation.target_name == "helper"
11962            }),
11963            &true,
11964            "partial PHP known call",
11965        )?;
11966        require_eq(
11967            &parsed.graph.relations.iter().all(|relation| {
11968                relation.kind != RelationKind::Calls || relation.target_name != "$callable"
11969            }),
11970            &true,
11971            "partial PHP dynamic call abstention",
11972        )?;
11973        Ok(())
11974    }
11975
11976    #[test]
11977    fn staged_plain_text_preserves_parser_bytes_and_avoids_a_second_read()
11978    -> Result<(), Box<dyn Error>> {
11979        let temp = tempfile::tempdir()?;
11980        let options = SymbolBuildOptions::new(4_096, Some(1), None);
11981        let control = standalone_index_work_control();
11982        for (path, language, content) in [
11983            (
11984                "lib.rs",
11985                "rust",
11986                "\u{feff}// Comment\r\npub fn entry() { helper(); }\r\nfn helper() {}\r\n",
11987            ),
11988            (
11989                "guide.md",
11990                "markdown",
11991                "\u{feff}# Guide\r\n\r\n[Library](lib.rs)\r\n",
11992            ),
11993        ] {
11994            let native_path = temp.path().join(path);
11995            fs::write(&native_path, content)?;
11996            let hash = blake3::hash(content.as_bytes()).to_hex().to_string();
11997            let node = Node {
11998                path: path.to_string(),
11999                kind: NodeKind::File,
12000                parent_path: None,
12001                extension: None,
12002                language: Some(language.to_string()),
12003                size_bytes: Some(content.len() as u64),
12004                mtime_ns: None,
12005                content_hash: Some(hash.clone()),
12006            };
12007            let rows =
12008                indexed_file_texts_for_nodes(temp.path(), &[node], TextIndexOptions::new(4_096))?;
12009            let text = rows
12010                .first()
12011                .and_then(|row| row.text.as_ref())
12012                .ok_or_else(|| io::Error::other("verified staged text missing"))?;
12013            require_eq(
12014                &text.content.as_str(),
12015                &content,
12016                "raw BOM and CRLF bytes preserved",
12017            )?;
12018            let job = SymbolParseJob {
12019                path: path.to_string(),
12020                native_path: native_path.clone(),
12021                expected_content_hash: hash,
12022                language: Some(language.to_string()),
12023                fallback_summary: None,
12024                purpose_needs_suggestion: false,
12025            };
12026            let SymbolParseOutcome::Parsed(expected) =
12027                parse_symbol_job_controlled(&job, &options, &control)
12028            else {
12029                return Err(io::Error::other("original source did not parse").into());
12030            };
12031            fs::remove_file(&native_path)?;
12032            let SymbolParseOutcome::Parsed(actual) =
12033                parse_symbol_job_with_staged_text_controlled(&job, &options, &control, Some(text))
12034            else {
12035                return Err(
12036                    io::Error::other("verified text was reopened instead of borrowed").into(),
12037                );
12038            };
12039            require_eq(
12040                &serde_json::to_value(&actual.graph)?,
12041                &serde_json::to_value(&expected.graph)?,
12042                "borrowed and independently read parser facts",
12043            )?;
12044            require_eq(
12045                &actual.summary,
12046                &expected.summary,
12047                "borrowed source summary",
12048            )?;
12049            require_eq(
12050                &actual.source_parser,
12051                &expected.source_parser,
12052                "borrowed parser provenance",
12053            )?;
12054            let smaller = SymbolBuildOptions::new((content.len() - 1) as u64, Some(1), None);
12055            require_eq(
12056                &matches!(
12057                    parse_symbol_job_with_staged_text_controlled(
12058                        &job,
12059                        &smaller,
12060                        &control,
12061                        Some(text)
12062                    ),
12063                    SymbolParseOutcome::Io { .. }
12064                ),
12065                &true,
12066                "different parser byte cap uses bounded read",
12067            )?;
12068            let mut mismatched = text.clone();
12069            mismatched.content_hash = Some("different".to_string());
12070            require_eq(
12071                &matches!(
12072                    parse_symbol_job_with_staged_text_controlled(
12073                        &job,
12074                        &options,
12075                        &control,
12076                        Some(&mismatched)
12077                    ),
12078                    SymbolParseOutcome::Io { .. }
12079                ),
12080                &true,
12081                "unmatched source hash uses independent read",
12082            )?;
12083            let mut document = job;
12084            document.path = "document.pdf".to_string();
12085            document.language = Some("pdf".to_string());
12086            mismatched.path = document.path.clone();
12087            mismatched.content_hash = Some(document.expected_content_hash.clone());
12088            require_eq(
12089                &matches!(
12090                    parse_symbol_job_with_staged_text_controlled(
12091                        &document,
12092                        &options,
12093                        &control,
12094                        Some(&mismatched)
12095                    ),
12096                    SymbolParseOutcome::Io { .. }
12097                ),
12098                &true,
12099                "extracted document text never replaces source bytes",
12100            )?;
12101        }
12102        Ok(())
12103    }
12104
12105    #[test]
12106    fn derived_source_readers_reject_bytes_outside_staged_hash() -> Result<(), Box<dyn Error>> {
12107        let temp = tempfile::tempdir()?;
12108        let path = temp.path().join("lib.rs");
12109        let staged_source = "fn first() {}\n";
12110        let changed_source = "fn other() {}\n";
12111        fs::write(&path, staged_source)?;
12112        let expected_content_hash = blake3::hash(staged_source.as_bytes()).to_hex().to_string();
12113        let node = Node {
12114            path: "lib.rs".to_string(),
12115            kind: NodeKind::File,
12116            parent_path: None,
12117            extension: Some(".rs".to_string()),
12118            language: Some("rust".to_string()),
12119            size_bytes: Some(staged_source.len() as u64),
12120            mtime_ns: Some(1),
12121            content_hash: Some(expected_content_hash.clone()),
12122        };
12123        fs::write(&path, changed_source)?;
12124
12125        let text_result = indexed_file_texts_for_nodes(
12126            temp.path(),
12127            std::slice::from_ref(&node),
12128            TextIndexOptions::new(1_024),
12129        );
12130        let Err(CliError::RefreshRequired(details)) = text_result else {
12131            return Err(io::Error::other("text derivation accepted changed source bytes").into());
12132        };
12133        require_eq(
12134            &details.reason,
12135            &IndexRefreshReason::SourceChanged,
12136            "text source-change reason",
12137        )?;
12138        require_eq(
12139            &details.sample_paths,
12140            &vec!["lib.rs".to_string()],
12141            "text source-change paths",
12142        )?;
12143
12144        let symbol_outcome = parse_symbol_job(
12145            &SymbolParseJob {
12146                path: node.path,
12147                native_path: path,
12148                expected_content_hash,
12149                language: node.language,
12150                fallback_summary: None,
12151                purpose_needs_suggestion: false,
12152            },
12153            &SymbolBuildOptions::new(1_024, Some(1), None),
12154            Instant::now(),
12155        );
12156        if !matches!(
12157            symbol_outcome,
12158            SymbolParseOutcome::SourceChanged { path } if path == "lib.rs"
12159        ) {
12160            return Err(io::Error::other("symbol derivation accepted changed source bytes").into());
12161        }
12162
12163        let bounded_path = temp.path().join("bounded.txt");
12164        fs::write(&bounded_path, "four")?;
12165        let bounded_node = Node {
12166            path: "bounded.txt".to_string(),
12167            kind: NodeKind::File,
12168            parent_path: None,
12169            extension: Some(".txt".to_string()),
12170            language: Some("text".to_string()),
12171            size_bytes: Some(4),
12172            mtime_ns: Some(1),
12173            content_hash: Some(blake3::hash(b"four").to_hex().to_string()),
12174        };
12175        let bounded_control = standalone_index_work_control();
12176        let bounded_result = indexed_file_texts_for_nodes_with_limit(
12177            temp.path(),
12178            &[bounded_node],
12179            TextIndexOptions::new(1_024),
12180            3,
12181            &bounded_control,
12182        );
12183        if !matches!(
12184            bounded_result,
12185            Err(CliError::IndexWork(
12186                IndexWorkFailure::ResourceLimitExceeded {
12187                    stage: IndexWorkStage::TextIndex,
12188                    resource: IndexWorkResource::TextBytes,
12189                    limit: 3,
12190                    observed: 4,
12191                }
12192            ))
12193        ) {
12194            return Err(io::Error::other("text staging accepted bytes beyond its limit").into());
12195        }
12196
12197        let bounded_symbol = parse_symbol_job(
12198            &SymbolParseJob {
12199                path: "bounded.txt".to_string(),
12200                native_path: bounded_path,
12201                expected_content_hash: blake3::hash(b"four").to_hex().to_string(),
12202                language: Some("text".to_string()),
12203                fallback_summary: None,
12204                purpose_needs_suggestion: false,
12205            },
12206            &SymbolBuildOptions::new(3, Some(1), None),
12207            Instant::now(),
12208        );
12209        if !matches!(
12210            bounded_symbol,
12211            SymbolParseOutcome::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
12212                stage: IndexWorkStage::SymbolParsing,
12213                resource: IndexWorkResource::SourceBytes,
12214                limit: 3,
12215                observed: 4,
12216            })
12217        ) {
12218            return Err(io::Error::other("symbol read accepted bytes beyond its limit").into());
12219        }
12220        Ok(())
12221    }
12222
12223    #[test]
12224    fn parser_workers_reuse_structural_summaries_without_touching_approved_purpose()
12225    -> Result<(), Box<dyn Error>> {
12226        let temp = tempfile::tempdir()?;
12227        let path = temp.path().join("package.json");
12228        let content =
12229            r#"{"name":"demo","scripts":{"test":"vitest"},"dependencies":{"react":"1.0.0"}}"#;
12230        fs::write(&path, content)?;
12231        let node = Node {
12232            path: "package.json".to_string(),
12233            kind: NodeKind::File,
12234            parent_path: None,
12235            extension: Some(".json".to_string()),
12236            language: Some("json".to_string()),
12237            size_bytes: Some(content.len() as u64),
12238            mtime_ns: Some(1),
12239            content_hash: Some(blake3::hash(content.as_bytes()).to_hex().to_string()),
12240        };
12241        let mut store = AtlasStore::in_memory()?;
12242        store.replace_scan(std::slice::from_ref(&node))?;
12243        store.set_purpose(
12244            "package.json",
12245            "Own the JavaScript package manifest.",
12246            PurposeSource::Agent,
12247        )?;
12248        let text = refresh_text_index_for_nodes_with_rows(
12249            &mut store,
12250            temp.path(),
12251            std::slice::from_ref(&node),
12252            TextIndexOptions::new(1_024),
12253        )?;
12254        let SymbolParseOutcome::Parsed(parsed) = parse_symbol_job(
12255            &SymbolParseJob {
12256                path: node.path.clone(),
12257                native_path: path,
12258                expected_content_hash: node
12259                    .content_hash
12260                    .clone()
12261                    .ok_or_else(|| io::Error::other("fixture hash missing"))?,
12262                language: node.language.clone(),
12263                fallback_summary: None,
12264                purpose_needs_suggestion: false,
12265            },
12266            &SymbolBuildOptions::new(1_024, Some(1), None),
12267            Instant::now(),
12268        ) else {
12269            return Err(io::Error::other("package manifest did not parse").into());
12270        };
12271        require_eq(
12272            &parsed.summary_is_structural,
12273            &true,
12274            "parser-owned structural summary",
12275        )?;
12276        require_eq(
12277            &parsed.summary.as_str(),
12278            &"package manifest for demo with scripts test and 1 dependencies.",
12279            "structural content summary",
12280        )?;
12281        require_eq(
12282            &parsed.purpose_suggestion.is_none(),
12283            &true,
12284            "approved-purpose suggestion suppression",
12285        )?;
12286        let retained_bytes = symbol_parse_output_bytes(&parsed);
12287        let mut symbols = SymbolBuildStage {
12288            report: empty_symbol_build_report(),
12289            changes: vec![SymbolProjectionChange::Parsed(parsed)],
12290            retained_bytes,
12291            identity_admission: graph_projection::GraphIdentityAdmission::default(),
12292        };
12293        let protected_purpose_paths = HashSet::from(["package.json".to_string()]);
12294        let control = standalone_index_work_control();
12295        let structural = stage_structural_summaries_for_nodes_controlled(
12296            &store,
12297            std::slice::from_ref(&node),
12298            &text.rows,
12299            Some(&symbols),
12300            &protected_purpose_paths,
12301            1,
12302            &control,
12303        )?;
12304        require_eq(
12305            &structural.report.summarized,
12306            &1,
12307            "structural summary report",
12308        )?;
12309        require_eq(
12310            &structural.report.purpose_suggestions,
12311            &0,
12312            "structural purpose-suggestion report",
12313        )?;
12314        require_eq(
12315            &structural.changes.is_empty(),
12316            &true,
12317            "duplicate structural mutations",
12318        )?;
12319
12320        apply_symbol_build_stage(&mut store, &mut symbols, &control)?;
12321        apply_structural_summary_stage(&mut store, &structural, &control)?;
12322        let indexed = store
12323            .load_node_by_path("package.json")?
12324            .ok_or_else(|| io::Error::other("indexed package manifest missing"))?;
12325        require_eq(
12326            &indexed.summary.as_deref(),
12327            &Some("package manifest for demo with scripts test and 1 dependencies."),
12328            "persisted structural content summary",
12329        )?;
12330        require_eq(
12331            &indexed.purpose.purpose.as_deref(),
12332            &Some("Own the JavaScript package manifest."),
12333            "approved purpose text",
12334        )?;
12335        require_eq(
12336            &indexed.purpose.status,
12337            &PurposeStatus::Approved,
12338            "approved purpose status",
12339        )?;
12340        Ok(())
12341    }
12342
12343    #[test]
12344    fn symbol_build_clamps_file_bytes_and_bounds_all_published_output() -> Result<(), Box<dyn Error>>
12345    {
12346        let temp = tempfile::tempdir()?;
12347        fs::write(
12348            temp.path().join("lib.rs"),
12349            "pub fn first() { second(); }\nfn second() {}\n",
12350        )?;
12351        let nodes = scan_repo(temp.path(), &ScanOptions::default())?;
12352        let mut store = AtlasStore::in_memory()?;
12353        store.replace_scan(&nodes)?;
12354        let options = SymbolBuildOptions::new(u64::MAX, Some(INDEX_WORKER_SAFE_CEILING), None);
12355        require_eq(
12356            &options.max_bytes,
12357            &MAX_SYMBOL_FILE_BYTES,
12358            "effective CLI/MCP symbol file limit",
12359        )?;
12360        let control = standalone_index_work_control();
12361        for resource in [
12362            IndexWorkResource::SymbolRows,
12363            IndexWorkResource::RelationRows,
12364            IndexWorkResource::OutputBytes,
12365        ] {
12366            if !matches!(
12367                checked_symbol_publication_usage(1, 1, 1, resource),
12368                Err(CliError::IndexWork(
12369                    IndexWorkFailure::ResourceLimitExceeded { observed: 2, .. }
12370                ))
12371            ) {
12372                return Err(io::Error::other(format!(
12373                    "symbol publication did not accumulate {resource}"
12374                ))
12375                .into());
12376            }
12377        }
12378        let cases = [
12379            (
12380                SymbolPublicationLimits {
12381                    symbol_rows: 0,
12382                    relation_rows: u64::MAX,
12383                    output_bytes: u64::MAX,
12384                },
12385                IndexWorkResource::SymbolRows,
12386            ),
12387            (
12388                SymbolPublicationLimits {
12389                    symbol_rows: u64::MAX,
12390                    relation_rows: 0,
12391                    output_bytes: u64::MAX,
12392                },
12393                IndexWorkResource::RelationRows,
12394            ),
12395            (
12396                SymbolPublicationLimits {
12397                    symbol_rows: u64::MAX,
12398                    relation_rows: u64::MAX,
12399                    output_bytes: 0,
12400                },
12401                IndexWorkResource::OutputBytes,
12402            ),
12403        ];
12404        for (limits, expected_resource) in cases {
12405            let result = build_symbols_for_paths_with_limits(
12406                &mut store,
12407                temp.path(),
12408                &options,
12409                None,
12410                None,
12411                &control,
12412                limits,
12413            );
12414            if !matches!(
12415                result,
12416                Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
12417                    stage: IndexWorkStage::SymbolParsing,
12418                    resource,
12419                    ..
12420                })) if resource == expected_resource
12421            ) {
12422                return Err(io::Error::other(format!(
12423                    "symbol publication did not enforce {expected_resource}"
12424                ))
12425                .into());
12426            }
12427            require_eq(
12428                &store.symbol_count_for_path("lib.rs")?,
12429                &0,
12430                "no over-limit symbol output persisted",
12431            )?;
12432        }
12433
12434        let mut oversized_node = nodes
12435            .iter()
12436            .find(|node| node.path == "lib.rs")
12437            .cloned()
12438            .ok_or_else(|| io::Error::other("oversized symbol fixture node missing"))?;
12439        oversized_node.size_bytes = Some(4);
12440        let clear_bytes = oversized_node.path.len() as u64
12441            + oversized_node.language.as_ref().map_or(0, String::len) as u64;
12442        let clear_options = SymbolBuildOptions::new(3, Some(1), None);
12443        let clear_limits = SymbolPublicationLimits {
12444            symbol_rows: u64::MAX,
12445            relation_rows: u64::MAX,
12446            output_bytes: clear_bytes.saturating_sub(1),
12447        };
12448        let clear_result = stage_symbols_for_nodes_with_limits(
12449            &store,
12450            temp.path(),
12451            #[cfg(feature = "optional-parser-supervisor")]
12452            &OptionalParserPackProjectSelection::Inactive,
12453            std::slice::from_ref(&oversized_node),
12454            &clear_options,
12455            None,
12456            None,
12457            &[],
12458            &HashSet::new(),
12459            &control,
12460            clear_limits,
12461        );
12462        if !matches!(
12463            clear_result,
12464            Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
12465                stage: IndexWorkStage::SymbolParsing,
12466                resource: IndexWorkResource::OutputBytes,
12467                limit,
12468                observed,
12469            })) if limit == clear_bytes.saturating_sub(1) && observed == clear_bytes
12470        ) {
12471            return Err(
12472                io::Error::other("symbol clear output bypassed its retained-byte limit").into(),
12473            );
12474        }
12475        let clear_stage = stage_symbols_for_nodes_with_limits(
12476            &store,
12477            temp.path(),
12478            #[cfg(feature = "optional-parser-supervisor")]
12479            &OptionalParserPackProjectSelection::Inactive,
12480            std::slice::from_ref(&oversized_node),
12481            &clear_options,
12482            None,
12483            None,
12484            &[],
12485            &HashSet::new(),
12486            &control,
12487            SymbolPublicationLimits {
12488                output_bytes: clear_bytes,
12489                ..SymbolPublicationLimits::STANDARD
12490            },
12491        )?;
12492        require_eq(
12493            &clear_stage.retained_bytes,
12494            &clear_bytes,
12495            "retained symbol clear bytes",
12496        )?;
12497        if !matches!(
12498            clear_stage.changes.as_slice(),
12499            [SymbolProjectionChange::Clear { path, language }]
12500                if path == "lib.rs" && language.as_deref() == Some("rust")
12501        ) {
12502            return Err(
12503                io::Error::other("oversized symbol output did not retain one clear").into(),
12504            );
12505        }
12506
12507        let report = build_symbols_for_paths_with_limits(
12508            &mut store,
12509            temp.path(),
12510            &options,
12511            None,
12512            None,
12513            &control,
12514            SymbolPublicationLimits::STANDARD,
12515        )?;
12516        require_eq(&report.parsed, &1, "compatible bounded symbol build")?;
12517        require_eq(&report.max_workers, &1, "single-job symbol worker count")?;
12518        if report.symbols == 0 || report.relations == 0 {
12519            return Err(io::Error::other("bounded symbol build omitted parser output").into());
12520        }
12521        Ok(())
12522    }
12523
12524    #[test]
12525    fn markdown_parse_budget_counts_enclosing_heading_identity() -> Result<(), Box<dyn Error>> {
12526        let temp = tempfile::tempdir()?;
12527        let path = temp.path().join("guide.md");
12528        let content = "# Guide\n\n[target](target.md)\n";
12529        fs::write(&path, content)?;
12530        let SymbolParseOutcome::Parsed(mut parsed) = parse_symbol_job(
12531            &SymbolParseJob {
12532                path: "guide.md".to_string(),
12533                native_path: path,
12534                expected_content_hash: blake3::hash(content.as_bytes()).to_hex().to_string(),
12535                language: Some("markdown".to_string()),
12536                fallback_summary: None,
12537                purpose_needs_suggestion: false,
12538            },
12539            &SymbolBuildOptions::new(1_024, Some(1), None),
12540            Instant::now(),
12541        ) else {
12542            return Err(io::Error::other("Markdown fixture did not parse").into());
12543        };
12544        let enclosing_heading_bytes = parsed
12545            .markdown_facts
12546            .as_ref()
12547            .ok_or_else(|| io::Error::other("Markdown facts missing"))?
12548            .link_candidates
12549            .iter()
12550            .map(|candidate| candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64)
12551            .sum::<u64>();
12552        let retained_with_heading_owners = symbol_parse_output_bytes(&parsed);
12553        for candidate in &mut parsed
12554            .markdown_facts
12555            .as_mut()
12556            .ok_or_else(|| io::Error::other("Markdown facts missing"))?
12557            .link_candidates
12558        {
12559            candidate.enclosing_heading = None;
12560        }
12561        require_eq(
12562            &retained_with_heading_owners.saturating_sub(symbol_parse_output_bytes(&parsed)),
12563            &enclosing_heading_bytes,
12564            "parser output enclosing-heading bytes",
12565        )?;
12566        Ok(())
12567    }
12568
12569    #[test]
12570    fn purpose_import_skips_non_utf8_source_headers_but_keeps_authored_inputs_strict()
12571    -> Result<(), Box<dyn Error>> {
12572        let temp = tempfile::tempdir()?;
12573        let config_path = init_config_path(temp.path(), None);
12574        init_project_with_config(temp.path(), Some(&config_path))?;
12575        let config = load_atlas_config(Some(&config_path))?;
12576        fs::write(
12577            temp.path().join("binary.txt"),
12578            b"// Purpose: Must not be imported from binary source.\n\xff",
12579        )?;
12580        let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
12581        let nodes = scan_repo(&plan.root, &plan.scan_options)?;
12582        if !nodes.iter().any(|node| node.path == "binary.txt") {
12583            return Err(io::Error::other("binary source fixture was not scanned").into());
12584        }
12585        let snapshot =
12586            plan.purpose_import_snapshot_controlled(&nodes, &standalone_index_work_control())?;
12587        if snapshot
12588            .records
12589            .iter()
12590            .any(|record| record.path == "binary.txt")
12591        {
12592            return Err(io::Error::other("non-UTF-8 source header imported a purpose").into());
12593        }
12594
12595        fs::write(&config.map_path, [0xff])?;
12596        let strict =
12597            plan.purpose_import_snapshot_controlled(&nodes, &standalone_index_work_control());
12598        if !matches!(
12599            strict,
12600            Err(CliError::InvalidInput(message))
12601                if message.contains("purpose input is not valid UTF-8")
12602        ) {
12603            return Err(
12604                io::Error::other("non-UTF-8 authored purpose input was not rejected").into(),
12605            );
12606        }
12607        Ok(())
12608    }
12609
12610    #[test]
12611    fn purpose_import_inputs_observe_cancellation_limits_and_rollback() -> Result<(), Box<dyn Error>>
12612    {
12613        struct CancelAfterFirstRead {
12614            bytes: io::Cursor<Vec<u8>>,
12615            cancellation: IndexCancellation,
12616            reads: usize,
12617        }
12618
12619        impl Read for CancelAfterFirstRead {
12620            fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
12621                let read = self.bytes.read(buffer)?;
12622                self.reads += 1;
12623                if self.reads == 1 {
12624                    self.cancellation.cancel();
12625                }
12626                Ok(read)
12627            }
12628        }
12629
12630        let temp = tempfile::tempdir()?;
12631        let config_path = init_config_path(temp.path(), None);
12632        init_project_with_config(temp.path(), Some(&config_path))?;
12633        let fixture_config = load_atlas_config(Some(&config_path))?;
12634        fs::write(temp.path().join("lib.rs"), "fn imported() {}\n")?;
12635        fs::write(
12636            &fixture_config.map_path,
12637            "folders[1]:\n  .,Controlled imported repository purpose\n",
12638        )?;
12639        let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
12640        let nodes = scan_repo(&plan.root, &plan.scan_options)?;
12641        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12642        let mut store = AtlasStore::in_memory()?;
12643        store.set_project_root(&plan.root)?;
12644        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
12645        let publication_before = store
12646            .index_publication()?
12647            .ok_or_else(|| io::Error::other("controlled import publication missing"))?;
12648        let root_before = store
12649            .load_node_by_path(".")?
12650            .ok_or_else(|| io::Error::other("controlled import root missing"))?;
12651        let text_before = store
12652            .load_file_text("lib.rs")?
12653            .ok_or_else(|| io::Error::other("controlled import text missing"))?;
12654        let symbol_count_before = store.symbol_count()?;
12655        let relation_count_before = store.symbol_relation_count()?;
12656
12657        let config_bytes = fs::metadata(&config_path)?.len();
12658        let map_bytes = fs::metadata(&fixture_config.map_path)?.len();
12659        let nonsource_bytes = fs::metadata(&fixture_config.nonsource_files_path)?.len();
12660        let source_bytes = fs::metadata(temp.path().join("lib.rs"))?.len();
12661        let staged_input_bytes = config_bytes
12662            .saturating_add(map_bytes)
12663            .saturating_add(nonsource_bytes)
12664            .saturating_add(source_bytes);
12665        let complete_operation_bytes = config_bytes
12666            .saturating_add(staged_input_bytes)
12667            .saturating_add(config_bytes)
12668            .saturating_add(staged_input_bytes);
12669        let largest_complete_input = config_bytes.max(map_bytes).max(nonsource_bytes);
12670        let aggregate_limits = PurposeImportLimits {
12671            total_bytes: complete_operation_bytes,
12672            complete_file_bytes: largest_complete_input,
12673            header_bytes: source_bytes,
12674            records: 100,
12675        };
12676        let aggregate_control = standalone_index_work_control();
12677        let aggregate_plan = ScanRuntimePlan::for_path_controlled_with_limits(
12678            None,
12679            temp.path(),
12680            None,
12681            &aggregate_control,
12682            aggregate_limits,
12683        )?;
12684        let aggregate_nodes = scan_repo(&aggregate_plan.root, &aggregate_plan.scan_options)?;
12685        let aggregate_snapshot = aggregate_plan.purpose_import_snapshot_controlled_with_limits(
12686            &aggregate_nodes,
12687            &aggregate_control,
12688            aggregate_limits,
12689        )?;
12690        revalidate_index_publication_inputs_controlled_with_limits(
12691            &store,
12692            &aggregate_plan,
12693            Some(&aggregate_snapshot.fingerprint),
12694            &aggregate_control,
12695            aggregate_limits,
12696        )?;
12697
12698        let cumulative_limit = complete_operation_bytes.saturating_sub(1);
12699        let cumulative_limits = PurposeImportLimits {
12700            total_bytes: cumulative_limit,
12701            ..aggregate_limits
12702        };
12703        let cumulative_control = standalone_index_work_control();
12704        let cumulative_plan = ScanRuntimePlan::for_path_controlled_with_limits(
12705            None,
12706            temp.path(),
12707            None,
12708            &cumulative_control,
12709            cumulative_limits,
12710        )?;
12711        let cumulative_nodes = scan_repo(&cumulative_plan.root, &cumulative_plan.scan_options)?;
12712        let cumulative_snapshot = cumulative_plan.purpose_import_snapshot_controlled_with_limits(
12713            &cumulative_nodes,
12714            &cumulative_control,
12715            cumulative_limits,
12716        )?;
12717        let cumulative_result = revalidate_index_publication_inputs_controlled_with_limits(
12718            &store,
12719            &cumulative_plan,
12720            Some(&cumulative_snapshot.fingerprint),
12721            &cumulative_control.with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT),
12722            cumulative_limits,
12723        );
12724        if !matches!(
12725            cumulative_result,
12726            Err(CliError::IndexWork(
12727                IndexWorkFailure::ResourceLimitExceeded {
12728                    stage: IndexWorkStage::Publication,
12729                    resource: IndexWorkResource::PurposeBytes,
12730                    limit,
12731                    observed,
12732                }
12733            )) if limit == cumulative_limit && observed > limit
12734        ) {
12735            return Err(io::Error::other(
12736                "plan, staging, and revalidation readers did not share one purpose-byte budget",
12737            )
12738            .into());
12739        }
12740
12741        let control = standalone_index_work_control();
12742        let staged_snapshot = plan.purpose_import_snapshot_controlled(&nodes, &control)?;
12743        let small_limits = PurposeImportLimits {
12744            total_bytes: 64,
12745            complete_file_bytes: 64,
12746            header_bytes: 64,
12747            records: 100,
12748        };
12749        let initial_limit_control = standalone_index_work_control();
12750        let initial_limited = plan.purpose_import_snapshot_controlled_with_limits(
12751            &nodes,
12752            &initial_limit_control,
12753            small_limits,
12754        );
12755        if !matches!(
12756            initial_limited,
12757            Err(CliError::IndexWork(
12758                IndexWorkFailure::ResourceLimitExceeded {
12759                    stage: IndexWorkStage::Publication,
12760                    resource: IndexWorkResource::PurposeBytes,
12761                    limit: 64,
12762                    observed: 65,
12763                }
12764            ))
12765        ) {
12766            return Err(io::Error::other(
12767                "initial purpose snapshot exceeded limits without a typed failure",
12768            )
12769            .into());
12770        }
12771        let initial_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
12772        initial_cancel.cancel();
12773        if !matches!(
12774            plan.purpose_import_snapshot_controlled(&nodes, &initial_cancel),
12775            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
12776                stage: IndexWorkStage::Publication,
12777            }))
12778        ) {
12779            return Err(io::Error::other(
12780                "initial purpose snapshot ignored operation cancellation",
12781            )
12782            .into());
12783        }
12784
12785        let contract_fingerprint = plan.publication_contract_fingerprint();
12786        let mut publication = store.begin_index_publication(&contract_fingerprint)?;
12787        publication.set_project_root(&plan.root)?;
12788        publication.replace_scan(&nodes)?;
12789        publication.set_purpose(".", "Uncommitted purpose input", PurposeSource::Imported)?;
12790        let publication_limit_control = standalone_index_work_control();
12791        let limited = revalidate_index_publication_inputs_controlled_with_limits(
12792            &publication,
12793            &plan,
12794            Some(&staged_snapshot.fingerprint),
12795            &publication_limit_control,
12796            small_limits,
12797        );
12798        if !matches!(
12799            limited,
12800            Err(CliError::IndexWork(
12801                IndexWorkFailure::ResourceLimitExceeded {
12802                    stage: IndexWorkStage::Publication,
12803                    resource: IndexWorkResource::PurposeBytes,
12804                    limit: 64,
12805                    observed: 65,
12806                }
12807            ))
12808        ) {
12809            return Err(io::Error::other(
12810                "publication purpose inputs exceeded limits without a typed failure",
12811            )
12812            .into());
12813        }
12814        drop(publication);
12815        require_eq(
12816            &store.index_publication()?,
12817            &Some(publication_before.clone()),
12818            "publication after bounded purpose-input rollback",
12819        )?;
12820        require_eq(
12821            &store.load_node_by_path(".")?,
12822            &Some(root_before.clone()),
12823            "authored purpose after bounded input rollback",
12824        )?;
12825        require_eq(
12826            &store.load_file_text("lib.rs")?,
12827            &Some(text_before.clone()),
12828            "indexed text after bounded input rollback",
12829        )?;
12830        require_eq(
12831            &store.symbol_count()?,
12832            &symbol_count_before,
12833            "symbols after bounded input rollback",
12834        )?;
12835        require_eq(
12836            &store.symbol_relation_count()?,
12837            &relation_count_before,
12838            "relations after bounded input rollback",
12839        )?;
12840
12841        let mut canceled_publication = store.begin_index_publication(&contract_fingerprint)?;
12842        canceled_publication.set_project_root(&plan.root)?;
12843        canceled_publication.replace_scan(&nodes)?;
12844        canceled_publication.set_purpose(
12845            ".",
12846            "Canceled uncommitted purpose input",
12847            PurposeSource::Imported,
12848        )?;
12849        let late_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
12850        late_cancel.cancel();
12851        let canceled = revalidate_index_publication_inputs_controlled(
12852            &canceled_publication,
12853            &plan,
12854            Some(&staged_snapshot.fingerprint),
12855            &late_cancel,
12856        );
12857        if !matches!(
12858            canceled,
12859            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
12860                stage: IndexWorkStage::Publication,
12861            }))
12862        ) {
12863            return Err(io::Error::other(
12864                "late purpose-input revalidation ignored operation cancellation",
12865            )
12866            .into());
12867        }
12868        drop(canceled_publication);
12869        require_eq(
12870            &store.index_publication()?,
12871            &Some(publication_before),
12872            "publication after canceled purpose-input rollback",
12873        )?;
12874        require_eq(
12875            &store.load_node_by_path(".")?,
12876            &Some(root_before),
12877            "authored purpose after canceled input rollback",
12878        )?;
12879        require_eq(
12880            &store.load_file_text("lib.rs")?,
12881            &Some(text_before),
12882            "indexed text after canceled input rollback",
12883        )?;
12884        require_eq(
12885            &store.symbol_count()?,
12886            &symbol_count_before,
12887            "symbols after canceled input rollback",
12888        )?;
12889        require_eq(
12890            &store.symbol_relation_count()?,
12891            &relation_count_before,
12892            "relations after canceled input rollback",
12893        )?;
12894
12895        let record_limited = plan.purpose_import_snapshot_controlled_with_limits(
12896            &nodes,
12897            &control,
12898            PurposeImportLimits {
12899                records: 0,
12900                ..PurposeImportLimits::default()
12901            },
12902        );
12903        if !matches!(
12904            record_limited,
12905            Err(CliError::IndexWork(
12906                IndexWorkFailure::ResourceLimitExceeded {
12907                    stage: IndexWorkStage::Publication,
12908                    resource: IndexWorkResource::PurposeRecords,
12909                    limit: 0,
12910                    observed,
12911                }
12912            )) if observed > 0
12913        ) {
12914            return Err(io::Error::other("purpose record limit was not enforced").into());
12915        }
12916
12917        let cancellation = IndexCancellation::new();
12918        let cancel_control = IndexWorkControl::new(cancellation.clone(), None);
12919        let mut reader =
12920            PurposeInputReader::new(&plan, &cancel_control, PurposeImportLimits::default());
12921        let mut input = CancelAfterFirstRead {
12922            bytes: io::Cursor::new(vec![b'x'; CONTROLLED_SOURCE_READ_BUFFER_BYTES * 2]),
12923            cancellation,
12924            reads: 0,
12925        };
12926        let canceled = reader.read_bytes(
12927            Path::new("controlled-purpose-input"),
12928            &mut input,
12929            u64::try_from(CONTROLLED_SOURCE_READ_BUFFER_BYTES * 2).unwrap_or(u64::MAX),
12930            true,
12931        );
12932        if !matches!(
12933            canceled,
12934            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
12935                stage: IndexWorkStage::Publication,
12936            }))
12937        ) {
12938            return Err(io::Error::other(
12939                "purpose input did not observe cancellation between chunks",
12940            )
12941            .into());
12942        }
12943        Ok(())
12944    }
12945
12946    #[test]
12947    fn publication_revalidation_rejects_source_policy_and_import_drift()
12948    -> Result<(), Box<dyn Error>> {
12949        let temp = tempfile::tempdir()?;
12950        let config_dir = temp.path().join(".projectatlas");
12951        fs::create_dir_all(&config_dir)?;
12952        let source_path = temp.path().join("lib.rs");
12953        let staged_source = "fn first() {}\n";
12954        fs::write(&source_path, staged_source)?;
12955        let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
12956        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12957        let db_path = config_dir.join("projectatlas.db");
12958        let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
12959        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
12960        let control = standalone_index_work_control();
12961        let generation_before_contention = store
12962            .index_publication()?
12963            .ok_or_else(|| io::Error::other("initial publication missing"))?
12964            .generation;
12965        let contract_fingerprint = plan.publication_contract_fingerprint();
12966        store.probe_index_publication_writer()?;
12967        let contended_batch =
12968            stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
12969        revalidate_staged_publication_inputs_controlled(
12970            &plan,
12971            contended_batch.nodes.expected_nodes(),
12972            None,
12973            &control,
12974        )?;
12975        let writer_blocker = rusqlite::Connection::open(&db_path)?;
12976        writer_blocker.execute_batch("BEGIN IMMEDIATE")?;
12977        let source_after_contention = "fn later() {}\n";
12978        let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
12979        let (publish_tx, publish_rx) = std::sync::mpsc::sync_channel(1);
12980        let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1);
12981        let publisher = std::thread::spawn(move || {
12982            if ready_tx.send(()).is_err() || publish_rx.recv().is_err() {
12983                return;
12984            }
12985            let publication_control = standalone_index_work_control();
12986            let result = publish_index_batch(&mut store, contended_batch, &publication_control);
12987            drop(result_tx.send((store, result)));
12988        });
12989        ready_rx
12990            .recv_timeout(Duration::from_secs(1))
12991            .map_err(|source| {
12992                io::Error::other(format!("publisher did not become ready: {source}"))
12993            })?;
12994        fs::write(&source_path, source_after_contention)?;
12995        publish_tx
12996            .send(())
12997            .map_err(|source| io::Error::other(format!("publisher stopped early: {source}")))?;
12998        let timely_result = result_rx.recv_timeout(Duration::from_millis(500));
12999        writer_blocker.execute_batch("ROLLBACK")?;
13000        let (returned_store, contention_result) = match timely_result {
13001            Ok(result) => result,
13002            Err(source) => {
13003                publisher
13004                    .join()
13005                    .map_err(|_panic| io::Error::other("publication thread panicked"))?;
13006                return Err(io::Error::other(format!(
13007                    "publication waited for a contending writer instead of failing fast: {source}"
13008                ))
13009                .into());
13010            }
13011        };
13012        publisher
13013            .join()
13014            .map_err(|_panic| io::Error::other("publication thread panicked"))?;
13015        store = returned_store;
13016        let Err(CliError::Db(contention)) = contention_result else {
13017            return Err(io::Error::other(
13018                "publication waited through contention and accepted stale staged source",
13019            )
13020            .into());
13021        };
13022        if !contention.is_write_unavailable() {
13023            return Err(io::Error::other(
13024                "publication contention did not return typed write-unavailable state",
13025            )
13026            .into());
13027        }
13028        require_eq(
13029            &store
13030                .index_publication()?
13031                .ok_or_else(|| io::Error::other("publication missing after contention"))?
13032                .generation,
13033            &generation_before_contention,
13034            "publication generation after contention",
13035        )?;
13036        require_eq(
13037            &store
13038                .load_file_text("lib.rs")?
13039                .ok_or_else(|| io::Error::other("indexed text missing after contention"))?
13040                .content,
13041            &staged_source.to_string(),
13042            "indexed text after contention",
13043        )?;
13044        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
13045        require_eq(
13046            &store
13047                .load_file_text("lib.rs")?
13048                .ok_or_else(|| io::Error::other("indexed text missing after retry"))?
13049                .content,
13050            &source_after_contention.to_string(),
13051            "restaged text after contention retry",
13052        )?;
13053        let initial_generation = store
13054            .index_publication()?
13055            .ok_or_else(|| io::Error::other("retried publication missing"))?
13056            .generation;
13057        require_eq(
13058            &initial_generation,
13059            &generation_before_contention
13060                .checked_next()
13061                .ok_or_else(|| io::Error::other("test generation overflowed"))?,
13062            "single generation advance after contention retry",
13063        )?;
13064        let mut competing_store = open_atlas_store_for_project(&db_path, &plan.root)?;
13065        let competing_publication = competing_store
13066            .begin_index_projection_refresh_from(&contract_fingerprint, initial_generation)?;
13067        competing_publication.set_node_summary("lib.rs", "winning projection")?;
13068        let generation_conflict_batch =
13069            stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
13070        competing_publication.complete()?;
13071        let winning_generation = initial_generation
13072            .checked_next()
13073            .ok_or_else(|| io::Error::other("test generation overflowed"))?;
13074        let conflict = publish_index_batch(&mut store, generation_conflict_batch, &control);
13075        if !matches!(
13076            conflict,
13077            Err(CliError::Db(
13078                projectatlas_db::DbError::PublicationBaseGenerationChanged {
13079                    expected,
13080                    found,
13081                }
13082            )) if expected == initial_generation && found == winning_generation
13083        ) {
13084            return Err(io::Error::other(
13085                "runtime publication did not reject a generation changed after preparation",
13086            )
13087            .into());
13088        }
13089        let winning_publication = store
13090            .index_publication()?
13091            .ok_or_else(|| io::Error::other("winning publication missing"))?;
13092        require_eq(
13093            &winning_publication.generation,
13094            &winning_generation,
13095            "winning publication generation",
13096        )?;
13097        require_eq(
13098            &winning_publication.state,
13099            &projectatlas_db::IndexPublicationState::Complete,
13100            "winning publication state",
13101        )?;
13102        require_eq(
13103            &store
13104                .load_node_by_path("lib.rs")?
13105                .and_then(|node| node.summary),
13106            &Some("winning projection".to_string()),
13107            "winning publication summary",
13108        )?;
13109        let staged_source_batch =
13110            stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
13111
13112        fs::write(&source_path, "fn other() {}\n")?;
13113        let source_result = revalidate_staged_publication_inputs_controlled(
13114            &plan,
13115            staged_source_batch.nodes.expected_nodes(),
13116            None,
13117            &control,
13118        );
13119        let Err(CliError::RefreshRequired(details)) = source_result else {
13120            return Err(io::Error::other("publication accepted changed source state").into());
13121        };
13122        require_eq(
13123            &details.reason,
13124            &IndexRefreshReason::SourceChanged,
13125            "publication source-change reason",
13126        )?;
13127        require_eq(
13128            &details.sample_paths,
13129            &vec!["lib.rs".to_string()],
13130            "publication source-change paths",
13131        )?;
13132
13133        fs::write(&source_path, staged_source)?;
13134        let staged_policy_batch =
13135            stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
13136        let config_path = config_dir.join("config.toml");
13137        fs::write(
13138            &config_path,
13139            r#"[project]
13140root = "."
13141map_path = ".projectatlas/projectatlas.toon"
13142nonsource_files_path = ".projectatlas/projectatlas-nonsource-files.toon"
13143
13144[scan]
13145source_extensions = [".rs"]
13146exclude_dir_names = [".git", ".projectatlas", "target"]
13147exclude_dir_suffixes = []
13148exclude_path_prefixes = []
13149non_source_path_prefixes = []
13150text_index_max_bytes = 7
13151
13152[purpose]
13153default_style = "line-comment"
13154line_comment_prefixes = ["//"]
13155
13156[purpose.styles_by_extension]
13157".rs" = "line-comment"
13158"#,
13159        )?;
13160        let policy_result = revalidate_staged_publication_inputs_controlled(
13161            &plan,
13162            staged_policy_batch.nodes.expected_nodes(),
13163            None,
13164            &control,
13165        );
13166        let Err(CliError::VerificationIncomplete(details)) = policy_result else {
13167            return Err(io::Error::other("publication accepted changed effective policy").into());
13168        };
13169        require_eq(
13170            &details.reason,
13171            &IndexVerificationReason::PublicationContractMismatch,
13172            "publication policy-change reason",
13173        )?;
13174
13175        let configured_plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
13176        let request_limited_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1))?;
13177        require_eq(
13178            &configured_plan.text_options.max_bytes,
13179            &7,
13180            "configured text-index limit",
13181        )?;
13182        require_eq(
13183            &request_limited_plan.text_options.max_bytes,
13184            &1,
13185            "request-scoped text-index limit",
13186        )?;
13187        require_eq(
13188            &configured_plan.publication_contract_fingerprint(),
13189            &request_limited_plan.publication_contract_fingerprint(),
13190            "request limit excluded from publication contract",
13191        )?;
13192        let reloaded_request_plan = request_limited_plan.reload()?;
13193        require_eq(
13194            &reloaded_request_plan.text_options.max_bytes,
13195            &1,
13196            "request limit retained across operation reload",
13197        )?;
13198        require_eq(
13199            &request_limited_plan.publication_contract_fingerprint(),
13200            &reloaded_request_plan.publication_contract_fingerprint(),
13201            "reloaded operation publication contract",
13202        )?;
13203
13204        let changed_config = fs::read_to_string(&config_path)?
13205            .replace("text_index_max_bytes = 7", "text_index_max_bytes = 8");
13206        fs::write(&config_path, changed_config)?;
13207        let configured_cap_changed_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1))?;
13208        if configured_plan.publication_contract_fingerprint()
13209            == configured_cap_changed_plan.publication_contract_fingerprint()
13210        {
13211            return Err(io::Error::other(
13212                "configured text-index limit did not change the publication contract",
13213            )
13214            .into());
13215        }
13216
13217        let import_repo = temp.path().join("import-repo");
13218        let import_atlas_dir = import_repo.join(".projectatlas");
13219        fs::create_dir_all(&import_atlas_dir)?;
13220        fs::write(import_repo.join("lib.rs"), "fn imported() {}\n")?;
13221        let import_map_path = import_atlas_dir.join("projectatlas.toon");
13222        fs::write(
13223            &import_map_path,
13224            "folders[1]:\n  .,Original imported repository purpose\n",
13225        )?;
13226        let external_config_path = temp.path().join("external-config.toml");
13227        fs::write(
13228            &external_config_path,
13229            r#"[project]
13230root = "import-repo"
13231map_path = ".projectatlas/projectatlas.toon"
13232nonsource_files_path = ".projectatlas/projectatlas-nonsource-files.toon"
13233"#,
13234        )?;
13235        let import_plan =
13236            ScanRuntimePlan::for_path(Some(&external_config_path), &import_repo, Some(1))?;
13237        let mut import_store = open_atlas_store_for_project(
13238            &import_atlas_dir.join("projectatlas.db"),
13239            &import_plan.root,
13240        )?;
13241        run_scan_pipeline(&mut import_store, &import_plan, &symbol_options)?;
13242        verify_index_freshness(&import_store, &import_repo, Some(&external_config_path))?;
13243        let normal_import_plan =
13244            ScanRuntimePlan::for_path(Some(&external_config_path), &import_repo, None)?;
13245        run_symbol_build_pipeline(
13246            &mut import_store,
13247            &normal_import_plan,
13248            &symbol_options,
13249            None,
13250        )?;
13251        verify_index_publication(&import_store, &normal_import_plan)?;
13252        let publication_before = import_store
13253            .index_publication()?
13254            .ok_or_else(|| io::Error::other("initial imported publication missing"))?;
13255        let root_before = import_store
13256            .load_node_by_path(".")?
13257            .ok_or_else(|| io::Error::other("imported root node missing"))?;
13258        if root_before.purpose.purpose.as_deref() != Some("Original imported repository purpose") {
13259            return Err(io::Error::other("legacy purpose fixture was not imported").into());
13260        }
13261
13262        let import_control = standalone_index_work_control();
13263        let staged_import_batch = stage_full_index_publication(
13264            &import_store,
13265            &import_plan,
13266            &symbol_options,
13267            true,
13268            true,
13269            &import_control,
13270        )?;
13271        let staged_purpose_import = staged_import_batch
13272            .purpose_import
13273            .as_ref()
13274            .ok_or_else(|| io::Error::other("staged purpose import missing"))?;
13275        if !staged_purpose_import
13276            .records
13277            .iter()
13278            .any(|record| record.summary == "Original imported repository purpose")
13279        {
13280            return Err(io::Error::other("legacy purpose fixture was not imported").into());
13281        }
13282        fs::write(
13283            &import_map_path,
13284            "folders[1]:\n  .,Changed imported repository purpose\n",
13285        )?;
13286        let import_result = revalidate_staged_publication_inputs_with_purpose_snapshot(
13287            &import_plan,
13288            staged_import_batch.nodes.expected_nodes(),
13289            Some(staged_purpose_import),
13290            &import_control,
13291        );
13292        let Err(CliError::VerificationIncomplete(details)) = import_result else {
13293            return Err(io::Error::other(
13294                "publication accepted changed legacy purpose import inputs",
13295            )
13296            .into());
13297        };
13298        require_eq(
13299            &details.reason,
13300            &IndexVerificationReason::PublicationContractMismatch,
13301            "publication import-change reason",
13302        )?;
13303        require_eq(
13304            &import_store.index_publication()?,
13305            &Some(publication_before),
13306            "publication after purpose-import rollback",
13307        )?;
13308        require_eq(
13309            &import_store.load_node_by_path(".")?,
13310            &Some(root_before),
13311            "authored purpose after purpose-import rollback",
13312        )?;
13313        Ok(())
13314    }
13315
13316    #[test]
13317    fn projection_contract_revisions_force_full_projection_refresh() -> Result<(), Box<dyn Error>> {
13318        const PRE_MODULE_CALLBACK_DIGEST: &str =
13319            "487625adf2f9ec76f98034d4ef5667e707960b6b8afd280b213021cb64a0f10f";
13320        const PRE_BINDING_OWNERSHIP_DIGEST: &str =
13321            "70149049f404b00bd7d956adabda377be3509080bea68a8978eac3501fee0929";
13322        const PRE_OCCURRENCE_PROJECTION_DIGEST: &str =
13323            "8e0d5f3a34d88507e9d3c64857949a299bdbf516a1eca06adf0cbcd7ca7962a4";
13324        const STABLE_050_SEMANTIC_DIGEST: &str =
13325            "deba1bcef2b03869c211b5ba91036bb87e7cdd9c9ebb565ed447d6aea78ca535";
13326        const PRE_GENERATOR_DECLARATION_DIGEST: &str =
13327            "4542cd9a22a1df432d3a3d212f2ee5b6c52067411c3f54ca77221566a8f2ab7d";
13328        const PRE_SOURCE_CONTEXT_DIGEST: &str =
13329            "784435da80445735c78b112bebab6352f74c660d550399a9617dae87d2df0424";
13330        const PRE_CARGO_COHORT_DIGEST: &str =
13331            "77ce9ef63aa9da217f35e41d59470395e7be08d45f167c14b2477eb29309d2ca";
13332        const PRE_GO_CLAUSE_DIGEST: &str =
13333            "e0bc8aa6d30d159c0e1eae3b80773fb0f4031398608b01e1bba31b6b8d6cbd04";
13334        const PRE_COMPREHENSION_ORDER_DIGEST: &str =
13335            "9d16d41a80b1115f4d0706d7046f1be092fd2bd4a2abc41ba5d78749970d0eb6";
13336        const PRE_QUALIFIED_OWNER_DIGEST: &str =
13337            "d662ae01fce4a173b5b0e520a7cf0465d7b7184528b914afa24b9145c3efe8ed";
13338        const PRE_IDENTITY_NEIGHBOR_DIGEST: &str =
13339            "79d37888e9dff0e1ac37dfce6970a34b3ee8058b32dc0f973338153a20ac523b";
13340        const PRE_ACCESSOR_PARAMETER_DIGEST: &str =
13341            "4c7a58829b2910e38015f4576ad26e458a8192363e3024d732467db2355abac4";
13342        const PRE_MEMBER_OWNERSHIP_DIGEST: &str =
13343            "5627189a6bc0d67d2483073582b55ba3d9ea04c55ef3177462ce584361c7c355";
13344        const PRE_INSTANCE_FIELD_DIGEST: &str =
13345            "1b695fb9372249524c55c19c6969478f8a10b0414471de4eb8e467249c4baaf1";
13346        const PRE_REOPENED_CONTAINER_DIGEST: &str =
13347            "6a6912198f2cc054d76072375eb328f285f5c232710a6dc2d698169b3e7ef65a";
13348        const PRE_ARROW_CONTEXT_DIGEST: &str =
13349            "66cab1855f1689308cdc5bb858a63e8d3ed42e825b073d62cada7347383731a7";
13350        const PRE_FUNCTION_ENVIRONMENT_DIGEST: &str =
13351            "a1d3ff14480c0efd0dc36ab9abe78ea6ac9423b62ef2ce52b36996436e06b421";
13352        const PRE_RECOVERY_ADMISSION_DIGEST: &str =
13353            "7ebce4c0ac31d1a9bb12313ae7777133d6f7c3c712ec3a147a244b2cc0edf7de";
13354        const PRE_VAR_RESERVATION_DIGEST: &str =
13355            "a5fd0cc0717afd6c733563637f6f24ba86f08ec447422edc319f26d480f24267";
13356        const PRE_DIRECTIVE_BINDING_DIGEST: &str =
13357            "4f50804d1e3b708c7081f654b39287fbca88adaeb4dfbbbb60fb5c708404e626";
13358        let temp = tempfile::tempdir()?;
13359        let atlas_dir = temp.path().join(".projectatlas");
13360        fs::create_dir_all(&atlas_dir)?;
13361        fs::create_dir_all(temp.path().join("src"))?;
13362        fs::create_dir_all(temp.path().join("docs"))?;
13363        fs::write(
13364            temp.path().join("src/config.rs"),
13365            "pub fn load_timeout_millis() -> u64 { 250 }\n",
13366        )?;
13367        fs::write(
13368            temp.path().join("src/handler.rs"),
13369            "use crate::config;\npub fn health_response() { let _ = config::load_timeout_millis(); }\n",
13370        )?;
13371        fs::write(
13372            temp.path().join("src/router.rs"),
13373            "use crate::handler;\npub fn dispatch(path: &str) -> Option<()> { (path == \"/health\").then(handler::health_response) }\n",
13374        )?;
13375        fs::write(
13376            temp.path().join("docs/guide.md"),
13377            "# Configuration\n\nSee [the timeout source](../src/config.rs).\n",
13378        )?;
13379
13380        let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
13381        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
13382        let db_path = atlas_dir.join("projectatlas.db");
13383        let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
13384        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
13385        let current_fingerprint = plan.publication_contract_fingerprint();
13386        let prior_projection_fingerprint = index_derivation_fingerprint_for_contract(
13387            &plan.scan_options,
13388            text_index_options(plan.config.as_ref(), None),
13389            #[cfg(feature = "optional-parser-supervisor")]
13390            &plan.optional_parser_selection,
13391            "23",
13392            PRE_CARGO_COHORT_DIGEST,
13393        );
13394        let cargo_projection_fingerprint = index_derivation_fingerprint_for_contract(
13395            &plan.scan_options,
13396            text_index_options(plan.config.as_ref(), None),
13397            #[cfg(feature = "optional-parser-supervisor")]
13398            &plan.optional_parser_selection,
13399            "22",
13400            PRE_CARGO_COHORT_DIGEST,
13401        );
13402        let go_clause_projection_fingerprint = index_derivation_fingerprint_for_contract(
13403            &plan.scan_options,
13404            text_index_options(plan.config.as_ref(), None),
13405            #[cfg(feature = "optional-parser-supervisor")]
13406            &plan.optional_parser_selection,
13407            "21",
13408            PRE_GO_CLAUSE_DIGEST,
13409        );
13410        let comprehension_projection_fingerprint = index_derivation_fingerprint_for_contract(
13411            &plan.scan_options,
13412            text_index_options(plan.config.as_ref(), None),
13413            #[cfg(feature = "optional-parser-supervisor")]
13414            &plan.optional_parser_selection,
13415            "20",
13416            PRE_COMPREHENSION_ORDER_DIGEST,
13417        );
13418        let qualified_owner_projection_fingerprint = index_derivation_fingerprint_for_contract(
13419            &plan.scan_options,
13420            text_index_options(plan.config.as_ref(), None),
13421            #[cfg(feature = "optional-parser-supervisor")]
13422            &plan.optional_parser_selection,
13423            "19",
13424            PRE_QUALIFIED_OWNER_DIGEST,
13425        );
13426        let identity_projection_fingerprint = index_derivation_fingerprint_for_contract(
13427            &plan.scan_options,
13428            text_index_options(plan.config.as_ref(), None),
13429            #[cfg(feature = "optional-parser-supervisor")]
13430            &plan.optional_parser_selection,
13431            "18",
13432            PRE_IDENTITY_NEIGHBOR_DIGEST,
13433        );
13434        let accessor_projection_fingerprint = index_derivation_fingerprint_for_contract(
13435            &plan.scan_options,
13436            text_index_options(plan.config.as_ref(), None),
13437            #[cfg(feature = "optional-parser-supervisor")]
13438            &plan.optional_parser_selection,
13439            "17",
13440            PRE_ACCESSOR_PARAMETER_DIGEST,
13441        );
13442        let member_projection_fingerprint = index_derivation_fingerprint_for_contract(
13443            &plan.scan_options,
13444            text_index_options(plan.config.as_ref(), None),
13445            #[cfg(feature = "optional-parser-supervisor")]
13446            &plan.optional_parser_selection,
13447            "16",
13448            PRE_MEMBER_OWNERSHIP_DIGEST,
13449        );
13450        let instance_field_projection_fingerprint = index_derivation_fingerprint_for_contract(
13451            &plan.scan_options,
13452            text_index_options(plan.config.as_ref(), None),
13453            #[cfg(feature = "optional-parser-supervisor")]
13454            &plan.optional_parser_selection,
13455            "15",
13456            PRE_INSTANCE_FIELD_DIGEST,
13457        );
13458        let container_projection_fingerprint = index_derivation_fingerprint_for_contract(
13459            &plan.scan_options,
13460            text_index_options(plan.config.as_ref(), None),
13461            #[cfg(feature = "optional-parser-supervisor")]
13462            &plan.optional_parser_selection,
13463            "14",
13464            PRE_REOPENED_CONTAINER_DIGEST,
13465        );
13466        let arrow_projection_fingerprint = index_derivation_fingerprint_for_contract(
13467            &plan.scan_options,
13468            text_index_options(plan.config.as_ref(), None),
13469            #[cfg(feature = "optional-parser-supervisor")]
13470            &plan.optional_parser_selection,
13471            "13",
13472            PRE_ARROW_CONTEXT_DIGEST,
13473        );
13474        let source_context_projection_fingerprint = index_derivation_fingerprint_for_contract(
13475            &plan.scan_options,
13476            text_index_options(plan.config.as_ref(), None),
13477            #[cfg(feature = "optional-parser-supervisor")]
13478            &plan.optional_parser_selection,
13479            "12",
13480            PRE_SOURCE_CONTEXT_DIGEST,
13481        );
13482        let generator_projection_fingerprint = index_derivation_fingerprint_for_contract(
13483            &plan.scan_options,
13484            text_index_options(plan.config.as_ref(), None),
13485            #[cfg(feature = "optional-parser-supervisor")]
13486            &plan.optional_parser_selection,
13487            "11",
13488            PRE_GENERATOR_DECLARATION_DIGEST,
13489        );
13490        let function_environment_projection_fingerprint = index_derivation_fingerprint_for_contract(
13491            &plan.scan_options,
13492            text_index_options(plan.config.as_ref(), None),
13493            #[cfg(feature = "optional-parser-supervisor")]
13494            &plan.optional_parser_selection,
13495            "10",
13496            PRE_FUNCTION_ENVIRONMENT_DIGEST,
13497        );
13498        let recovery_projection_fingerprint = index_derivation_fingerprint_for_contract(
13499            &plan.scan_options,
13500            text_index_options(plan.config.as_ref(), None),
13501            #[cfg(feature = "optional-parser-supervisor")]
13502            &plan.optional_parser_selection,
13503            "9",
13504            PRE_RECOVERY_ADMISSION_DIGEST,
13505        );
13506        let var_projection_fingerprint = index_derivation_fingerprint_for_contract(
13507            &plan.scan_options,
13508            text_index_options(plan.config.as_ref(), None),
13509            #[cfg(feature = "optional-parser-supervisor")]
13510            &plan.optional_parser_selection,
13511            "8",
13512            PRE_VAR_RESERVATION_DIGEST,
13513        );
13514        let directive_projection_fingerprint = index_derivation_fingerprint_for_contract(
13515            &plan.scan_options,
13516            text_index_options(plan.config.as_ref(), None),
13517            #[cfg(feature = "optional-parser-supervisor")]
13518            &plan.optional_parser_selection,
13519            "7",
13520            PRE_DIRECTIVE_BINDING_DIGEST,
13521        );
13522        let deferred_projection_fingerprint = index_derivation_fingerprint_for_contract(
13523            &plan.scan_options,
13524            text_index_options(plan.config.as_ref(), None),
13525            #[cfg(feature = "optional-parser-supervisor")]
13526            &plan.optional_parser_selection,
13527            "6",
13528            PRE_OCCURRENCE_PROJECTION_DIGEST,
13529        );
13530        let occurrence_projection_fingerprint = index_derivation_fingerprint_for_contract(
13531            &plan.scan_options,
13532            text_index_options(plan.config.as_ref(), None),
13533            #[cfg(feature = "optional-parser-supervisor")]
13534            &plan.optional_parser_selection,
13535            "5",
13536            PRE_OCCURRENCE_PROJECTION_DIGEST,
13537        );
13538        let binding_projection_fingerprint = index_derivation_fingerprint_for_contract(
13539            &plan.scan_options,
13540            text_index_options(plan.config.as_ref(), None),
13541            #[cfg(feature = "optional-parser-supervisor")]
13542            &plan.optional_parser_selection,
13543            "4",
13544            PRE_BINDING_OWNERSHIP_DIGEST,
13545        );
13546        let prior_semantic_fingerprint = index_derivation_fingerprint_for_contract(
13547            &plan.scan_options,
13548            text_index_options(plan.config.as_ref(), None),
13549            #[cfg(feature = "optional-parser-supervisor")]
13550            &plan.optional_parser_selection,
13551            INDEX_DERIVATION_CONTRACT_VERSION,
13552            PRE_MODULE_CALLBACK_DIGEST,
13553        );
13554        let binding_semantic_fingerprint = index_derivation_fingerprint_for_contract(
13555            &plan.scan_options,
13556            text_index_options(plan.config.as_ref(), None),
13557            #[cfg(feature = "optional-parser-supervisor")]
13558            &plan.optional_parser_selection,
13559            INDEX_DERIVATION_CONTRACT_VERSION,
13560            PRE_BINDING_OWNERSHIP_DIGEST,
13561        );
13562        let stable_fingerprint = index_derivation_fingerprint_for_contract(
13563            &plan.scan_options,
13564            text_index_options(plan.config.as_ref(), None),
13565            #[cfg(feature = "optional-parser-supervisor")]
13566            &plan.optional_parser_selection,
13567            "3",
13568            STABLE_050_SEMANTIC_DIGEST,
13569        );
13570        if prior_projection_fingerprint == current_fingerprint
13571            || cargo_projection_fingerprint == current_fingerprint
13572            || go_clause_projection_fingerprint == current_fingerprint
13573            || comprehension_projection_fingerprint == current_fingerprint
13574            || qualified_owner_projection_fingerprint == current_fingerprint
13575            || identity_projection_fingerprint == current_fingerprint
13576            || accessor_projection_fingerprint == current_fingerprint
13577            || member_projection_fingerprint == current_fingerprint
13578            || instance_field_projection_fingerprint == current_fingerprint
13579            || container_projection_fingerprint == current_fingerprint
13580            || arrow_projection_fingerprint == current_fingerprint
13581            || source_context_projection_fingerprint == current_fingerprint
13582            || generator_projection_fingerprint == current_fingerprint
13583            || function_environment_projection_fingerprint == current_fingerprint
13584            || recovery_projection_fingerprint == current_fingerprint
13585            || var_projection_fingerprint == current_fingerprint
13586            || directive_projection_fingerprint == current_fingerprint
13587            || deferred_projection_fingerprint == current_fingerprint
13588            || occurrence_projection_fingerprint == current_fingerprint
13589            || binding_projection_fingerprint == current_fingerprint
13590            || prior_semantic_fingerprint == current_fingerprint
13591            || binding_semantic_fingerprint == current_fingerprint
13592            || stable_fingerprint == current_fingerprint
13593        {
13594            return Err(io::Error::other(
13595                "projection or semantic contract revision did not change the derivation fingerprint",
13596            )
13597            .into());
13598        }
13599
13600        let prior_control = standalone_index_work_control();
13601        let mut prior_batch = stage_full_index_publication(
13602            &store,
13603            &plan,
13604            &symbol_options,
13605            true,
13606            false,
13607            &prior_control,
13608        )?;
13609        // Seed a preceding contract through the full native owner so raw and
13610        // canonical graph generations remain consistent with its publication.
13611        prior_batch.contract_fingerprint = prior_projection_fingerprint;
13612        publish_index_batch(&mut store, prior_batch, &prior_control)?;
13613        if publication_contract_matches(&store, &plan)? {
13614            return Err(io::Error::other(
13615                "prior semantic contract unexpectedly matched the current plan",
13616            )
13617            .into());
13618        }
13619        let Err(CliError::VerificationIncomplete(stale_contract)) =
13620            verify_index_publication(&store, &plan)
13621        else {
13622            return Err(io::Error::other(
13623                "stale document projection crossed the publication read boundary",
13624            )
13625            .into());
13626        };
13627        require_eq(
13628            &stale_contract.reason,
13629            &IndexVerificationReason::PublicationContractMismatch,
13630            "stale document projection read reason",
13631        )?;
13632
13633        let stale_generation = store
13634            .index_publication()?
13635            .ok_or_else(|| io::Error::other("stale publication missing"))?
13636            .generation;
13637        let control = standalone_index_work_control();
13638        let before_repair = store.index_publication()?;
13639        let before_identity = store.project_instance_id()?;
13640        let source_paths = vec![
13641            "src/config.rs".to_string(),
13642            "src/handler.rs".to_string(),
13643            "src/router.rs".to_string(),
13644        ];
13645        let before_graphs = store.load_symbol_graphs_for_paths(&source_paths)?;
13646        let call_query = RepositoryGraphRelationQuery::Family {
13647            relation: GraphRelationKind::Legacy(RelationKind::Calls),
13648        };
13649        let before_calls = store
13650            .repository_graph_relations(call_query.clone(), 100)?
13651            .rows;
13652        let repair =
13653            stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
13654        // Cancel after complete repair staging, before the atomic publication owner.
13655        let cancelled = standalone_index_work_control();
13656        cancelled.cancel();
13657        if !matches!(
13658            publish_index_batch(&mut store, repair, &cancelled),
13659            Err(CliError::IndexWork(IndexWorkFailure::Cancelled { .. }))
13660        ) {
13661            return Err(io::Error::other("cancelled contract repair was not refused").into());
13662        }
13663        require_eq(
13664            &store.index_publication()?,
13665            &before_repair,
13666            "cancelled repair publication",
13667        )?;
13668        require_eq(
13669            &store.project_instance_id()?,
13670            &before_identity,
13671            "cancelled repair identity",
13672        )?;
13673        require_eq(
13674            &store.load_symbol_graphs_for_paths(&source_paths)?,
13675            &before_graphs,
13676            "cancelled repair raw symbol graphs",
13677        )?;
13678        require_eq(
13679            &store.repository_graph_relations(call_query, 100)?.rows,
13680            &before_calls,
13681            "cancelled repair canonical calls",
13682        )?;
13683        refresh_index_controlled(&mut store, &plan, &symbol_options, &control)?;
13684        let refreshed = store
13685            .index_publication()?
13686            .ok_or_else(|| io::Error::other("refreshed publication missing"))?;
13687        if refreshed.generation <= stale_generation
13688            || refreshed.contract_fingerprint.as_deref() != Some(current_fingerprint.as_str())
13689        {
13690            return Err(io::Error::other(
13691                "semantic contract mismatch did not force a current full publication",
13692            )
13693            .into());
13694        }
13695        let document_relations = store.repository_graph_relations(
13696            RepositoryGraphRelationQuery::Family {
13697                relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
13698            },
13699            10,
13700        )?;
13701        let document_source_is_file = document_relations.rows.iter().any(|relation| {
13702            store
13703                .repository_graph_entity(relation.source())
13704                .ok()
13705                .flatten()
13706                .is_some_and(|source| {
13707                    matches!(
13708                        source.selector(),
13709                        EntitySelector::File { path } if path.as_str() == "docs/guide.md"
13710                    )
13711                })
13712        });
13713        if !document_source_is_file {
13714            return Err(io::Error::other(
13715                "full contract refresh did not republish the document file as source",
13716            )
13717            .into());
13718        }
13719        let graphs = store.load_symbol_graphs_for_paths(&["src/router.rs".to_string()])?;
13720        if !graphs
13721            .iter()
13722            .flat_map(|graph| &graph.relations)
13723            .any(|relation| {
13724                relation.kind == projectatlas_core::symbols::RelationKind::Calls
13725                    && relation.target_name == "handler::health_response"
13726            })
13727        {
13728            return Err(io::Error::other(
13729                "semantic refresh did not publish the comparison-then callback edge",
13730            )
13731            .into());
13732        }
13733        Ok(())
13734    }
13735
13736    #[cfg(feature = "optional-parser-supervisor")]
13737    #[test]
13738    fn optional_parser_selection_changes_derivation_and_preserves_prior_generation_on_failure()
13739    -> Result<(), Box<dyn Error>> {
13740        let temp = tempfile::tempdir()?;
13741        let atlas_dir = temp.path().join(".projectatlas");
13742        fs::create_dir_all(&atlas_dir)?;
13743        let source_path = temp.path().join("main.awk");
13744        fs::write(&source_path, "BEGIN { print \"atlas\" }\n")?;
13745        let inactive_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
13746        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
13747        let db_path = atlas_dir.join("projectatlas.db");
13748        let mut store = open_atlas_store_for_project(&db_path, &inactive_plan.root)?;
13749        run_scan_pipeline(&mut store, &inactive_plan, &symbol_options)?;
13750        if inactive_plan.scan_options.admit_optional_languages {
13751            return Err(io::Error::other(
13752                "inactive optional pack admitted catalog languages into the scan policy",
13753            )
13754            .into());
13755        }
13756        let inactive_optional = store
13757            .load_node_by_path("main.awk")?
13758            .ok_or_else(|| io::Error::other("inactive optional source node missing"))?;
13759        if inactive_optional.node.language.is_some() {
13760            return Err(io::Error::other(
13761                "inactive optional extension received a catalog language assignment",
13762            )
13763            .into());
13764        }
13765        let before = store
13766            .index_publication()?
13767            .ok_or_else(|| io::Error::other("initial publication missing"))?;
13768
13769        let selection_path = temp.path().join(repo_path_to_native(
13770            OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH,
13771        ));
13772        fs::write(
13773            &selection_path,
13774            serde_json::to_vec(&json!({
13775                "schema_version": 1,
13776                "pack_id": "broad-parser",
13777                "selected": {
13778                    "projectatlas_version": OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION,
13779                    "artifact": "a".repeat(64),
13780                }
13781            }))?,
13782        )?;
13783        let selected_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
13784        if !selected_plan.scan_options.admit_optional_languages {
13785            return Err(io::Error::other(
13786                "selected optional pack did not enable catalog language admission",
13787            )
13788            .into());
13789        }
13790        if inactive_plan.publication_contract_fingerprint()
13791            == selected_plan.publication_contract_fingerprint()
13792        {
13793            return Err(io::Error::other(
13794                "optional parser selection did not change the derivation contract",
13795            )
13796            .into());
13797        }
13798        if publication_contract_matches(&store, &selected_plan)? {
13799            return Err(io::Error::other(
13800                "selected optional artifact unexpectedly matched the inactive publication",
13801            )
13802            .into());
13803        }
13804        if !watch_path_requires_full_scan(temp.path(), &selection_path) {
13805            return Err(io::Error::other(
13806                "optional parser selection event did not require a full refresh",
13807            )
13808            .into());
13809        }
13810
13811        let mut changes = WatchChangeSet::default();
13812        changes.paths.insert(source_path);
13813        let result =
13814            refresh_index_for_changes(&mut store, &selected_plan, &changes, &symbol_options);
13815        if !matches!(result, Err(CliError::ParserPack(_))) {
13816            return Err(io::Error::other(
13817                "missing selected artifact did not fail before publication",
13818            )
13819            .into());
13820        }
13821        require_eq(
13822            &store
13823                .index_publication()?
13824                .ok_or_else(|| io::Error::other("publication disappeared after failure"))?
13825                .generation,
13826            &before.generation,
13827            "generation after selected optional artifact failure",
13828        )?;
13829
13830        fs::remove_file(selection_path)?;
13831        let disabled_plan = selected_plan.reload()?;
13832        if disabled_plan.scan_options.admit_optional_languages {
13833            return Err(io::Error::other(
13834                "disabled optional pack retained catalog language admission",
13835            )
13836            .into());
13837        }
13838        require_eq(
13839            &disabled_plan.publication_contract_fingerprint(),
13840            &inactive_plan.publication_contract_fingerprint(),
13841            "disabled optional parser derivation contract",
13842        )?;
13843
13844        let stale_graph = SymbolGraph {
13845            path: "main.awk".to_string(),
13846            language: Some("awk".to_string()),
13847            parser: ParserKind::Fallback,
13848            symbols: Vec::new(),
13849            relations: Vec::new(),
13850        };
13851        let stale_metadata = SourceParseMetadata {
13852            path: stale_graph.path.clone(),
13853            language: stale_graph.language.clone(),
13854            parser: ParserKind::TreeSitter,
13855            symbol_count: 0,
13856            relation_count: 0,
13857        };
13858        store.replace_symbol_graph_with_metadata(&stale_graph, &stale_metadata)?;
13859        let selected_publication = store.begin_index_publication_from(
13860            &selected_plan.publication_contract_fingerprint(),
13861            before.generation,
13862        )?;
13863        selected_publication.complete()?;
13864
13865        refresh_index(&mut store, &disabled_plan, &symbol_options)?;
13866        require_eq(
13867            &store.load_source_parse_metadata("main.awk")?,
13868            &None,
13869            "disabled optional parser metadata",
13870        )?;
13871        if !publication_contract_matches(&store, &disabled_plan)? {
13872            return Err(io::Error::other(
13873                "disabled optional parser refresh did not publish its derivation contract",
13874            )
13875            .into());
13876        }
13877        Ok(())
13878    }
13879
13880    #[test]
13881    fn symbol_projection_refresh_republishes_normalized_graph_at_one_generation()
13882    -> Result<(), Box<dyn Error>> {
13883        let temp = tempfile::tempdir()?;
13884        let atlas_dir = temp.path().join(".projectatlas");
13885        fs::create_dir_all(&atlas_dir)?;
13886        fs::write(
13887            temp.path().join("lib.rs"),
13888            "pub fn caller() { target(); }\npub fn target() {}\n",
13889        )?;
13890        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
13891        let mut store =
13892            open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
13893        refresh_index(
13894            &mut store,
13895            &plan,
13896            &SymbolBuildOptions::new(1_024, Some(1), None),
13897        )?;
13898        let before = store
13899            .index_publication()?
13900            .ok_or_else(|| io::Error::other("initial publication missing"))?;
13901        let relation_query = RepositoryGraphRelationQuery::Family {
13902            relation: GraphRelationKind::Legacy(RelationKind::Calls),
13903        };
13904        require_eq(
13905            &store
13906                .repository_graph_relations(relation_query.clone(), 10)?
13907                .rows
13908                .len(),
13909            &1,
13910            "initial normalized call relation",
13911        )?;
13912
13913        run_symbol_build_pipeline(
13914            &mut store,
13915            &plan,
13916            &SymbolBuildOptions::new(1, Some(1), None),
13917            None,
13918        )?;
13919
13920        let after = store
13921            .index_publication()?
13922            .ok_or_else(|| io::Error::other("symbol publication missing"))?;
13923        require_eq(
13924            &after.generation,
13925            &before
13926                .generation
13927                .checked_next()
13928                .ok_or_else(|| io::Error::other("publication generation overflowed"))?,
13929            "symbol and graph publication generation",
13930        )?;
13931        require_eq(
13932            &store
13933                .repository_graph_relations(relation_query, 10)?
13934                .rows
13935                .len(),
13936            &0,
13937            "cleared symbol relation projection",
13938        )?;
13939        let project = store
13940            .project_instance_id()?
13941            .ok_or_else(|| io::Error::other("project identity missing"))?;
13942        let path = RepositoryNodePath::new(Path::new("lib.rs"))?;
13943        let entities = store.repository_graph_entities_by_path(project, &path, 10)?;
13944        require_eq(
13945            &entities
13946                .rows
13947                .iter()
13948                .all(|entity| entity.generation() == after.generation),
13949            &true,
13950            "symbol refresh normalized graph generation",
13951        )?;
13952        Ok(())
13953    }
13954
13955    #[test]
13956    fn unchanged_full_and_incremental_refreshes_do_not_advance_generation()
13957    -> Result<(), Box<dyn Error>> {
13958        let temp = tempfile::tempdir()?;
13959        let atlas_dir = temp.path().join(".projectatlas");
13960        fs::create_dir_all(&atlas_dir)?;
13961        let source_path = temp.path().join("lib.rs");
13962        fs::write(&source_path, "fn stable() {}\n")?;
13963        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
13964        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
13965        let mut store =
13966            open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
13967        refresh_index(&mut store, &plan, &symbol_options)?;
13968        let normal_read_plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
13969        verify_index_publication(&store, &normal_read_plan)?;
13970        let before = store
13971            .index_publication()?
13972            .ok_or_else(|| io::Error::other("initial publication missing"))?;
13973        let project = store
13974            .project_instance_id()?
13975            .ok_or_else(|| io::Error::other("project identity missing"))?;
13976        let abandoned_full_stage = atlas_dir.join("graph-stage-full-noop");
13977        fs::create_dir(&abandoned_full_stage)?;
13978        drop(AtlasStore::create_repository_graph_staging(
13979            &abandoned_full_stage.join("projectatlas.db"),
13980            &plan.root,
13981            project,
13982        )?);
13983        let full_report = refresh_index(&mut store, &plan, &symbol_options)?;
13984        let after_full = store
13985            .index_publication()?
13986            .ok_or_else(|| io::Error::other("publication missing after full no-op refresh"))?;
13987        require_eq(
13988            &after_full.generation,
13989            &before.generation,
13990            "full no-op publication generation",
13991        )?;
13992        require_eq(
13993            &full_report.text_index.candidates,
13994            &0,
13995            "full no-op text candidates",
13996        )?;
13997        require_eq(
13998            &full_report.structural_summaries.candidates,
13999            &0,
14000            "full no-op summary candidates",
14001        )?;
14002        require_eq(
14003            &full_report.symbols.candidates,
14004            &0,
14005            "full no-op symbol candidates",
14006        )?;
14007        require_eq(
14008            &full_report.symbols.max_workers,
14009            &0,
14010            "full no-op symbol workers",
14011        )?;
14012        require_eq(
14013            &abandoned_full_stage.exists(),
14014            &false,
14015            "full no-op abandoned graph stage",
14016        )?;
14017        let abandoned_incremental_stage = atlas_dir.join("graph-stage-incremental-noop");
14018        fs::create_dir(&abandoned_incremental_stage)?;
14019        drop(AtlasStore::create_repository_graph_staging(
14020            &abandoned_incremental_stage.join("projectatlas.db"),
14021            &plan.root,
14022            project,
14023        )?);
14024        let mut changes = WatchChangeSet::default();
14025        changes.paths.insert(source_path);
14026
14027        let report = refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
14028        let after = store
14029            .index_publication()?
14030            .ok_or_else(|| io::Error::other("publication missing after no-op refresh"))?;
14031
14032        require_eq(
14033            &after.generation,
14034            &before.generation,
14035            "no-op publication generation",
14036        )?;
14037        require_eq(&report.text_index.candidates, &0, "no-op text candidates")?;
14038        require_eq(
14039            &report.structural_summaries.candidates,
14040            &0,
14041            "no-op summary candidates",
14042        )?;
14043        require_eq(&report.symbols.candidates, &0, "no-op symbol candidates")?;
14044        require_eq(&report.symbols.max_workers, &0, "no-op symbol workers")?;
14045        require_eq(
14046            &abandoned_incremental_stage.exists(),
14047            &false,
14048            "incremental no-op abandoned graph stage",
14049        )?;
14050        Ok(())
14051    }
14052
14053    #[test]
14054    fn full_fallback_consumes_captured_discovery_and_rejects_later_changes()
14055    -> Result<(), Box<dyn Error>> {
14056        let temp = tempfile::tempdir()?;
14057        fs::write(temp.path().join("lib.rs"), "pub fn baseline() {}\n")?;
14058        let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
14059        let options = SymbolBuildOptions::new(1_024, Some(1), None);
14060        let mut store = AtlasStore::in_memory()?;
14061        store.set_project_root(&plan.root)?;
14062        run_scan_pipeline(&mut store, &plan, &options)?;
14063        store.set_purpose(
14064            "lib.rs",
14065            "Expose the library entry point",
14066            PurposeSource::Agent,
14067        )?;
14068        let identity = store.project_instance_id()?;
14069        let publication = store.index_publication()?;
14070        fs::create_dir(temp.path().join("added"))?;
14071        fs::write(temp.path().join("added/first.rs"), "pub fn first() {}\n")?;
14072        let control = standalone_index_work_control();
14073        let assessment =
14074            detect_index_freshness_controlled(&store, &plan, ScanLimits::default(), &control)?;
14075        require_eq(
14076            &assessment
14077                .delta
14078                .as_ref()
14079                .is_some_and(|delta| delta.requires_full_scan),
14080            &true,
14081            "folder change selects full staging",
14082        )?;
14083        fs::write(temp.path().join("late.rs"), "pub fn late() {}\n")?;
14084        // A second discovery here would absorb this edit and wrongly accept the old assessment.
14085        require_eq(
14086            &matches!(
14087                refresh_index_from_assessment_controlled(
14088                    &mut store, &plan, &options, assessment, &control,
14089                ),
14090                Err(CliError::RefreshRequired(report))
14091                    if report.changed == 1 && report.sample_paths == ["late.rs"]
14092            ),
14093            &true,
14094            "captured full fallback refuses late source",
14095        )?;
14096        require_eq(
14097            &store.index_publication()?,
14098            &publication,
14099            "unchanged publication",
14100        )?;
14101        require_eq(
14102            &store.project_instance_id()?,
14103            &identity,
14104            "preserved identity",
14105        )?;
14106        require_eq(
14107            &store.load_symbols(Some("late.rs"), None, 10)?.is_empty(),
14108            &true,
14109            "late source not published",
14110        )?;
14111        let refreshed = refresh_index(&mut store, &plan, &options)?;
14112        require_eq(
14113            &refreshed.symbols.parsed,
14114            &2,
14115            "retry parses both added sources",
14116        )?;
14117        require_eq(
14118            &refreshed.symbols.unchanged,
14119            &1,
14120            "retry reuses baseline source",
14121        )?;
14122        require_eq(
14123            &store.load_symbols(Some("late.rs"), None, 10)?.len(),
14124            &1,
14125            "retry publishes late symbol",
14126        )?;
14127        require_eq(
14128            &store.project_instance_id()?,
14129            &identity,
14130            "preserved identity",
14131        )?;
14132        require_eq(
14133            &store
14134                .load_node_by_path("lib.rs")?
14135                .and_then(|node| node.purpose.purpose),
14136            &Some("Expose the library entry point".to_string()),
14137            "preserved purpose",
14138        )?;
14139        Ok(())
14140    }
14141
14142    #[test]
14143    fn cold_refresh_applies_mixed_changes_and_preserves_same_metadata_edits()
14144    -> Result<(), Box<dyn Error>> {
14145        let temp = tempfile::tempdir()?;
14146        let root = temp.path();
14147        let changed = root.join("lib.rs");
14148        fs::write(&changed, "pub fn alpha() {}\n")?;
14149        fs::write(root.join("stable.rs"), "pub fn stable() {}\n")?;
14150        fs::write(root.join("removed.rs"), "pub fn removed() {}\n")?;
14151        fs::write(root.join("moved.rs"), "pub fn moved() {}\n")?;
14152        let plan = ScanRuntimePlan::for_path(None, root, None)?;
14153        let options = SymbolBuildOptions::new(1_024, Some(1), None);
14154        let database = root.join(".projectatlas/projectatlas.db");
14155        let mut store = open_atlas_store_for_project(&database, root)?;
14156        run_scan_pipeline(&mut store, &plan, &options)?;
14157        store.set_purpose(
14158            "lib.rs",
14159            "Expose the library entry point",
14160            PurposeSource::Agent,
14161        )?;
14162        let generation = publication_base_generation(&store)?;
14163        let identity = store.project_instance_id()?;
14164        drop(store);
14165
14166        let modified = fs::metadata(&changed)?.modified()?;
14167        fs::write(&changed, "pub fn omega() {}\n")?;
14168        fs::File::options()
14169            .write(true)
14170            .open(&changed)?
14171            .set_times(fs::FileTimes::new().set_modified(modified))?;
14172        fs::remove_file(root.join("removed.rs"))?;
14173        fs::rename(root.join("moved.rs"), root.join("renamed.rs"))?;
14174        fs::write(root.join("added.rs"), "pub fn added() {}\n")?;
14175
14176        // A new store and operation have no in-process history of these edits.
14177        let mut store = open_atlas_store_for_project(&database, root)?;
14178        let report = refresh_index(&mut store, &plan, &options)?;
14179        require_eq(
14180            &report.symbols.parsed,
14181            &3,
14182            "only current changed files parsed",
14183        )?;
14184        require_eq(
14185            &report.text_index.candidates,
14186            &3,
14187            "only changed text staged",
14188        )?;
14189        require_eq(
14190            &store.project_instance_id()?,
14191            &identity,
14192            "native identity retained",
14193        )?;
14194        require_eq(
14195            &publication_base_generation(&store)?,
14196            &generation
14197                .checked_next()
14198                .ok_or_else(|| io::Error::other("generation overflow"))?,
14199            "one complete mixed-change publication",
14200        )?;
14201        let library = store
14202            .load_node_by_path("lib.rs")?
14203            .ok_or_else(|| io::Error::other("updated library missing"))?;
14204        require_eq(
14205            &library.purpose.purpose.as_deref(),
14206            &Some("Expose the library entry point"),
14207            "approved purpose retained",
14208        )?;
14209        require_eq(
14210            &store.load_file_text("lib.rs")?.map(|text| text.content),
14211            &Some("pub fn omega() {}\n".to_string()),
14212            "same-metadata edit indexed",
14213        )?;
14214        for path in ["removed.rs", "moved.rs"] {
14215            require_eq(
14216                &store.load_node_by_path(path)?.is_none(),
14217                &true,
14218                "removed source absent",
14219            )?;
14220            require_eq(
14221                &store.load_file_text(path)?.is_none(),
14222                &true,
14223                "removed text absent",
14224            )?;
14225        }
14226        require_eq(
14227            &store.load_node_by_path("stable.rs")?.is_some(),
14228            &true,
14229            "unrelated file retained",
14230        )?;
14231        verify_index_freshness(&store, root, None)?;
14232        Ok(())
14233    }
14234
14235    #[test]
14236    fn source_assessment_rejects_policy_publication_between_capture_and_comparison()
14237    -> Result<(), Box<dyn Error>> {
14238        let temp = tempfile::tempdir()?;
14239        let root = temp.path();
14240        fs::write(root.join("lib.rs"), "pub fn stable() {}\n")?;
14241        let database = root.join(".projectatlas/projectatlas.db");
14242        let plan = ScanRuntimePlan::for_path(None, root, None)?;
14243        let options = SymbolBuildOptions::new(1_024, Some(1), None);
14244        let mut store = open_atlas_store_for_project(&database, root)?;
14245        run_scan_pipeline(&mut store, &plan, &options)?;
14246        let generation = verify_index_publication(&store, &plan)?;
14247        // A separate publisher changes only the derivation contract, leaving source identical.
14248        let mut competing = open_atlas_store_for_project(&database, root)?;
14249        competing
14250            .begin_index_publication_from("different-derivation-contract", generation)?
14251            .complete()?;
14252        let winning = competing.index_publication()?;
14253        let result = assess_source_against_publication(
14254            &store,
14255            &plan,
14256            ScanLimits::default(),
14257            &standalone_index_work_control(),
14258            generation,
14259            SourceVerificationWork::default(),
14260        );
14261        require_eq(
14262            &matches!(result, Err(CliError::Db(
14263            DbError::PublicationBaseGenerationChanged { expected, .. }
14264        )) if expected == generation),
14265            &true,
14266            "concurrent policy publication refused",
14267        )?;
14268        require_eq(
14269            &store.index_publication()?,
14270            &winning,
14271            "winning publication untouched",
14272        )?;
14273        let repaired = refresh_index(&mut store, &plan, &options)?;
14274        require_eq(
14275            &(repaired.text_index.candidates > 0),
14276            &true,
14277            "mismatched policy cannot be reported as no-op",
14278        )?;
14279        verify_index_publication(&store, &plan)?;
14280        Ok(())
14281    }
14282
14283    #[test]
14284    fn captured_source_rejects_changed_identity_contract_and_generation()
14285    -> Result<(), Box<dyn Error>> {
14286        let temp = tempfile::tempdir()?;
14287        let root = temp.path();
14288        fs::write(root.join("lib.rs"), "pub fn initial() {}\n")?;
14289        let plan = ScanRuntimePlan::for_path(None, root, None)?;
14290        let options = SymbolBuildOptions::new(1_024, Some(1), None);
14291        let mut store =
14292            open_atlas_store_for_project(&root.join(".projectatlas/projectatlas.db"), root)?;
14293        run_scan_pipeline(&mut store, &plan, &options)?;
14294        let mut changes = WatchChangeSet::default();
14295        changes.paths.insert(root.join("lib.rs"));
14296        fs::write(root.join("lib.rs"), "pub fn updated() {}\n")?;
14297        let control = standalone_index_work_control();
14298        for mismatch in ["root", "project", "contract", "generation"] {
14299            let mut source =
14300                detect_index_freshness_controlled(&store, &plan, ScanLimits::default(), &control)?
14301                    .source;
14302            match mismatch {
14303                "root" => source.root = root.join("different"),
14304                "project" => {
14305                    let alternate = ProjectInstanceId::from_bytes([1; 16])?;
14306                    source.project_instance_id = if source.project_instance_id == alternate {
14307                        ProjectInstanceId::from_bytes([2; 16])?
14308                    } else {
14309                        alternate
14310                    };
14311                }
14312                "contract" => source.contract_fingerprint.push_str("-different"),
14313                "generation" => {
14314                    run_scan_pipeline(&mut store, &plan, &options)?;
14315                }
14316                _ => return Err(io::Error::other("unexpected mismatch fixture").into()),
14317            }
14318            let before = store.index_publication()?;
14319            let result = refresh_index_for_changes_from_source_controlled(
14320                &mut store,
14321                &plan,
14322                &changes,
14323                &options,
14324                Some(source),
14325                &control,
14326            );
14327            let refused = match mismatch {
14328                "project" => matches!(result, Err(CliError::InvalidInput(_))),
14329                "generation" => matches!(
14330                    result,
14331                    Err(CliError::Db(
14332                        DbError::PublicationBaseGenerationChanged { .. }
14333                    ))
14334                ),
14335                _ => matches!(result, Err(CliError::RefreshRequired(_))),
14336            };
14337            require_eq(&refused, &true, "captured source identity mismatch refused")?;
14338            require_eq(
14339                &store.index_publication()?,
14340                &before,
14341                "last publication retained",
14342            )?;
14343        }
14344        Ok(())
14345    }
14346
14347    #[test]
14348    fn captured_source_rejects_late_unrelated_edit_without_publication()
14349    -> Result<(), Box<dyn Error>> {
14350        let temp = tempfile::tempdir()?;
14351        let root = temp.path();
14352        fs::write(root.join("lib.rs"), "pub fn initial() {}\n")?;
14353        fs::write(root.join("stable.rs"), "pub fn stable() {}\n")?;
14354        let plan = ScanRuntimePlan::for_path(None, root, None)?;
14355        let options = SymbolBuildOptions::new(1_024, Some(1), None);
14356        let mut store =
14357            open_atlas_store_for_project(&root.join(".projectatlas/projectatlas.db"), root)?;
14358        run_scan_pipeline(&mut store, &plan, &options)?;
14359        store.set_purpose(
14360            "lib.rs",
14361            "Expose reviewed library behavior",
14362            PurposeSource::Agent,
14363        )?;
14364        let before = store.index_publication()?;
14365        let identity = store.project_instance_id()?;
14366        let library = store.load_node_by_path("lib.rs")?;
14367        let text = store.load_file_text("lib.rs")?.map(|row| row.content);
14368        fs::write(root.join("lib.rs"), "pub fn updated() {}\n")?;
14369        let control = standalone_index_work_control();
14370        let source =
14371            detect_index_freshness_controlled(&store, &plan, ScanLimits::default(), &control)?
14372                .source;
14373        fs::write(
14374            root.join("stable.rs"),
14375            "pub fn changed_after_discovery() {}\n",
14376        )?;
14377        let mut changes = WatchChangeSet::default();
14378        changes.paths.insert(root.join("lib.rs"));
14379        let result = refresh_index_for_changes_from_source_controlled(
14380            &mut store,
14381            &plan,
14382            &changes,
14383            &options,
14384            Some(source),
14385            &control,
14386        );
14387        require_eq(
14388            &matches!(result, Err(CliError::RefreshRequired(_))),
14389            &true,
14390            "late unrelated edit refused",
14391        )?;
14392        require_eq(
14393            &store.index_publication()?,
14394            &before,
14395            "last generation retained",
14396        )?;
14397        require_eq(
14398            &store.project_instance_id()?,
14399            &identity,
14400            "identity retained",
14401        )?;
14402        require_eq(
14403            &store.load_node_by_path("lib.rs")?,
14404            &library,
14405            "source and purpose retained",
14406        )?;
14407        require_eq(
14408            &store.load_file_text("lib.rs")?.map(|row| row.content),
14409            &text,
14410            "last valid text retained",
14411        )?;
14412        let report = refresh_index(&mut store, &plan, &options)?;
14413        require_eq(&report.symbols.parsed, &2, "retry reconciles both edits")?;
14414        verify_index_freshness(&store, root, None)?;
14415        Ok(())
14416    }
14417
14418    #[test]
14419    fn oversized_watch_batch_reconciles_complete_state_in_one_publication()
14420    -> Result<(), Box<dyn Error>> {
14421        let temp = tempfile::tempdir()?;
14422        let root = temp.path();
14423        fs::write(root.join("lib.rs"), "pub fn previous() {}\n")?;
14424        fs::write(root.join("stable.rs"), "pub fn stable() {}\n")?;
14425        let plan = ScanRuntimePlan::for_path(None, root, None)?;
14426        let options = SymbolBuildOptions::new(1_024, Some(1), None);
14427        let mut store =
14428            open_atlas_store_for_project(&root.join(".projectatlas/projectatlas.db"), root)?;
14429        run_scan_pipeline(&mut store, &plan, &options)?;
14430        store.set_purpose(
14431            "lib.rs",
14432            "Expose the library entry point",
14433            PurposeSource::Agent,
14434        )?;
14435        let before = publication_base_generation(&store)?;
14436        let identity = store.project_instance_id()?;
14437        fs::write(root.join("lib.rs"), "pub fn updated() {}\n")?;
14438        fs::write(root.join("late.rs"), "pub fn late() {}\n")?;
14439
14440        // Event overflow requires exact discovery, including a live addition
14441        // not present in the retained event set. None of these deleted paths
14442        // should be published, and the known source edit must not be lost.
14443        let changes = WatchChangeSet {
14444            requires_full_scan: false,
14445            document_paths: HashSet::new(),
14446            paths: (0..=MAX_INCREMENTAL_CHANGED_PATHS)
14447                .map(|index| root.join(format!("deleted_{index}.rs")))
14448                .chain(std::iter::once(root.join("lib.rs")))
14449                .collect(),
14450        };
14451        let report = refresh_index_for_changes(&mut store, &plan, &changes, &options)?;
14452        require_eq(
14453            &report.text_index.candidates,
14454            &3,
14455            "full fallback covers every current file",
14456        )?;
14457        require_eq(&report.symbols.candidates, &3, "complete symbol inventory")?;
14458        require_eq(
14459            &report.symbols.parsed,
14460            &2,
14461            "changed and newly discovered sources parsed",
14462        )?;
14463        require_eq(
14464            &report.symbols.unchanged,
14465            &1,
14466            "unrelated valid parser facts reused",
14467        )?;
14468        require_eq(
14469            &store.overview()?.files,
14470            &3,
14471            "deleted event paths remain absent",
14472        )?;
14473        require_eq(
14474            &store.load_file_text("lib.rs")?.map(|text| text.content),
14475            &Some("pub fn updated() {}\n".to_string()),
14476            "retained edit published",
14477        )?;
14478        require_eq(
14479            &store.load_file_text("late.rs")?.is_some(),
14480            &true,
14481            "addition outside event set discovered",
14482        )?;
14483        require_eq(
14484            &publication_base_generation(&store)?,
14485            &before
14486                .checked_next()
14487                .ok_or_else(|| io::Error::other("generation overflow"))?,
14488            "fallback publishes exactly one complete generation",
14489        )?;
14490        require_eq(
14491            &store.project_instance_id()?,
14492            &identity,
14493            "fallback preserves identity",
14494        )?;
14495        require_eq(
14496            &store
14497                .load_node_by_path("lib.rs")?
14498                .and_then(|node| node.purpose.purpose),
14499            &Some("Expose the library entry point".to_string()),
14500            "fallback preserves approved purpose",
14501        )?;
14502        verify_index_freshness(&store, root, None)?;
14503        Ok(())
14504    }
14505
14506    #[test]
14507    fn watcher_preserves_explicit_full_refresh_guidance() -> Result<(), Box<dyn Error>> {
14508        let temp = tempfile::tempdir()?;
14509        fs::write(temp.path().join("source.rs"), "pub fn source() {}\n")?;
14510        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
14511        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
14512        let database = temp.path().join(".projectatlas/projectatlas.db");
14513        let mut store = open_atlas_store_for_project(&database, &plan.root)?;
14514        refresh_index(&mut store, &plan, &symbol_options)?;
14515        let before = store.index_publication()?;
14516
14517        let error =
14518            run_watch_with_polling_fallback(&mut store, &plan, 0, 1, &symbol_options, |_| {
14519                Err(CliError::RefreshRequired(Box::new(
14520                    index_policy_refresh_required(&plan.root),
14521                )))
14522            })
14523            .err()
14524            .ok_or_else(|| {
14525                io::Error::other("full-refresh guidance was hidden by polling fallback")
14526            })?;
14527        let CliError::RefreshRequired(report) = error else {
14528            return Err(io::Error::other(format!(
14529                "unexpected watcher error after full-refresh guidance: {error:?}"
14530            ))
14531            .into());
14532        };
14533        require_eq(
14534            &report.scope,
14535            &IndexRefreshScope::Full,
14536            "watcher full-refresh scope",
14537        )?;
14538        require_eq(
14539            &store.index_publication()?,
14540            &before,
14541            "watcher generation after full-refresh guidance",
14542        )?;
14543        Ok(())
14544    }
14545
14546    #[test]
14547    fn stopped_watcher_batch_preserves_last_valid_and_retries_one_generation()
14548    -> Result<(), Box<dyn Error>> {
14549        let temp = tempfile::tempdir()?;
14550        let atlas_dir = temp.path().join(".projectatlas");
14551        fs::create_dir_all(&atlas_dir)?;
14552        let reserved_purpose_path = atlas_dir.join("projectatlas-nonsource-files.toon");
14553        fs::write(&reserved_purpose_path, "nonsource_files[]:\n")?;
14554        let changed_path = temp.path().join("changed.rs");
14555        let deleted_path = temp.path().join("deleted.rs");
14556        let deleted_dir = temp.path().join("deleted");
14557        let deleted_descendant = deleted_dir.join("descendant.rs");
14558        fs::create_dir(&deleted_dir)?;
14559        fs::write(&changed_path, "pub fn before() {}\n")?;
14560        fs::write(&deleted_path, "pub fn removed() {}\n")?;
14561        fs::write(&deleted_descendant, "pub fn descendant() {}\n")?;
14562        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
14563        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
14564        let db_path = atlas_dir.join("projectatlas.db");
14565        let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
14566        refresh_index(&mut store, &plan, &symbol_options)?;
14567        let before = store
14568            .index_publication()?
14569            .ok_or_else(|| io::Error::other("initial publication missing"))?;
14570        let before_identity = store
14571            .project_root_identity()?
14572            .ok_or_else(|| io::Error::other("initial project identity missing"))?;
14573        let before_node = store
14574            .load_node_by_path("changed.rs")?
14575            .ok_or_else(|| io::Error::other("initial changed node missing"))?;
14576        let reviewed_reserved_purpose = "Describe reviewed non-source atlas responsibilities.";
14577        store.set_purpose(
14578            ".projectatlas/projectatlas-nonsource-files.toon",
14579            reviewed_reserved_purpose,
14580            PurposeSource::Agent,
14581        )?;
14582        let old_reader = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
14583        require_eq(
14584            &old_reader
14585                .index_publication()?
14586                .as_ref()
14587                .map(|state| state.generation),
14588            &Some(before.generation),
14589            "old reader generation before staged publication",
14590        )?;
14591        let old_text = old_reader
14592            .load_file_text("changed.rs")?
14593            .ok_or_else(|| io::Error::other("old reader text missing"))?;
14594
14595        fs::write(&changed_path, "pub fn after() {}\n")?;
14596        fs::remove_file(&deleted_path)?;
14597        fs::remove_dir_all(&deleted_dir)?;
14598        fs::write(&reserved_purpose_path, "nonsource_files[]:\n\n")?;
14599        let expired = IndexWorkControl::new(IndexCancellation::new(), Some(Duration::ZERO));
14600        if !matches!(
14601            refresh_index_controlled(&mut store, &plan, &symbol_options, &expired),
14602            Err(CliError::IndexWork(
14603                IndexWorkFailure::DeadlineExceeded { .. }
14604            ))
14605        ) {
14606            return Err(io::Error::other("expired refresh did not refuse before parsing").into());
14607        }
14608        require_eq(
14609            &store.index_publication()?.map(|state| state.generation),
14610            &Some(before.generation),
14611            "generation after pre-parser deadline refusal",
14612        )?;
14613        require_eq(
14614            &store.project_root_identity()?.as_ref(),
14615            &Some(&before_identity),
14616            "project identity after deadline refusal",
14617        )?;
14618        require_eq(
14619            &store.load_node_by_path("changed.rs")?.as_ref(),
14620            &Some(&before_node),
14621            "last-valid node after deadline refusal",
14622        )?;
14623        let retained_purpose = store
14624            .load_node_by_path(".projectatlas/projectatlas-nonsource-files.toon")?
14625            .ok_or_else(|| io::Error::other("reviewed purpose missing after deadline refusal"))?;
14626        require_eq(
14627            &retained_purpose.purpose.purpose.as_deref(),
14628            &Some(reviewed_reserved_purpose),
14629            "reviewed purpose after deadline refusal",
14630        )?;
14631        require_eq(
14632            &retained_purpose.purpose.status,
14633            &PurposeStatus::Approved,
14634            "approved purpose state after deadline refusal",
14635        )?;
14636        let preparation_control = standalone_index_work_control();
14637        let staged_batch = stage_full_index_publication(
14638            &store,
14639            &plan,
14640            &symbol_options,
14641            true,
14642            false,
14643            &preparation_control,
14644        )?;
14645        revalidate_staged_publication_inputs_controlled(
14646            &plan,
14647            staged_batch.nodes.expected_nodes(),
14648            None,
14649            &preparation_control,
14650        )?;
14651        let IndexPublicationBatch {
14652            base_generation,
14653            contract_fingerprint,
14654            root,
14655            nodes,
14656            purpose_import: _,
14657            text_paths,
14658            text,
14659            content_classifications,
14660            symbols: _,
14661            graph: _,
14662            structural_summaries: _,
14663        } = staged_batch;
14664        let mut staged =
14665            store.begin_index_publication_from(&contract_fingerprint, base_generation)?;
14666        staged.set_project_root(&root)?;
14667        let NodePublicationBatch::Full { nodes } = nodes else {
14668            return Err(io::Error::other("full staging returned an incremental batch").into());
14669        };
14670        staged.begin_scan_replacement()?;
14671        for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
14672            staged.upsert_scan_node_batch(batch)?;
14673        }
14674        apply_file_content_classification_stage(
14675            &mut staged,
14676            &content_classifications,
14677            &preparation_control,
14678        )?;
14679        staged.finish_scan_replacement()?;
14680        let late_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
14681        late_cancel.cancel();
14682        let late_result = apply_text_index_stage(&mut staged, &text_paths, &text, &late_cancel);
14683        if !matches!(
14684            late_result,
14685            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
14686                stage: IndexWorkStage::Publication,
14687            }))
14688        ) {
14689            return Err(io::Error::other("late cancellation did not stop publication").into());
14690        }
14691        drop(staged);
14692        require_eq(
14693            &store
14694                .index_publication()?
14695                .as_ref()
14696                .map(|state| state.generation),
14697            &Some(before.generation),
14698            "generation after canceled publication",
14699        )?;
14700        require_eq(
14701            &store.load_node_by_path("changed.rs")?,
14702            &Some(before_node),
14703            "last-valid node after canceled publication",
14704        )?;
14705        let mut changes = WatchChangeSet::default();
14706        changes.paths.insert(changed_path);
14707        changes.paths.insert(deleted_path);
14708        changes.paths.insert(deleted_dir);
14709        changes.paths.insert(deleted_descendant);
14710        changes.paths.insert(reserved_purpose_path);
14711        let fallback_report =
14712            run_watch_with_polling_fallback(&mut store, &plan, 0, 1, &symbol_options, |store| {
14713                let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
14714                canceled.cancel();
14715                let Err(error) = refresh_index_for_changes_controlled(
14716                    store,
14717                    &plan,
14718                    &changes,
14719                    &symbol_options,
14720                    &canceled,
14721                ) else {
14722                    return Err(CliError::InvalidInput(
14723                        "canceled watcher batch unexpectedly succeeded".to_string(),
14724                    ));
14725                };
14726                let generation = store.index_publication()?.map(|state| state.generation);
14727                if generation != Some(before.generation) {
14728                    return Err(CliError::InvalidInput(
14729                        "canceled watcher batch advanced publication before fallback".to_string(),
14730                    ));
14731                }
14732                Err(error)
14733            })?;
14734        require_eq(
14735            &fallback_report.mode.as_str(),
14736            &WATCH_MODE_POLLING,
14737            "canceled notify batch fallback mode",
14738        )?;
14739        if fallback_report
14740            .fallback_reason
14741            .as_deref()
14742            .is_none_or(|reason| !reason.contains("canceled"))
14743        {
14744            return Err(io::Error::other(
14745                "polling fallback did not retain the notify failure reason",
14746            )
14747            .into());
14748        }
14749
14750        let after = store
14751            .index_publication()?
14752            .ok_or_else(|| io::Error::other("incremental publication missing"))?;
14753        require_eq(
14754            &after.generation,
14755            &before
14756                .generation
14757                .checked_next()
14758                .ok_or_else(|| io::Error::other("test generation overflowed"))?,
14759            "one incremental publication generation",
14760        )?;
14761        require_eq(
14762            &store.load_node_by_path("deleted.rs")?.is_none(),
14763            &true,
14764            "deleted path is absent",
14765        )?;
14766        require_eq(
14767            &store
14768                .load_symbols(Some("deleted.rs"), Some("removed"), 10)?
14769                .is_empty(),
14770            &true,
14771            "deleted symbols are invalidated",
14772        )?;
14773        require_eq(
14774            &store.load_node_by_path("deleted/descendant.rs")?.is_none(),
14775            &true,
14776            "deleted descendant path is absent",
14777        )?;
14778        require_eq(
14779            &store
14780                .load_symbols(Some("deleted/descendant.rs"), Some("descendant"), 10)?
14781                .is_empty(),
14782            &true,
14783            "deleted descendant symbols are invalidated",
14784        )?;
14785        require_eq(
14786            &store
14787                .load_symbols(Some("changed.rs"), Some("after"), 10)?
14788                .len(),
14789            &1,
14790            "changed symbols are published",
14791        )?;
14792        require_eq(
14793            &store
14794                .load_symbols(Some("changed.rs"), Some("before"), 10)?
14795                .is_empty(),
14796            &true,
14797            "replaced symbols are invalidated",
14798        )?;
14799        let reserved = store
14800            .load_node_by_path(".projectatlas/projectatlas-nonsource-files.toon")?
14801            .ok_or_else(|| io::Error::other("reserved metadata node missing"))?;
14802        require_eq(
14803            &reserved.purpose.purpose.as_deref(),
14804            &Some(reviewed_reserved_purpose),
14805            "stale reviewed built-in purpose text",
14806        )?;
14807        require_eq(
14808            &reserved.purpose.status,
14809            &PurposeStatus::Approved,
14810            "reviewed built-in purpose state",
14811        )?;
14812        require_eq(
14813            &old_reader
14814                .index_publication()?
14815                .as_ref()
14816                .map(|state| state.generation),
14817            &Some(before.generation),
14818            "old reader remains on the complete prior generation",
14819        )?;
14820        require_eq(
14821            &old_reader
14822                .load_file_text("changed.rs")?
14823                .map(|text| text.content),
14824            &Some(old_text.content),
14825            "old reader remains on prior source text",
14826        )?;
14827        let new_reader = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
14828        require_eq(
14829            &new_reader
14830                .index_publication()?
14831                .as_ref()
14832                .map(|state| state.generation),
14833            &Some(after.generation),
14834            "new reader sees the complete replacement generation",
14835        )?;
14836        require_eq(
14837            &new_reader
14838                .load_file_text("changed.rs")?
14839                .map(|text| text.content),
14840            &Some("pub fn after() {}\n".to_string()),
14841            "new reader sees replacement source text",
14842        )?;
14843        new_reader.finish_index_read_snapshot()?;
14844        old_reader.finish_index_read_snapshot()?;
14845        Ok(())
14846    }
14847
14848    #[test]
14849    fn one_sided_notify_rename_events_require_full_verification() -> Result<(), Box<dyn Error>> {
14850        let temp = tempfile::tempdir()?;
14851        let old_path = temp.path().join("old.rs");
14852        let new_path = temp.path().join("new.rs");
14853        for (mode, path) in [
14854            (notify::event::RenameMode::From, old_path),
14855            (notify::event::RenameMode::To, new_path),
14856        ] {
14857            let event =
14858                Event::new(EventKind::Modify(notify::event::ModifyKind::Name(mode))).add_path(path);
14859            let changes = notify_event_changes(temp.path(), &ScanOptions::default(), &event);
14860            require_eq(
14861                &changes.requires_full_scan,
14862                &true,
14863                "one-sided rename full verification",
14864            )?;
14865        }
14866        Ok(())
14867    }
14868
14869    #[test]
14870    fn notify_rescan_and_ignored_policy_events_require_full_verification()
14871    -> Result<(), Box<dyn Error>> {
14872        let temp = tempfile::tempdir()?;
14873        fs::create_dir_all(temp.path().join(".projectatlas"))?;
14874        fs::write(temp.path().join(".gitignore"), ".projectatlas/\n")?;
14875        let config = temp.path().join(".projectatlas/config.toml");
14876        fs::write(&config, "[project]\nroot = \".\"\n")?;
14877        let config_event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
14878            notify::event::DataChange::Content,
14879        )))
14880        .add_path(config.clone());
14881        let config_changes =
14882            notify_event_changes(temp.path(), &ScanOptions::default(), &config_event);
14883        require_eq(
14884            &config_changes.requires_full_scan,
14885            &true,
14886            "ignored ProjectAtlas config full verification",
14887        )?;
14888        require_eq(
14889            &config_changes.paths.contains(&config),
14890            &true,
14891            "ignored ProjectAtlas config event path",
14892        )?;
14893
14894        let rescan_event = Event::new(EventKind::Any).set_flag(notify::event::Flag::Rescan);
14895        let rescan_changes =
14896            notify_event_changes(temp.path(), &ScanOptions::default(), &rescan_event);
14897        require_eq(
14898            &rescan_changes.requires_full_scan,
14899            &true,
14900            "backend rescan flag full verification",
14901        )?;
14902        Ok(())
14903    }
14904
14905    #[test]
14906    fn nonindexed_document_target_transitions_refresh_the_inbound_closure()
14907    -> Result<(), Box<dyn Error>> {
14908        let temp = tempfile::tempdir()?;
14909        let docs = temp.path().join("docs");
14910        fs::create_dir_all(&docs)?;
14911        fs::write(temp.path().join(".gitignore"), "!docs/target.md\n")?;
14912        fs::write(docs.join("guide.md"), "# Guide\n\n[target](target.md)\n")?;
14913        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
14914        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
14915        let mut store = open_atlas_store_for_project(
14916            &temp.path().join(".projectatlas/projectatlas.db"),
14917            &plan.root,
14918        )?;
14919        refresh_index(&mut store, &plan, &symbol_options)?;
14920        let documents = GraphRelationKind::Extended(ExtendedRelationKind::Documents);
14921        let reason = |store: &AtlasStore| -> Result<_, Box<dyn Error>> {
14922            let page = store.repository_graph_relation_rows(
14923                RepositoryGraphRelationQuery::Family {
14924                    relation: documents,
14925                },
14926                8,
14927                None,
14928            )?;
14929            require_eq(&page.rows.len(), &1, "document relation count")?;
14930            Ok(page.rows[0].document_unresolved_reason)
14931        };
14932        require_eq(
14933            &reason(&store)?,
14934            &Some(DocumentTargetUnresolvedReason::Missing),
14935            "initial missing document target",
14936        )?;
14937
14938        let outside = tempfile::tempdir()?;
14939        let outside_target = outside.path().join("target.md");
14940        fs::write(&outside_target, "outside\n")?;
14941        let target = docs.join("target.md");
14942        if !create_document_target_symlink(&outside_target, &target)? {
14943            return Ok(());
14944        }
14945        let mut changes = WatchChangeSet::default();
14946        changes.document_paths.insert(target.clone());
14947        refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
14948        require_eq(
14949            &reason(&store)?,
14950            &Some(DocumentTargetUnresolvedReason::OutsideRoot),
14951            "missing-to-outside-symlink document target",
14952        )?;
14953
14954        fs::remove_file(&target)?;
14955        refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
14956        require_eq(
14957            &reason(&store)?,
14958            &Some(DocumentTargetUnresolvedReason::Missing),
14959            "outside-symlink-to-missing document target",
14960        )?;
14961        Ok(())
14962    }
14963
14964    #[cfg(unix)]
14965    fn create_document_target_symlink(target: &Path, link: &Path) -> io::Result<bool> {
14966        std::os::unix::fs::symlink(target, link)?;
14967        Ok(true)
14968    }
14969
14970    #[cfg(windows)]
14971    fn create_document_target_symlink(target: &Path, link: &Path) -> io::Result<bool> {
14972        match std::os::windows::fs::symlink_file(target, link) {
14973            Ok(()) => Ok(true),
14974            Err(source)
14975                if source.kind() == io::ErrorKind::PermissionDenied
14976                    || source.raw_os_error() == Some(1314) =>
14977            {
14978                Ok(false)
14979            }
14980            Err(source) => Err(source),
14981        }
14982    }
14983
14984    #[test]
14985    fn directory_only_deletion_re_resolves_external_inbound_callers() -> Result<(), Box<dyn Error>>
14986    {
14987        let temp = tempfile::tempdir()?;
14988        let atlas_dir = temp.path().join(".projectatlas");
14989        let source_dir = temp.path().join("src");
14990        let removed_dir = source_dir.join("removed");
14991        fs::create_dir_all(&atlas_dir)?;
14992        fs::create_dir_all(&removed_dir)?;
14993        fs::write(
14994            source_dir.join("caller.rs"),
14995            "use crate::removed::target::target;\npub fn caller() { target(); }\n",
14996        )?;
14997        fs::write(removed_dir.join("target.rs"), "pub fn target() {}\n")?;
14998
14999        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
15000        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
15001        let mut store =
15002            open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
15003        refresh_index(&mut store, &plan, &symbol_options)?;
15004        let before = store
15005            .index_publication()?
15006            .ok_or_else(|| io::Error::other("initial publication missing"))?;
15007        require_caller_resolution(
15008            &store,
15009            "src/caller.rs",
15010            |resolution| matches!(resolution, RelationResolution::Resolved { .. }),
15011            "initial caller resolution",
15012        )?;
15013
15014        fs::remove_dir_all(&removed_dir)?;
15015        let mut changes = WatchChangeSet::default();
15016        changes.paths.insert(removed_dir);
15017        refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
15018
15019        let after = store
15020            .index_publication()?
15021            .ok_or_else(|| io::Error::other("replacement publication missing"))?;
15022        require_eq(
15023            &after.generation,
15024            &before
15025                .generation
15026                .checked_next()
15027                .ok_or_else(|| io::Error::other("test generation overflowed"))?,
15028            "directory deletion generation",
15029        )?;
15030        require_eq(
15031            &store.load_node_by_path("src/removed/target.rs")?.is_none(),
15032            &true,
15033            "deleted descendant node",
15034        )?;
15035        require_eq(
15036            &store
15037                .load_symbols(Some("src/removed/target.rs"), Some("target"), 10)?
15038                .is_empty(),
15039            &true,
15040            "deleted descendant symbol",
15041        )?;
15042        require_caller_resolution(
15043            &store,
15044            "src/caller.rs",
15045            |resolution| matches!(resolution, RelationResolution::Unresolved { .. }),
15046            "caller resolution after directory deletion",
15047        )?;
15048        Ok(())
15049    }
15050
15051    fn require_caller_resolution(
15052        store: &AtlasStore,
15053        caller_path: &str,
15054        expected: impl FnOnce(&RelationResolution) -> bool,
15055        label: &str,
15056    ) -> Result<(), Box<dyn Error>> {
15057        let project = store
15058            .project_instance_id()?
15059            .ok_or_else(|| io::Error::other("project identity missing"))?;
15060        let caller_path = RepositoryNodePath::new(Path::new(caller_path))?;
15061        let caller_entities =
15062            store.repository_graph_entities_by_path(project, &caller_path, 100)?;
15063        let relations = store.repository_graph_relations(
15064            RepositoryGraphRelationQuery::Family {
15065                relation: GraphRelationKind::Legacy(RelationKind::Calls),
15066            },
15067            100,
15068        )?;
15069        let mut matching = relations.rows.iter().filter(|relation| {
15070            caller_entities
15071                .rows
15072                .iter()
15073                .any(|entity| entity.key() == relation.source())
15074        });
15075        let relation = matching
15076            .next()
15077            .ok_or_else(|| io::Error::other(format!("{label}: caller relation missing")))?;
15078        if matching.next().is_some() {
15079            return Err(io::Error::other(format!("{label}: multiple caller relations")).into());
15080        }
15081        if !expected(relation.resolution()) {
15082            return Err(io::Error::other(format!(
15083                "{label}: unexpected resolution {:?}",
15084                relation.resolution()
15085            ))
15086            .into());
15087        }
15088        Ok(())
15089    }
15090
15091    #[test]
15092    fn purpose_curator_handoff_applies_one_stale_safe_batch() -> Result<(), Box<dyn Error>> {
15093        let temp = tempfile::tempdir()?;
15094        let root = temp.path().join("repository");
15095        fs::create_dir(&root)?;
15096        let database = temp.path().join("projectatlas.db");
15097        let mut store = AtlasStore::open_for_project(&database, &root)?;
15098        store.replace_scan(&[
15099            Node {
15100                path: "src/main.rs".to_string(),
15101                kind: NodeKind::File,
15102                parent_path: Some("src".to_string()),
15103                extension: Some(".rs".to_string()),
15104                language: Some("rust".to_string()),
15105                size_bytes: Some(12),
15106                mtime_ns: Some(10),
15107                content_hash: Some("hash-main".to_string()),
15108            },
15109            Node {
15110                path: "src/detail.rs".to_string(),
15111                kind: NodeKind::File,
15112                parent_path: Some("src".to_string()),
15113                extension: Some(".rs".to_string()),
15114                language: Some("rust".to_string()),
15115                size_bytes: Some(12),
15116                mtime_ns: Some(10),
15117                content_hash: Some("hash-detail".to_string()),
15118            },
15119        ])?;
15120        store.set_suggested_purpose("src/detail.rs", "Generated detail suggestion")?;
15121        let task = "runtime-purpose-curator";
15122        let page = purpose_curation_page(
15123            &store,
15124            &HealthQuery {
15125                start_index: 0,
15126                limit: 20,
15127                category: None,
15128                severity: Some(Severity::Warning),
15129                path_prefix: None,
15130                summary_only: false,
15131                scope: HealthScope::all(),
15132            },
15133            task,
15134        )?;
15135        require_eq(&page.actionable, &true, "actionable queue")?;
15136        require_eq(&page.items.len(), &2, "queue item count")?;
15137        require_eq(&page.task, &task.to_string(), "queue task")?;
15138        require_eq(
15139            &page
15140                .items
15141                .iter()
15142                .all(|item| item.classification == Some(ContentClassification::Opaque)),
15143            &true,
15144            "queue file classifications",
15145        )?;
15146        let requests = page
15147            .items
15148            .iter()
15149            .map(|item| PurposeReviewRequest {
15150                path: item.path.clone(),
15151                purpose: Some(format!("Reviewed purpose for {}", item.path)),
15152                confirm_existing: false,
15153                task: Some(page.task.clone()),
15154                work_key: Some(item.work_key.clone()),
15155                state_token: Some(item.state_token.clone()),
15156            })
15157            .collect::<Vec<_>>();
15158        let handoff = purpose_curator_handoff(page);
15159        require_eq(
15160            &handoff.execution_owner,
15161            &"agent_host",
15162            "host-owned execution",
15163        )?;
15164        require_eq(
15165            &handoff.server_started_curator,
15166            &false,
15167            "no server-started curator",
15168        )?;
15169        require_eq(
15170            &handoff.recommended_subagent_reasoning,
15171            &PURPOSE_CURATOR_RECOMMENDED_REASONING,
15172            "lowest reliable host-supported reasoning",
15173        )?;
15174        require_eq(
15175            &handoff.instructions.first().is_some_and(|instruction| {
15176                instruction.contains("lowest reliable reasoning and cost tier the host supports")
15177            }),
15178            &true,
15179            "purpose handoff omitted the reliable reasoning and cost qualification",
15180        )?;
15181        require_eq(&handoff.main_agent_fallback, &true, "main-agent fallback")?;
15182
15183        let mixed = vec![
15184            requests[0].clone(),
15185            PurposeReviewRequest {
15186                path: requests[1].path.clone(),
15187                purpose: Some("Explicit correction must stay separate".to_string()),
15188                confirm_existing: false,
15189                task: None,
15190                work_key: None,
15191                state_token: None,
15192            },
15193        ];
15194        match review_purposes(&store, &mixed, true) {
15195            Err(CliError::InvalidInput(_)) => {}
15196            Err(error) => {
15197                return Err(io::Error::other(format!(
15198                    "mixed purpose batch returned the wrong error: {error}"
15199                ))
15200                .into());
15201            }
15202            Ok(_) => return Err(io::Error::other("mixed purpose batch was accepted").into()),
15203        }
15204        let mut partial = requests[1].clone();
15205        partial.state_token = None;
15206        match review_purposes(&store, &[requests[0].clone(), partial], true) {
15207            Err(CliError::InvalidInput(_)) => {}
15208            Err(error) => {
15209                return Err(io::Error::other(format!(
15210                    "partial conditional batch returned the wrong error: {error}"
15211                ))
15212                .into());
15213            }
15214            Ok(_) => {
15215                return Err(io::Error::other("partial conditional batch was accepted").into());
15216            }
15217        }
15218        drop(store);
15219        store = AtlasStore::open_for_project(&database, &root)?;
15220        let unchanged =
15221            store.load_nodes_by_paths(&["src/main.rs".to_string(), "src/detail.rs".to_string()])?;
15222        require_eq(
15223            &unchanged.iter().all(|node| !node.purpose.agent_reviewed()),
15224            &true,
15225            "rejected batch left every purpose unapproved after reopen",
15226        )?;
15227
15228        let applied = review_purposes(&store, &requests, true)?;
15229        require_eq(&applied.changed, &2, "conditional batch changed count")?;
15230        require_eq(&applied.conflicts, &0, "conditional batch conflicts")?;
15231        require_eq(
15232            &applied
15233                .items
15234                .iter()
15235                .all(|item| item.classification == Some(ContentClassification::Opaque)),
15236            &true,
15237            "purpose review classifications",
15238        )?;
15239        require_eq(
15240            &applied
15241                .items
15242                .iter()
15243                .all(|item| item.action == PurposeReviewAction::Review),
15244            &true,
15245            "conditional batch actions",
15246        )?;
15247
15248        let repeated = review_purposes(&store, &requests, true)?;
15249        require_eq(&repeated.changed, &0, "accepted repeat changed count")?;
15250        require_eq(&repeated.conflicts, &2, "accepted repeat conflicts")?;
15251        require_eq(
15252            &repeated
15253                .items
15254                .iter()
15255                .all(|item| item.action == PurposeReviewAction::Accepted),
15256            &true,
15257            "accepted repeat actions",
15258        )?;
15259        let empty = purpose_curation_page(
15260            &store,
15261            &HealthQuery {
15262                start_index: 0,
15263                limit: 20,
15264                category: None,
15265                severity: Some(Severity::Warning),
15266                path_prefix: None,
15267                summary_only: false,
15268                scope: HealthScope::all(),
15269            },
15270            task,
15271        )?;
15272        require_eq(&empty.actionable, &false, "accepted queue is quiet")?;
15273
15274        let correction = review_purposes(
15275            &store,
15276            &[PurposeReviewRequest {
15277                path: "src/main.rs".to_string(),
15278                purpose: Some("Explicit corrected purpose".to_string()),
15279                confirm_existing: false,
15280                task: None,
15281                work_key: None,
15282                state_token: None,
15283            }],
15284            true,
15285        )?;
15286        require_eq(
15287            &correction.items[0].action,
15288            &PurposeReviewAction::Review,
15289            "explicit correction action",
15290        )?;
15291        let corrected = store
15292            .load_node_by_path("src/main.rs")?
15293            .ok_or_else(|| io::Error::other("corrected runtime path disappeared"))?;
15294        require_eq(
15295            &corrected.purpose.purpose.as_deref(),
15296            &Some("Explicit corrected purpose"),
15297            "explicit correction value",
15298        )?;
15299        Ok(())
15300    }
15301
15302    #[test]
15303    fn purpose_review_admission_bounds_input_and_prevents_partial_apply()
15304    -> Result<(), Box<dyn Error>> {
15305        let temp = tempfile::tempdir()?;
15306        let root = temp.path().join("repository");
15307        fs::create_dir(&root)?;
15308        let database = temp.path().join("projectatlas.db");
15309        let mut store = AtlasStore::open_for_project(&database, &root)?;
15310        store.replace_scan(&[
15311            Node {
15312                path: "src/first.rs".to_string(),
15313                kind: NodeKind::File,
15314                parent_path: Some("src".to_string()),
15315                extension: Some(".rs".to_string()),
15316                language: Some("rust".to_string()),
15317                size_bytes: Some(12),
15318                mtime_ns: Some(10),
15319                content_hash: Some("hash-first".to_string()),
15320            },
15321            Node {
15322                path: "src/second.rs".to_string(),
15323                kind: NodeKind::File,
15324                parent_path: Some("src".to_string()),
15325                extension: Some(".rs".to_string()),
15326                language: Some("rust".to_string()),
15327                size_bytes: Some(12),
15328                mtime_ns: Some(10),
15329                content_hash: Some("hash-second".to_string()),
15330            },
15331        ])?;
15332        store.set_suggested_purpose("src/first.rs", "Generated first purpose")?;
15333        store.set_purpose(
15334            "src/second.rs",
15335            &"x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES + 1),
15336            PurposeSource::Imported,
15337        )?;
15338
15339        let valid_first = PurposeReviewRequest {
15340            path: "src/first.rs".to_string(),
15341            purpose: Some("Reviewed café λ purpose".to_string()),
15342            confirm_existing: false,
15343            task: None,
15344            work_key: None,
15345            state_token: None,
15346        };
15347        let oversized_report = PurposeReviewRequest {
15348            path: "src/second.rs".to_string(),
15349            purpose: None,
15350            confirm_existing: true,
15351            task: None,
15352            work_key: None,
15353            state_token: None,
15354        };
15355        let Err(error) = review_purposes(&store, &[valid_first.clone(), oversized_report], true)
15356        else {
15357            return Err(io::Error::other(
15358                "oversized retained report field was accepted before apply",
15359            )
15360            .into());
15361        };
15362        require_eq(
15363            &error
15364                .to_string()
15365                .contains("purpose review report field purpose"),
15366            &true,
15367            "oversized report field error",
15368        )?;
15369        let unchanged = store
15370            .load_node_by_path("src/first.rs")?
15371            .ok_or_else(|| io::Error::other("first review fixture disappeared"))?;
15372        require_eq(
15373            &unchanged.purpose.agent_reviewed(),
15374            &false,
15375            "report admission failure prevented partial apply",
15376        )?;
15377
15378        let oversized_field = PurposeReviewRequest {
15379            purpose: Some("x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES + 1)),
15380            ..valid_first.clone()
15381        };
15382        let Err(error) = review_purposes(&store, &[oversized_field], true) else {
15383            return Err(io::Error::other("oversized request field passed admission").into());
15384        };
15385        require_eq(
15386            &error.to_string().contains("field purpose"),
15387            &true,
15388            "oversized input field error",
15389        )?;
15390
15391        let too_many = vec![valid_first.clone(); MAX_PURPOSE_CURATION_BATCH_ROWS + 1];
15392        let Err(error) = review_purposes(&store, &too_many, true) else {
15393            return Err(io::Error::other("oversized request count passed admission").into());
15394        };
15395        require_eq(
15396            &error.to_string().contains("maximum is 200"),
15397            &true,
15398            "oversized item count error",
15399        )?;
15400
15401        let aggregate = (0..9)
15402            .map(|index| PurposeReviewRequest {
15403                path: format!("src/{index}.rs"),
15404                purpose: Some("x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES)),
15405                confirm_existing: false,
15406                task: None,
15407                work_key: None,
15408                state_token: None,
15409            })
15410            .collect::<Vec<_>>();
15411        let Err(error) = review_purposes(&store, &aggregate, false) else {
15412            return Err(io::Error::other("oversized aggregate request passed admission").into());
15413        };
15414        require_eq(
15415            &error.to_string().contains("aggregate string bytes"),
15416            &true,
15417            "oversized aggregate input error",
15418        )?;
15419
15420        let preview = review_purposes(&store, &[valid_first], false)?;
15421        require_eq(
15422            &preview.items[0].purpose,
15423            &"Reviewed café λ purpose".to_string(),
15424            "UTF-8 purpose compatibility",
15425        )?;
15426        require_eq(
15427            &render_purpose_review_report(&preview).contains("Reviewed café λ purpose"),
15428            &true,
15429            "UTF-8 TOON compatibility",
15430        )?;
15431        Ok(())
15432    }
15433
15434    #[test]
15435    fn indexed_navigation_read_rejects_stale_or_oversized_source_before_allocation()
15436    -> Result<(), Box<dyn Error>> {
15437        let temp = tempfile::tempdir()?;
15438        let root = temp.path().join("repository");
15439        let source_dir = root.join("src");
15440        fs::create_dir_all(&source_dir)?;
15441        let source = source_dir.join("large.rs");
15442        fs::File::create(&source)?.set_len(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1)?;
15443        let database = temp.path().join("projectatlas.db");
15444        let mut store = AtlasStore::open_for_project(&database, &root)?;
15445        store.replace_scan(&[Node {
15446            path: "src/large.rs".to_string(),
15447            kind: NodeKind::File,
15448            parent_path: Some("src".to_string()),
15449            extension: Some(".rs".to_string()),
15450            language: Some("rust".to_string()),
15451            size_bytes: Some(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1),
15452            mtime_ns: Some(10),
15453            content_hash: Some("unused-oversized-hash".to_string()),
15454        }])?;
15455
15456        let Err(error) = read_indexed_file_content(&store, "src/large.rs") else {
15457            return Err(
15458                io::Error::other("oversized indexed source was allocated and accepted").into(),
15459            );
15460        };
15461        let CliError::VerificationIncomplete(details) = error else {
15462            return Err(io::Error::other("oversized source returned the wrong error type").into());
15463        };
15464        require_eq(
15465            &details.reason,
15466            &IndexVerificationReason::SourceTooLarge,
15467            "oversized source reason",
15468        )?;
15469
15470        fs::File::create(&source)?.set_len(1)?;
15471        let Err(error) = read_indexed_file_content(&store, "src/large.rs") else {
15472            return Err(io::Error::other("changed source size did not require refresh").into());
15473        };
15474        let CliError::RefreshRequired(details) = error else {
15475            return Err(
15476                io::Error::other("changed source size returned the wrong error type").into(),
15477            );
15478        };
15479        require_eq(
15480            &details.reason,
15481            &IndexRefreshReason::SourceChanged,
15482            "changed source size reason",
15483        )?;
15484        Ok(())
15485    }
15486
15487    #[cfg(windows)]
15488    #[test]
15489    fn extended_windows_watch_roots_keep_deleted_paths_in_scope() -> Result<(), Box<dyn Error>> {
15490        let root = Path::new(r"\\?\C:\repo");
15491        let deleted = Path::new(r"C:\repo\src\deleted.rs");
15492        require_eq(
15493            &normalized_deleted_path(root, deleted)?,
15494            &Some("src/deleted.rs".to_string()),
15495            "extended root deleted path",
15496        )?;
15497        Ok(())
15498    }
15499
15500    #[cfg(windows)]
15501    #[test]
15502    fn notify_events_normalize_unicode_paths_against_extended_windows_roots()
15503    -> Result<(), Box<dyn Error>> {
15504        let temp = tempfile::tempdir()?;
15505        let source_dir = temp.path().join("src");
15506        fs::create_dir(&source_dir)?;
15507        let deleted = source_dir.join("Über.rs");
15508        fs::write(&deleted, "pub fn before() {}\n")?;
15509        fs::remove_file(&deleted)?;
15510
15511        let root_text = temp
15512            .path()
15513            .to_str()
15514            .ok_or_else(|| io::Error::other("temporary path is not UTF-8"))?;
15515        let extended_root = if root_text.starts_with(r"\\?\") {
15516            temp.path().to_path_buf()
15517        } else {
15518            PathBuf::from(format!(r"\\?\{root_text}"))
15519        };
15520        let event = Event::new(EventKind::Remove(notify::event::RemoveKind::File))
15521            .add_path(deleted.clone());
15522        let changes = notify_event_changes(&extended_root, &ScanOptions::default(), &event);
15523
15524        require_eq(
15525            &changes.paths.contains(&deleted),
15526            &true,
15527            "native Unicode watcher path",
15528        )?;
15529        require_eq(
15530            &changes.requires_full_scan,
15531            &false,
15532            "source removal full-scan policy",
15533        )?;
15534        require_eq(
15535            &normalized_deleted_path(&extended_root, &deleted)?,
15536            &Some("src/Über.rs".to_string()),
15537            "normalized Unicode deleted path",
15538        )?;
15539        Ok(())
15540    }
15541
15542    #[cfg(unix)]
15543    #[test]
15544    fn notify_events_preserve_native_backslash_paths_on_unix() -> Result<(), Box<dyn Error>> {
15545        let temp = tempfile::tempdir()?;
15546        let source = temp.path().join(r"src\generated.rs");
15547        fs::write(&source, "pub fn generated() {}\n")?;
15548        let event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
15549            notify::event::DataChange::Content,
15550        )))
15551        .add_path(source.clone());
15552
15553        let changes = notify_event_changes(temp.path(), &ScanOptions::default(), &event);
15554
15555        require_eq(
15556            &changes.paths,
15557            &HashSet::from([source]),
15558            "native Unix watcher paths",
15559        )?;
15560        Ok(())
15561    }
15562
15563    fn runtime_pdf_fixture() -> Vec<u8> {
15564        let objects = [
15565            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".as_slice(),
15566            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n".as_slice(),
15567            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n".as_slice(),
15568            b"4 0 obj\n<< /Length 42 >>\nstream\nBT /F1 12 Tf 72 720 Td (Runtime PDF) Tj ET\nendstream\nendobj\n".as_slice(),
15569            b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n".as_slice(),
15570        ];
15571        let mut pdf = b"%PDF-1.4\n".to_vec();
15572        let mut offsets = Vec::new();
15573        for object in objects {
15574            offsets.push(pdf.len());
15575            pdf.extend_from_slice(object);
15576        }
15577        let xref = pdf.len();
15578        pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
15579        pdf.extend_from_slice(b"0000000000 65535 f \n");
15580        for offset in offsets {
15581            pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
15582        }
15583        pdf.extend_from_slice(
15584            format!(
15585                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
15586                objects.len() + 1
15587            )
15588            .as_bytes(),
15589        );
15590        pdf
15591    }
15592
15593    #[test]
15594    fn document_input_does_not_consume_retained_text_capacity() -> Result<(), Box<dyn Error>> {
15595        let temp = tempfile::tempdir()?;
15596        let pdf = runtime_pdf_fixture();
15597        let mut nodes = Vec::new();
15598        for (path, language, bytes) in [
15599            ("seed.txt", "text", b"seed".as_slice()),
15600            ("guide.pdf", "pdf", pdf.as_slice()),
15601        ] {
15602            fs::write(temp.path().join(path), bytes)?;
15603            nodes.push(Node {
15604                path: path.to_owned(),
15605                kind: NodeKind::File,
15606                parent_path: None,
15607                extension: None,
15608                language: Some(language.to_owned()),
15609                size_bytes: Some(bytes.len() as u64),
15610                mtime_ns: Some(1),
15611                content_hash: Some(blake3::hash(bytes).to_hex().to_string()),
15612            });
15613        }
15614        let control = standalone_index_work_control();
15615        for known_size in [true, false] {
15616            nodes[1].size_bytes = known_size.then_some(pdf.len() as u64);
15617            let rows = indexed_file_texts_for_nodes_with_limit(
15618                temp.path(),
15619                &nodes,
15620                TextIndexOptions::new(64),
15621                15,
15622                &control,
15623            )?;
15624            if rows.len() != 2
15625                || rows[1].text.as_ref().map(|text| text.content.as_str()) != Some("Runtime PDF")
15626            {
15627                return Err(
15628                    io::Error::other("document input consumed retained-text capacity").into(),
15629                );
15630            }
15631            if !matches!(
15632                indexed_file_texts_for_nodes_with_limit(
15633                    temp.path(),
15634                    &nodes,
15635                    TextIndexOptions::new(64),
15636                    14,
15637                    &control,
15638                ),
15639                Err(CliError::IndexWork(
15640                    IndexWorkFailure::ResourceLimitExceeded {
15641                        stage: IndexWorkStage::TextIndex,
15642                        resource: IndexWorkResource::TextBytes,
15643                        ..
15644                    }
15645                ))
15646            ) {
15647                return Err(io::Error::other(
15648                    "extracted document text exceeded retained-text capacity",
15649                )
15650                .into());
15651            }
15652        }
15653        Ok(())
15654    }
15655
15656    #[test]
15657    fn document_text_staging_preserves_typed_resource_refusal() -> Result<(), Box<dyn Error>> {
15658        let temp = tempfile::tempdir()?;
15659        let maximum = projectatlas_symbols::MAX_DOCUMENT_COMPRESSED_BYTES;
15660        let mut bytes = vec![b' '; maximum + 1];
15661        bytes[..5].copy_from_slice(b"%PDF-");
15662        fs::write(temp.path().join("oversized.pdf"), &bytes)?;
15663        let node = Node {
15664            path: "oversized.pdf".to_owned(),
15665            kind: NodeKind::File,
15666            parent_path: None,
15667            extension: Some(".pdf".to_owned()),
15668            language: Some("pdf".to_owned()),
15669            size_bytes: Some(bytes.len() as u64),
15670            mtime_ns: Some(1),
15671            content_hash: Some(blake3::hash(&bytes).to_hex().to_string()),
15672        };
15673        let result = indexed_file_texts_for_nodes(
15674            temp.path(),
15675            &[node],
15676            TextIndexOptions::new(MAX_SYMBOL_FILE_BYTES),
15677        );
15678        if matches!(
15679            result,
15680            Err(CliError::IndexWork(
15681                IndexWorkFailure::ResourceLimitExceeded {
15682                    stage: IndexWorkStage::TextIndex,
15683                    resource: IndexWorkResource::SourceBytes,
15684                    ..
15685                }
15686            ))
15687        ) {
15688            Ok(())
15689        } else {
15690            Err(io::Error::other(format!(
15691                "document resource refusal lost its type: {result:?}"
15692            ))
15693            .into())
15694        }
15695    }
15696
15697    #[test]
15698    fn document_bytes_reach_text_and_symbol_publication_paths() -> Result<(), Box<dyn Error>> {
15699        let temp = tempfile::tempdir()?;
15700        let path = temp.path().join("guide.pdf");
15701        let bytes = runtime_pdf_fixture();
15702        fs::write(&path, &bytes)?;
15703        let node = Node {
15704            path: "guide.pdf".to_owned(),
15705            kind: NodeKind::File,
15706            parent_path: None,
15707            extension: Some(".pdf".to_owned()),
15708            language: Some("pdf".to_owned()),
15709            size_bytes: Some(bytes.len() as u64),
15710            mtime_ns: Some(1),
15711            content_hash: Some(blake3::hash(&bytes).to_hex().to_string()),
15712        };
15713        let rows = indexed_file_texts_for_nodes(
15714            temp.path(),
15715            std::slice::from_ref(&node),
15716            TextIndexOptions::new(projectatlas_symbols::MAX_DOCUMENT_OUTPUT_BYTES as u64),
15717        )?;
15718        require_eq(&rows.len(), &1, "document text row count")?;
15719        require_eq(
15720            &rows[0].text.as_ref().map(|text| text.content.as_str()),
15721            &Some("Runtime PDF"),
15722            "document text content",
15723        )?;
15724        let SymbolParseOutcome::Parsed(parsed) = parse_symbol_job(
15725            &SymbolParseJob {
15726                path: node.path,
15727                native_path: path,
15728                expected_content_hash: node.content_hash.unwrap_or_default(),
15729                language: node.language,
15730                fallback_summary: None,
15731                purpose_needs_suggestion: false,
15732            },
15733            &SymbolBuildOptions::new(
15734                projectatlas_symbols::MAX_DOCUMENT_OUTPUT_BYTES as u64,
15735                Some(1),
15736                None,
15737            ),
15738            Instant::now(),
15739        ) else {
15740            return Err(io::Error::other("document symbol job did not parse").into());
15741        };
15742        require_eq(&parsed.graph.symbols.len(), &1, "document symbol count")?;
15743        require_eq(
15744            &parsed.graph.symbols[0]
15745                .signature
15746                .contains("pdf:page=1;text-span="),
15747            &true,
15748            "document locator persisted in graph symbol",
15749        )?;
15750        Ok(())
15751    }
15752
15753    #[test]
15754    fn document_coverage_marker_does_not_use_publication_symbol_budget()
15755    -> Result<(), Box<dyn Error>> {
15756        let facts = projectatlas_symbols::DocumentFacts {
15757            format: projectatlas_symbols::DocumentFormat::Docx,
15758            text: "Hello".to_owned(),
15759            facts: vec![projectatlas_symbols::DocumentFact {
15760                text: "Hello".to_owned(),
15761                locator: projectatlas_symbols::DocumentLocator::Docx {
15762                    part: "word/document.xml".to_owned(),
15763                    paragraph: 1,
15764                    run: 1,
15765                    text_start: 0,
15766                    text_end: 5,
15767                },
15768                line_start: 1,
15769                line_end: 1,
15770            }],
15771            symbols: Vec::new(),
15772            completeness: projectatlas_symbols::DocumentCompleteness::Partial {
15773                gaps: vec![projectatlas_symbols::DocumentCoverageGap::UnexaminedStory],
15774            },
15775            provenance: projectatlas_symbols::DocumentParserProvenance::QuickXml,
15776        };
15777        let mut parsed = SymbolParseSuccess {
15778            path: "guide.docx".to_owned(),
15779            graph: facts.symbol_graph("guide.docx", None),
15780            markdown_facts: None,
15781            source_semantics: None,
15782            source_parser: ParserKind::Structural,
15783            summary: "Hello".to_owned(),
15784            summary_is_structural: true,
15785            purpose_suggestion: None,
15786        };
15787        let with_marker = symbol_parse_output_bytes(&parsed);
15788        require_eq(
15789            &graph_projection::navigable_symbol_count(&parsed.graph),
15790            &1,
15791            "only the real document block uses a symbol row",
15792        )?;
15793        parsed.graph.symbols.pop();
15794        require_eq(
15795            &with_marker,
15796            &symbol_parse_output_bytes(&parsed),
15797            "coverage marker used publication output bytes",
15798        )?;
15799        Ok(())
15800    }
15801
15802    #[test]
15803    fn empty_document_refresh_discards_previous_summary() -> Result<(), Box<dyn Error>> {
15804        let bytes = runtime_pdf_fixture();
15805        let mut job = SymbolParseJob {
15806            path: "guide.pdf".to_owned(),
15807            native_path: PathBuf::from("guide.pdf"),
15808            expected_content_hash: String::new(),
15809            language: Some("pdf".to_owned()),
15810            fallback_summary: None,
15811            purpose_needs_suggestion: true,
15812        };
15813        let options = SymbolBuildOptions::new(
15814            projectatlas_symbols::MAX_DOCUMENT_OUTPUT_BYTES as u64,
15815            Some(1),
15816            None,
15817        );
15818        let control = standalone_index_work_control();
15819        let SymbolParseOutcome::Parsed(previous) =
15820            parse_document_symbol_job(&job, &bytes, &options, &control)
15821        else {
15822            return Err(io::Error::other("document did not parse").into());
15823        };
15824        require_eq(&previous.graph.symbols.len(), &1, "previous document block")?;
15825        require_eq(
15826            &previous.summary.contains("Runtime PDF"),
15827            &true,
15828            "literal document summary",
15829        )?;
15830        require_eq(
15831            &previous
15832                .purpose_suggestion
15833                .as_deref()
15834                .unwrap_or_default()
15835                .contains("Runtime PDF"),
15836            &true,
15837            "literal document purpose suggestion",
15838        )?;
15839        job.fallback_summary = Some(previous.summary);
15840        let empty = String::from_utf8(bytes)?.replace("Runtime PDF", "           ");
15841        let SymbolParseOutcome::Parsed(current) =
15842            parse_document_symbol_job(&job, empty.as_bytes(), &options, &control)
15843        else {
15844            return Err(io::Error::other("empty document did not parse").into());
15845        };
15846        require_eq(&current.graph.symbols.len(), &0, "empty document blocks")?;
15847        require_eq(
15848            &current.summary.contains("Runtime PDF"),
15849            &false,
15850            "removed document text",
15851        )?;
15852        require_eq(
15853            &current.summary.contains("document-block-1"),
15854            &false,
15855            "summary must discard removed document blocks",
15856        )?;
15857        require_eq(
15858            &current
15859                .purpose_suggestion
15860                .as_deref()
15861                .unwrap_or_default()
15862                .contains("document-block-1"),
15863            &false,
15864            "suggested purpose must discard removed document blocks",
15865        )?;
15866        Ok(())
15867    }
15868
15869    #[cfg(unix)]
15870    #[test]
15871    fn notify_events_preserve_non_utf8_worktree_root_identity() -> Result<(), Box<dyn Error>> {
15872        use std::ffi::OsString;
15873        use std::os::unix::ffi::OsStringExt;
15874
15875        let temp = tempfile::tempdir()?;
15876        let root = temp
15877            .path()
15878            .join(OsString::from_vec(b"worktree-root-\xff".to_vec()));
15879        let source = root.join("src").join("generated.rs");
15880        fs::create_dir_all(
15881            source
15882                .parent()
15883                .ok_or_else(|| io::Error::other("watch source has no parent"))?,
15884        )?;
15885        fs::write(&source, "pub fn generated() {}\n")?;
15886        let event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
15887            notify::event::DataChange::Content,
15888        )))
15889        .add_path(source.clone());
15890
15891        let changes = notify_event_changes(&root, &ScanOptions::default(), &event);
15892
15893        require_eq(
15894            &changes.paths,
15895            &HashSet::from([source.clone()]),
15896            "non-UTF-8 worktree-root watcher path",
15897        )?;
15898        require_eq(
15899            &normalized_deleted_path(&root, &source)?,
15900            &Some("src/generated.rs".to_string()),
15901            "non-UTF-8 worktree-root relative watcher path",
15902        )?;
15903        Ok(())
15904    }
15905
15906    /// Require a test condition without relying on a crate-level test helper.
15907    #[cfg(unix)]
15908    fn require_condition(condition: bool, label: &str) -> Result<(), Box<dyn Error>> {
15909        if condition {
15910            Ok(())
15911        } else {
15912            Err(io::Error::other(label).into())
15913        }
15914    }
15915
15916    /// Require equal test values without panicking from a fallible test.
15917    fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
15918    where
15919        T: Debug + PartialEq,
15920    {
15921        if actual == expected {
15922            Ok(())
15923        } else {
15924            Err(io::Error::other(format!(
15925                "{label} mismatch: expected {expected:?}, got {actual:?}"
15926            ))
15927            .into())
15928        }
15929    }
15930}