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, 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, language_capability,
42    language_registry_digest, language_registry_report,
43};
44#[cfg(all(test, feature = "optional-parser-supervisor"))]
45use projectatlas_core::optional_parser_pack::OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION;
46use projectatlas_core::outline::estimate_tokens;
47use projectatlas_core::relation_capabilities::{
48    RelationFamilyInventoryReport, relation_family_inventory_report,
49};
50use projectatlas_core::symbols::{
51    ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
52};
53use projectatlas_core::telemetry::{
54    TOKEN_BASELINE_DIRECTORY_WALK, TOKEN_BASELINE_SELECTED_CANDIDATES,
55    TOKEN_BUCKET_NAVIGATION_AVOIDANCE, TOKEN_CONFIDENCE_INFERRED, TOKEN_CONFIDENCE_POLICY_ESTIMATE,
56    UsageInstanceId, UsageInstanceOwner, usage_from_estimates_with_context, usage_from_text,
57};
58use projectatlas_core::toon::{encode_agent_payload, render_ranked_node_rows, render_symbol_rows};
59use projectatlas_core::{
60    CanonicalProjectRoot, IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure,
61    IndexWorkResource, IndexWorkStage, Node, NodeKind, Overview, PurposeSource, PurposeStatus,
62    normalize_native_path_display, normalize_native_path_display_str, normalize_repo_path,
63    purpose_review_signal, repo_path_to_native, validated_repo_file_key, validated_repo_node_key,
64};
65use projectatlas_db::{
66    AtlasStore, ClassifiedSymbol, DatabasePublicationContractState, DatabasePublicationReport,
67    DatabaseSchemaCompatibility, DatabaseSettingsReport, DbResult, FileContentClassification,
68    HealthFindingsPage, HealthQuery, HealthScope, IndexPublication, IndexPublicationGuard,
69    IndexPublicationState, IndexedFileText, MAX_FILE_CONTENT_CLASSIFICATION_PATHS,
70    MAX_PURPOSE_CURATION_BATCH_ROWS, PurposeConditionalApplyRequest, PurposeConditionalApplyState,
71    TelemetryRetentionState, WorktreeRegistration, WorktreeUsageSnapshot, database_settings_report,
72    preflight_project_binding_read_only, read_legacy_project_root_candidate_read_only,
73    read_project_root_identity_read_only, validate_database_location,
74};
75use projectatlas_fs::worktree::{
76    GitManagerSourceSelection, GitRepositorySelection, GitWorktreeState, RepositoryStructure,
77    git_administrative_identity, git_worktree_lifecycle_matches,
78};
79use projectatlas_fs::{
80    FsError, RootScanPolicy, ScanLimits, ScanOptions, gitignore_excludes_path,
81    scan_path_with_policy_controlled, scan_repo, scan_repo_controlled,
82    scan_repo_controlled_with_work,
83};
84use projectatlas_service::{
85    ClassifiedRankedNode, CoverageDiscoveryReport, FederatedInputWork, FederatedStore,
86    FilePathMatcher, MAX_FEDERATED_DATABASE_BYTES, MAX_FEDERATED_INPUT_BYTES, NextStepReport,
87    TokenReport, TokenReportRequest,
88    build_next_report_with_selection as build_next_report_with_selection_service,
89    load_agent_efficiency_comparison,
90    load_classified_ranked_file_nodes_with_reasons as load_classified_ranked_file_nodes_with_reasons_service,
91    load_ranked_file_nodes_with_reasons, load_ranked_folder_nodes_with_reasons,
92    validate_federated_root_count,
93};
94use projectatlas_symbols::{
95    DocumentExtractionError, DocumentLimit, MAX_DOCUMENT_COMPRESSED_BYTES, MarkdownFacts,
96    document_format_for_path, extract_document_symbol_facts_controlled,
97    extract_document_text_controlled, extract_markdown_facts_controlled,
98    extract_symbol_graph_with_source_controlled, semantic_resolution_contract_digest,
99};
100use rayon::ThreadPoolBuilder;
101use rayon::prelude::*;
102use serde::{Deserialize, Serialize};
103use serde_json::{Value, json};
104use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
105use std::fmt;
106use std::fmt::Write as _;
107use std::fs;
108use std::io::{self, Read};
109use std::path::{Path, PathBuf};
110use std::sync::Arc;
111use std::sync::atomic::{AtomicBool, Ordering};
112use std::sync::mpsc::{self, RecvTimeoutError, TrySendError};
113use std::thread;
114use std::time::{Duration, Instant};
115
116/// Maximum file size parsed for symbols by default.
117pub(crate) const MAX_SYMBOL_FILE_BYTES: u64 = 2_000_000;
118/// Default health rows returned when the caller does not request a page size.
119pub(crate) const DEFAULT_HEALTH_LIMIT: usize = 50;
120/// Maximum health rows returned in one payload.
121pub(crate) const MAX_HEALTH_LIMIT: usize = 200;
122/// Maximum JSON bytes read for one CLI purpose-review batch.
123pub(crate) const MAX_PURPOSE_REVIEW_INPUT_FILE_BYTES: u64 = 2 * 1_024 * 1_024;
124/// Stable serialized recommendation for host-owned purpose curator selection.
125pub(crate) const PURPOSE_CURATOR_RECOMMENDED_REASONING: &str = "lowest_reliable_host_supported";
126/// Default whole-operation deadline when no narrower parser limit is supplied.
127const DEFAULT_INDEX_WORK_TIMEOUT: Duration = Duration::from_mins(30);
128/// Maximum UTF-8 source bytes retained while one publication is staged.
129const MAX_STAGED_TEXT_BYTES: u64 = 512 * 1024 * 1024;
130/// Maximum aggregate retained string bytes across one in-memory publication batch.
131const MAX_PUBLICATION_STAGING_BYTES: u64 = 2 * 1024 * 1024 * 1024;
132/// Maximum scan-node mutations applied between publication cancellation checks.
133const PUBLICATION_NODE_BATCH_SIZE: usize = 1_024;
134/// Maximum deleted repository paths applied between publication cancellation checks.
135const PUBLICATION_PATH_BATCH_SIZE: usize = 128;
136/// Maximum persisted source texts applied between publication cancellation checks.
137const PUBLICATION_TEXT_BATCH_SIZE: usize = 32;
138/// Maximum symbol parse results retained before sequential persistence.
139#[cfg(not(feature = "optional-parser-supervisor"))]
140const SYMBOL_PARSE_BATCH_SIZE: usize = 64;
141/// Maximum symbol candidates accepted by one publication.
142const MAX_SYMBOL_PARSE_JOBS: usize = 100_000;
143/// Maximum event paths accepted by one incremental watcher publication.
144const MAX_INCREMENTAL_CHANGED_PATHS: usize = 100_000;
145/// Maximum source bytes accepted by one incremental watcher publication.
146const MAX_INCREMENTAL_SOURCE_BYTES: u64 = 1024 * 1024 * 1024;
147/// Maximum native watcher events buffered before continuity becomes uncertain.
148const WATCH_EVENT_QUEUE_CAPACITY: usize = 1_024;
149/// Maximum indexing workers regardless of a larger caller request.
150pub(crate) const INDEX_WORKER_SAFE_CEILING: usize = 32;
151/// Chunk size used by cancellation-aware bounded source reads.
152const CONTROLLED_SOURCE_READ_BUFFER_BYTES: usize = 8_192;
153/// Maximum aggregate authored-purpose bytes inspected by one publication.
154const MAX_PURPOSE_IMPORT_BYTES: u64 = 512 * 1_024 * 1_024;
155/// Maximum complete config, map, or non-source purpose input size.
156const MAX_PURPOSE_INPUT_FILE_BYTES: u64 = 16 * 1_024 * 1_024;
157/// Maximum bytes in one repository path supplied to purpose review.
158const MAX_PURPOSE_REVIEW_PATH_BYTES: usize = 4 * 1_024;
159/// Maximum bytes in one non-path purpose-review string field.
160const MAX_PURPOSE_REVIEW_FIELD_BYTES: usize = 64 * 1_024;
161/// Purpose-review report field name for an item error.
162const PURPOSE_REVIEW_REPORT_ERROR_FIELD: &str = "error";
163/// Maximum aggregate string bytes admitted to one purpose-review batch.
164const MAX_PURPOSE_REVIEW_INPUT_BYTES: usize = 512 * 1_024;
165/// Maximum retained item/output bytes for one purpose-review report.
166const MAX_PURPOSE_REVIEW_REPORT_BYTES: usize = 4 * 1_024 * 1_024;
167/// Maximum source prefix inspected for a legacy purpose header.
168const MAX_PURPOSE_HEADER_BYTES: u64 = 256 * 1_024;
169/// Maximum normalized legacy purpose rows admitted by one publication.
170const MAX_PURPOSE_IMPORT_RECORDS: u64 = 1_000_000;
171
172/// Built-in purposes for reserved project-local `ProjectAtlas` metadata inputs.
173const BUILTIN_PROJECTATLAS_PURPOSES: &[(&str, &str)] = &[
174    (
175        ".projectatlas",
176        "Store project-local ProjectAtlas metadata, configuration, and runtime state.",
177    ),
178    (
179        ".projectatlas/config.toml",
180        "Configure project-local ProjectAtlas scan, lint, purpose, and output policy.",
181    ),
182    (
183        ".projectatlas/projectatlas-nonsource-files.toon",
184        "Declare project-local non-source file purposes for ProjectAtlas map compatibility.",
185    ),
186    (
187        ".projectatlas/projectatlas-purpose-review.json",
188        "Replay agent-reviewed ProjectAtlas purpose records into the local SQLite index.",
189    ),
190];
191/// Core project-local files whose edits can change source-selection policy.
192const CORE_INDEX_POLICY_PATHS: &[&str] = &[".projectatlas/config.toml", "projectatlas.toml"];
193
194/// Resolved scan runtime policy shared by CLI and MCP adapters.
195pub(crate) struct ScanRuntimePlan {
196    /// Canonical project root.
197    pub(crate) root: PathBuf,
198    /// Optional `ProjectAtlas` config discovered for the root.
199    pub(crate) config: Option<atlas_map::AtlasMapConfig>,
200    /// Exact config file selected for this plan, if one exists.
201    selected_config_path: Option<PathBuf>,
202    /// Explicit config selector supplied by the caller, if any.
203    config_path_override: Option<PathBuf>,
204    /// Filesystem scanner options derived from config.
205    pub(crate) scan_options: ScanOptions,
206    /// `SQLite` text-index options derived from config and command override.
207    pub(crate) text_options: TextIndexOptions,
208    /// Explicit text-index limit supplied by the caller, if any.
209    text_index_max_bytes_override: Option<u64>,
210    /// Content-free optional parser selection bound into derived publication identity.
211    #[cfg(feature = "optional-parser-supervisor")]
212    optional_parser_selection: OptionalParserPackProjectSelection,
213}
214
215/// Deterministic purpose-import rows and the inputs that produced them.
216struct PurposeImportSnapshot {
217    /// Normalized purpose records staged by a full scan.
218    records: Vec<atlas_map::ImportedPurposeRecord>,
219    /// Digest of selected configuration, external inputs, and normalized rows.
220    fingerprint: String,
221}
222
223/// Hard authored-purpose input limits for one publication attempt.
224#[derive(Clone, Copy)]
225struct PurposeImportLimits {
226    /// Aggregate bytes read across all purpose inputs.
227    total_bytes: u64,
228    /// Maximum bytes in a complete config, map, or non-source input.
229    complete_file_bytes: u64,
230    /// Maximum prefix bytes inspected from one source file.
231    header_bytes: u64,
232    /// Maximum normalized records admitted after parsing.
233    records: u64,
234}
235
236impl Default for PurposeImportLimits {
237    fn default() -> Self {
238        Self {
239            total_bytes: MAX_PURPOSE_IMPORT_BYTES,
240            complete_file_bytes: MAX_PURPOSE_INPUT_FILE_BYTES,
241            header_bytes: MAX_PURPOSE_HEADER_BYTES,
242            records: MAX_PURPOSE_IMPORT_RECORDS,
243        }
244    }
245}
246
247/// Operation-owned reader for authored purpose and publication inputs.
248struct PurposeInputReader<'a> {
249    /// Shared cancellation and deadline boundary for the publication.
250    control: &'a IndexWorkControl,
251    /// Byte and record limits for authored purpose inputs.
252    limits: PurposeImportLimits,
253    /// Inputs that must be consumed completely rather than as header prefixes.
254    complete_paths: BTreeSet<PathBuf>,
255    /// Configured legacy folder-purpose filename.
256    purpose_filename: String,
257    /// Exact digests retained only for complete publication-contract inputs.
258    complete_digests: BTreeMap<PathBuf, String>,
259}
260
261/// Maximum changed paths included in a freshness failure payload.
262const INDEX_FRESHNESS_SAMPLE_LIMIT: usize = 8;
263/// Maximum affected paths a normal read may reconcile before answering.
264const NORMAL_READ_REFRESH_MAX_PATHS: usize = 64;
265/// Maximum current source bytes a normal read may reconcile before answering.
266const NORMAL_READ_REFRESH_MAX_BYTES: u64 = 8 * 1024 * 1024;
267/// Maximum current source bytes one navigation read may allocate and inspect.
268const MAX_INDEXED_NAVIGATION_SOURCE_BYTES: u64 = 16 * 1024 * 1024;
269/// Explicit version of the built-in derived-index projection contract.
270const INDEX_DERIVATION_CONTRACT_VERSION: &str = "3";
271
272/// Closed state returned when an index-backed read cannot proceed safely.
273#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
274#[serde(rename_all = "snake_case")]
275pub(crate) enum IndexReadStatus {
276    /// The selected project has not been initialized.
277    InitRequired,
278    /// A bare/common Git directory was selected instead of a source worktree.
279    WorktreeRequired,
280    /// Current saved local source differs from the durable index.
281    RefreshRequired,
282    /// Current saved local source could not be inspected completely.
283    VerificationIncomplete,
284    /// The opened index belongs to a different project root.
285    ProjectMismatch,
286}
287
288/// Closed reason for refusing an index-backed read.
289#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
290#[serde(rename_all = "snake_case")]
291pub(crate) enum IndexRefreshReason {
292    /// Existing indexed source bytes or structural metadata changed.
293    SourceChanged,
294    /// Paths were added, removed, renamed, ignored, or unignored.
295    PathsChanged,
296    /// Parser, source-selection, or indexing policy drifted.
297    PolicyDrift,
298    /// Dependency-aware incremental refresh exceeded its aggregate safe closure.
299    DependencyClosureLimit,
300}
301
302/// Scope required to recover a stale index safely.
303#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
304#[serde(rename_all = "snake_case")]
305pub(crate) enum IndexRefreshScope {
306    /// A bounded affected-path publication can restore current results.
307    Incremental,
308    /// Current publication safety requires a complete one-shot refresh.
309    Full,
310}
311
312/// Current local-source delta detected before a normal indexed read.
313struct IndexFreshnessDelta {
314    /// Typed public report when the delta cannot be reconciled automatically.
315    report: IndexRefreshRequired,
316    /// Complete native path set used for a safe affected-path publication.
317    paths: HashSet<PathBuf>,
318}
319
320/// Measured work performed by one exact source-freshness verification.
321#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
322pub(crate) struct SourceVerificationWork {
323    /// Repository entries inspected by the exact filesystem scan.
324    pub(crate) filesystem_entries: u64,
325    /// Current source bytes hashed by the exact filesystem scan.
326    pub(crate) filesystem_bytes: u64,
327    /// `SQLite` read statements owned directly by freshness verification.
328    pub(crate) sqlite_read_statements: u64,
329    /// Indexed nodes decoded for exact current-versus-durable comparison.
330    pub(crate) decoded_nodes: u64,
331}
332
333/// Exact freshness assessment plus its measured source/database work.
334struct IndexFreshnessAssessment {
335    /// Complete source delta, when current source differs from the index.
336    delta: Option<IndexFreshnessDelta>,
337    /// Work consumed to establish the assessment.
338    work: SourceVerificationWork,
339}
340
341/// Current read snapshot established through exact source verification.
342pub(crate) struct ExactFreshIndexRead {
343    /// Root-bound complete `SQLite` read snapshot.
344    pub(crate) store: AtlasStore,
345    /// Work consumed before this snapshot could be called current.
346    pub(crate) work: SourceVerificationWork,
347}
348
349/// Closed reason why current local source could not be verified.
350#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
351#[serde(rename_all = "snake_case")]
352pub(crate) enum IndexVerificationReason {
353    /// Current scan or ignore policy could not be loaded safely.
354    PolicyUnavailable,
355    /// A root or source path could not be inspected completely.
356    SourceInspectionFailed,
357    /// The selected source exceeds the bounded navigation-read ceiling.
358    SourceTooLarge,
359    /// The opened index does not contain a usable project identity.
360    ProjectIdentityUnavailable,
361    /// A prior multi-projection publication did not complete.
362    PublicationIncomplete,
363    /// The completed index used a different parser or scan-policy contract.
364    PublicationContractMismatch,
365}
366
367/// Typed first-use handoff for one selected project root.
368#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
369pub(crate) struct IndexInitRequired {
370    /// Canonical selected project root that needs initialization.
371    pub(crate) project_root: Option<String>,
372    /// Project-local durable index path that initialization will create.
373    pub(crate) database: Option<String>,
374    /// Registered MCP alias when the selected root came from worktree routing.
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub(crate) worktree: Option<String>,
377    /// Stable first-use state for adapters.
378    pub(crate) status: IndexReadStatus,
379}
380
381/// Typed refusal when a bare/common Git directory is selected as source.
382#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
383pub(crate) struct ProjectWorktreeRequired {
384    /// Canonical bare/common Git directory that was selected.
385    pub(crate) project_root: Option<String>,
386    /// Stable source-selection state for adapters.
387    pub(crate) status: IndexReadStatus,
388}
389
390/// Bounded typed report returned before a stale indexed read can execute.
391#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
392pub(crate) struct IndexRefreshRequired {
393    /// Canonical selected project root for a reusable recovery call.
394    pub(crate) project_root: 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 freshness state for adapters.
399    pub(crate) status: IndexReadStatus,
400    /// Why current saved source differs from the index.
401    pub(crate) reason: IndexRefreshReason,
402    /// Safe recovery scope.
403    pub(crate) scope: IndexRefreshScope,
404    /// Total added, removed, or modified paths.
405    pub(crate) changed: usize,
406    /// Newly visible paths.
407    pub(crate) added: usize,
408    /// Paths no longer visible under current source and ignore policy.
409    pub(crate) removed: usize,
410    /// Existing paths whose source or structural identity changed.
411    pub(crate) modified: usize,
412    /// Deterministic bounded path sample for agent recovery.
413    pub(crate) sample_paths: Vec<String>,
414}
415
416/// Bounded typed report returned when source verification is incomplete.
417#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
418pub(crate) struct IndexVerificationIncomplete {
419    /// Selected project root whose current source could not be verified.
420    pub(crate) project_root: Option<String>,
421    /// Registered alias when one selected this project.
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub(crate) worktree: Option<String>,
424    /// Stable verification state for adapters.
425    pub(crate) status: IndexReadStatus,
426    /// Why the verification could not complete.
427    pub(crate) reason: IndexVerificationReason,
428    /// Safe recovery scope once the underlying problem is resolved.
429    pub(crate) scope: IndexRefreshScope,
430    /// Bounded diagnostic from the failed policy or source inspection.
431    pub(crate) message: String,
432}
433
434/// Typed refusal when a selected project root and durable index disagree.
435#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
436pub(crate) struct IndexProjectMismatch {
437    /// Stable project binding state for adapters.
438    pub(crate) status: IndexReadStatus,
439    /// Registered alias when one selected this project.
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub(crate) worktree: Option<String>,
442    /// Lossless UTF-8 display of the canonical root selected for this read.
443    ///
444    /// `None` means that the native identity is valid but has no UTF-8 display
445    /// projection. Adapters must not use this field as identity authority.
446    pub(crate) selected_project_root: Option<String>,
447    /// Lossless UTF-8 display of the canonical root recorded by the opened index.
448    ///
449    /// `None` means that the native identity is valid but has no UTF-8 display
450    /// projection. Adapters must not use this field as identity authority.
451    pub(crate) indexed_project_root: Option<String>,
452    /// Terminal-only diagnostic retained when a lower layer supplied lossy text.
453    #[serde(skip)]
454    diagnostic: Option<String>,
455}
456
457impl IndexProjectMismatch {
458    /// Build adapter fields from native identities without lossy conversion.
459    pub(crate) fn from_native_roots(
460        selected: &CanonicalProjectRoot,
461        indexed: &CanonicalProjectRoot,
462    ) -> Self {
463        Self {
464            status: IndexReadStatus::ProjectMismatch,
465            worktree: None,
466            selected_project_root: selected.display_string().ok(),
467            indexed_project_root: indexed.display_string().ok(),
468            diagnostic: None,
469        }
470    }
471}
472
473impl fmt::Display for IndexRefreshRequired {
474    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
475        let recovery = self.worktree.as_ref().map_or_else(
476            || "run `projectatlas watch --once` or `atlas_watch_once` before retrying".to_string(),
477            |alias| format!("call `atlas_watch_once` with `worktree: {alias}` before retrying"),
478        );
479        if self.reason == IndexRefreshReason::PolicyDrift {
480            return write!(
481                formatter,
482                "refresh_required: derived index policy differs from the current project configuration; {recovery}"
483            );
484        }
485        if self.reason == IndexRefreshReason::DependencyClosureLimit {
486            let recovery = self.worktree.as_ref().map_or_else(
487                || "run a complete `projectatlas scan` or `atlas_scan` before retrying".to_string(),
488                |alias| format!("call `atlas_scan` with `worktree: {alias}` before retrying"),
489            );
490            return write!(
491                formatter,
492                "refresh_required: the dependency-aware incremental closure exceeded its safe limit; {recovery}"
493            );
494        }
495        write!(
496            formatter,
497            "refresh_required: {} indexed path(s) differ from current local source; {recovery}",
498            self.changed,
499        )
500    }
501}
502
503impl fmt::Display for IndexInitRequired {
504    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
505        if let Some(alias) = self.worktree.as_ref() {
506            return write!(
507                formatter,
508                "init_required: ProjectAtlas index '{}' is missing for registered worktree '{}'; call `atlas_init` with `worktree: {alias}`",
509                self.database
510                    .as_deref()
511                    .unwrap_or("<native display unavailable>"),
512                alias,
513            );
514        }
515        write!(
516            formatter,
517            "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`",
518            self.database
519                .as_deref()
520                .unwrap_or("<native display unavailable>"),
521            self.project_root
522                .as_deref()
523                .unwrap_or("<native display unavailable>")
524        )
525    }
526}
527
528impl fmt::Display for ProjectWorktreeRequired {
529    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
530        write!(
531            formatter,
532            "worktree_required: '{}' is a bare/common Git directory without checked-out source; select a checked-out worktree and initialize that exact root",
533            self.project_root
534                .as_deref()
535                .unwrap_or("<native display unavailable>")
536        )
537    }
538}
539
540impl fmt::Display for IndexVerificationIncomplete {
541    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
542        write!(
543            formatter,
544            "verification_incomplete: current local source could not be verified safely: {}",
545            self.message
546        )
547    }
548}
549
550impl fmt::Display for IndexProjectMismatch {
551    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
552        if let Some(diagnostic) = self.diagnostic.as_deref() {
553            return formatter.write_str(diagnostic);
554        }
555        write!(
556            formatter,
557            "project_mismatch: selected project root '{}' does not match index root '{}'",
558            self.selected_project_root
559                .as_deref()
560                .unwrap_or("<native display unavailable>"),
561            self.indexed_project_root
562                .as_deref()
563                .unwrap_or("<native display unavailable>"),
564        )
565    }
566}
567
568/// Verify current saved local source against the durable index in focused tests.
569#[cfg(test)]
570fn verify_index_freshness(
571    store: &AtlasStore,
572    root: &Path,
573    config_path: Option<&Path>,
574) -> Result<(), CliError> {
575    let plan = ScanRuntimePlan::for_path(config_path, root, None).map_err(|source| {
576        verification_incomplete(root, IndexVerificationReason::PolicyUnavailable, &source)
577    })?;
578    match detect_index_freshness(store, &plan)? {
579        Some(delta) => Err(CliError::RefreshRequired(Box::new(delta.report))),
580        None => Ok(()),
581    }
582}
583
584/// Open a current read snapshot, reconciling one safe bounded delta when possible.
585pub(crate) fn open_fresh_atlas_store_for_project(
586    db_path: &Path,
587    root: &Path,
588    config_path: Option<&Path>,
589) -> Result<AtlasStore, CliError> {
590    let control = standalone_index_work_control();
591    open_fresh_atlas_store_for_project_controlled(db_path, root, config_path, &control)
592}
593
594/// Open a current read snapshot under one cooperative freshness boundary.
595pub(crate) fn open_fresh_atlas_store_for_project_controlled(
596    db_path: &Path,
597    root: &Path,
598    config_path: Option<&Path>,
599    control: &IndexWorkControl,
600) -> Result<AtlasStore, CliError> {
601    Ok(
602        open_exact_fresh_atlas_store_for_project_controlled(db_path, root, config_path, control)?
603            .store,
604    )
605}
606
607/// Open a current snapshot and retain exact freshness work for epoch accounting.
608pub(crate) fn open_exact_fresh_atlas_store_for_project_controlled(
609    db_path: &Path,
610    root: &Path,
611    config_path: Option<&Path>,
612    control: &IndexWorkControl,
613) -> Result<ExactFreshIndexRead, CliError> {
614    open_exact_fresh_atlas_store_for_project_with_repair(
615        db_path,
616        root,
617        config_path,
618        control,
619        true,
620        ScanLimits::default(),
621    )
622}
623
624/// Verify exact saved source against the index without publishing a repair.
625pub(crate) fn verify_saved_source_matches_index_controlled(
626    db_path: &Path,
627    root: &Path,
628    config_path: Option<&Path>,
629    control: &IndexWorkControl,
630) -> Result<(), CliError> {
631    let exact =
632        open_exact_saved_source_matches_index_controlled(db_path, root, config_path, control)?;
633    exact.store.finish_index_read_snapshot()?;
634    Ok(())
635}
636
637/// Open an exact current snapshot without publishing a source repair.
638pub(crate) fn open_exact_saved_source_matches_index_controlled(
639    db_path: &Path,
640    root: &Path,
641    config_path: Option<&Path>,
642    control: &IndexWorkControl,
643) -> Result<ExactFreshIndexRead, CliError> {
644    open_exact_fresh_atlas_store_for_project_with_repair(
645        db_path,
646        root,
647        config_path,
648        control,
649        false,
650        ScanLimits::default(),
651    )
652}
653
654/// Open a current read snapshot without repairing stale source or durable state.
655fn open_exact_fresh_atlas_store_for_project_with_repair(
656    db_path: &Path,
657    root: &Path,
658    config_path: Option<&Path>,
659    control: &IndexWorkControl,
660    repair_safe_delta: bool,
661    scan_limits: ScanLimits,
662) -> Result<ExactFreshIndexRead, CliError> {
663    let bounded_control = bounded_index_work_control(control);
664    let control = &bounded_control;
665    let store = open_atlas_store_read_only_for_project(db_path, root)?;
666    let plan = ScanRuntimePlan::for_path_controlled(config_path, root, None, control)
667        .map_err(|source| publication_input_error(root, source))?;
668    let assessment = match detect_index_freshness_controlled(&store, &plan, scan_limits, control) {
669        Ok(assessment) => assessment,
670        Err(CliError::VerificationIncomplete(report))
671            if report.reason == IndexVerificationReason::PublicationContractMismatch =>
672        {
673            return Err(CliError::RefreshRequired(Box::new(
674                index_policy_refresh_required(&plan.root),
675            )));
676        }
677        Err(error) => return Err(error),
678    };
679    let mut work = assessment.work;
680    let Some(delta) = assessment.delta else {
681        return Ok(ExactFreshIndexRead { store, work });
682    };
683    if !repair_safe_delta {
684        return Err(CliError::RefreshRequired(Box::new(delta.report)));
685    }
686    if delta.report.scope != IndexRefreshScope::Incremental {
687        return Err(CliError::RefreshRequired(Box::new(delta.report)));
688    }
689
690    let refresh_required = delta.report.clone();
691    drop(store);
692    let repair = (|| {
693        let mut writer = open_atlas_store_for_project(db_path, &plan.root)?;
694        let changes = WatchChangeSet {
695            requires_full_scan: false,
696            document_paths: delta.paths.clone(),
697            paths: delta.paths,
698        };
699        refresh_index_for_changes_controlled(
700            &mut writer,
701            &plan,
702            &changes,
703            &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
704            control,
705        )
706    })();
707    if let Err(error) = repair {
708        if automatic_refresh_write_is_unavailable(&error) {
709            return Err(CliError::RefreshRequired(Box::new(refresh_required)));
710        }
711        return Err(error);
712    }
713
714    let store = open_atlas_store_read_only_for_project(db_path, &plan.root)?;
715    verify_index_project_root(&store, &plan.root)?;
716    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
717    verify_index_publication(&store, &plan)?;
718    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
719    Ok(ExactFreshIndexRead { store, work })
720}
721
722/// Open an explicit ordered set of current project indexes without mutating any root.
723pub(crate) fn open_federated_atlas_stores_for_project(
724    selected_db: &Path,
725    selected_root: &Path,
726    selected_config: Option<&Path>,
727    roots: &[PathBuf],
728    worktrees: Option<&[String]>,
729    control: &IndexWorkControl,
730) -> Result<Vec<FederatedStore>, CliError> {
731    validate_federated_root_count(roots.len()).map_err(CliError::Service)?;
732    if worktrees.is_some_and(|worktrees| worktrees.len() != roots.len()) {
733        return Err(CliError::Service(
734            projectatlas_service::ServiceError::InvalidInput(
735                "federated worktree labels must match the ordered root count".to_string(),
736            ),
737        ));
738    }
739    let selected_root = fs::canonicalize(selected_root).map_err(|source| CliError::Io {
740        path: selected_root.to_path_buf(),
741        source,
742    })?;
743    let mut canonical_roots = Vec::with_capacity(roots.len());
744    for root in roots {
745        let root = fs::canonicalize(root).map_err(|source| CliError::Io {
746            path: root.clone(),
747            source,
748        })?;
749        if canonical_roots.contains(&root) {
750            return Err(CliError::Service(
751                projectatlas_service::ServiceError::InvalidInput(
752                    "federated roots must be unique".to_string(),
753                ),
754            ));
755        }
756        canonical_roots.push(root);
757    }
758    if canonical_roots.first() != Some(&selected_root) {
759        return Err(CliError::Service(
760            projectatlas_service::ServiceError::InvalidInput(
761                "the first federated root must be the selected project root".to_string(),
762            ),
763        ));
764    }
765
766    let databases = canonical_roots
767        .iter()
768        .enumerate()
769        .map(|(order, root)| {
770            if order == 0 {
771                selected_db.to_path_buf()
772            } else {
773                root.join(".projectatlas").join("projectatlas.db")
774            }
775        })
776        .collect::<Vec<_>>();
777    let mut database_bytes = 0_u64;
778    for database in &databases {
779        let metadata = fs::metadata(database).map_err(|source| CliError::Io {
780            path: database.clone(),
781            source,
782        })?;
783        if !metadata.is_file() {
784            return Err(CliError::Service(
785                projectatlas_service::ServiceError::InvalidInput(
786                    "federated database path is not a regular file".to_string(),
787                ),
788            ));
789        }
790        database_bytes = database_bytes.checked_add(metadata.len()).ok_or_else(|| {
791            CliError::Service(projectatlas_service::ServiceError::InvalidInput(
792                "participating database byte count overflowed".to_string(),
793            ))
794        })?;
795        if database_bytes > MAX_FEDERATED_DATABASE_BYTES {
796            return Err(CliError::Service(
797                projectatlas_service::ServiceError::InvalidInput(format!(
798                    "participating databases exceed {MAX_FEDERATED_DATABASE_BYTES} bytes"
799                )),
800            ));
801        }
802    }
803
804    let default_scan_limits = ScanLimits::default();
805    let mut remaining_input_bytes = MAX_FEDERATED_INPUT_BYTES;
806    let mut stores: Vec<FederatedStore> = Vec::with_capacity(canonical_roots.len());
807    for (order, (root, database)) in canonical_roots.into_iter().zip(databases).enumerate() {
808        let started = Instant::now();
809        let config = (order == 0).then_some(selected_config).flatten();
810        let exact = match open_exact_fresh_atlas_store_for_project_with_repair(
811            &database,
812            &root,
813            config,
814            control,
815            false,
816            ScanLimits::new(
817                default_scan_limits.max_entries(),
818                remaining_input_bytes,
819                default_scan_limits.max_workers(),
820            ),
821        ) {
822            Ok(exact) => exact,
823            Err(error) => {
824                for store in stores {
825                    drop(store.finish());
826                }
827                let error =
828                    if let Some(worktree) = worktrees.and_then(|worktrees| worktrees.get(order)) {
829                        federated_worktree_error(error, worktree)
830                    } else {
831                        error
832                    };
833                return Err(error);
834            }
835        };
836        remaining_input_bytes = remaining_input_bytes.saturating_sub(exact.work.filesystem_bytes);
837        let input_work = FederatedInputWork {
838            filesystem_entries: exact.work.filesystem_entries,
839            filesystem_bytes: exact.work.filesystem_bytes,
840            sqlite_read_statements: exact.work.sqlite_read_statements,
841            decoded_nodes: exact.work.decoded_nodes,
842            elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
843        };
844        let worktree = worktrees.map(|worktrees| worktrees[order].clone());
845        match FederatedStore::new_with_worktree(exact.store, database, root, input_work, worktree) {
846            Ok(store) => stores.push(store),
847            Err(error) => {
848                for store in stores {
849                    drop(store.finish());
850                }
851                return Err(CliError::Service(error));
852            }
853        }
854    }
855    Ok(stores)
856}
857
858/// Synchronize active registrations and read the aggregate before catalog writers resume.
859fn with_synchronized_registered_worktree_usage<T>(
860    control_db: &Path,
861    control_root: &Path,
862    expected_control_project: Option<ProjectInstanceId>,
863    read: impl FnOnce(&AtlasStore) -> DbResult<T>,
864) -> Result<T, CliError> {
865    synchronize_registered_worktree_usage_with_catalog_validation(
866        control_db,
867        control_root,
868        expected_control_project,
869        |_, _| Ok(()),
870        || Ok(()),
871        read,
872    )
873}
874
875/// Load one combined repository report while active worktree membership is stable.
876pub(crate) fn load_synchronized_repository_token_report(
877    control_db: &Path,
878    control_root: &Path,
879    expected_control_project: Option<ProjectInstanceId>,
880    request: TokenReportRequest<'_>,
881) -> Result<TokenReport, CliError> {
882    let (benchmark_results, trend_window) = match request {
883        TokenReportRequest::RepositoryOverview { benchmark_results } => (benchmark_results, None),
884        TokenReportRequest::RepositoryTrends { window } => (None, Some(window)),
885        TokenReportRequest::Overview { .. } | TokenReportRequest::Trends { .. } => {
886            return Err(CliError::InvalidInput(
887                "synchronized token reports require repository scope".to_string(),
888            ));
889        }
890    };
891    let mut report = with_synchronized_registered_worktree_usage(
892        control_db,
893        control_root,
894        expected_control_project,
895        |control| match trend_window {
896            None => Ok(TokenReport::Overview(Box::new(
897                control.repository_token_overview()?,
898            ))),
899            Some(window) => Ok(TokenReport::Trends(
900                control.repository_token_trends(window)?,
901            )),
902        },
903    )?;
904    if let (Some(benchmark_results), TokenReport::Overview(overview)) =
905        (benchmark_results, &mut report)
906    {
907        let control = open_atlas_store_read_only_for_project(control_db, control_root)?;
908        overview.set_agent_efficiency(load_agent_efficiency_comparison(
909            &control,
910            Some(benchmark_results),
911        )?);
912    }
913    Ok(report)
914}
915
916/// Synchronize active registrations without a following repository aggregate read.
917#[cfg(test)]
918pub(crate) fn synchronize_registered_worktree_usage(
919    control_db: &Path,
920    control_root: &Path,
921    expected_control_project: Option<ProjectInstanceId>,
922) -> Result<(), CliError> {
923    with_synchronized_registered_worktree_usage(
924        control_db,
925        control_root,
926        expected_control_project,
927        |_| Ok(()),
928    )
929}
930
931/// Synchronize active registrations with one injectable final catalog boundary.
932fn synchronize_registered_worktree_usage_with_catalog_validation<T>(
933    control_db: &Path,
934    control_root: &Path,
935    expected_control_project: Option<ProjectInstanceId>,
936    mut before_snapshot_revalidation: impl FnMut(&WorktreeRegistration, &Path) -> Result<(), CliError>,
937    before_catalog_validation: impl FnOnce() -> Result<(), CliError>,
938    read: impl FnOnce(&AtlasStore) -> DbResult<T>,
939) -> Result<T, CliError> {
940    let control_reader = open_atlas_store_read_only_for_project(control_db, control_root)?;
941    let control_project =
942        require_synchronization_control_identity(&control_reader, expected_control_project)?;
943    let mut registrations = control_reader.worktree_registrations(false)?;
944    drop(control_reader);
945    if registrations.is_empty() {
946        before_catalog_validation()?;
947        let control = open_atlas_store_for_project(control_db, control_root)?;
948        require_synchronization_control_identity(&control, Some(control_project))?;
949        return match control.with_matching_active_worktree_catalog(&registrations, || read(&control))?
950        {
951            Some(value) => Ok(value),
952            None => Err(CliError::InvalidInput(
953                    "registered worktree catalog changed during aggregate synchronization; retry the token report"
954                        .to_string(),
955                )),
956        };
957    }
958    let repository = match projectatlas_fs::worktree::discover_repository_structure(control_root)? {
959        RepositoryStructure::Git(repository) => repository,
960        RepositoryStructure::NonGit { selected_root } => {
961            return Err(CliError::InvalidInput(format!(
962                "registered worktree synchronization requires Git control evidence at '{}'",
963                normalize_native_path_display(selected_root)
964            )));
965        }
966        RepositoryStructure::InvalidGit {
967            selected_root,
968            issue,
969        } => {
970            return Err(CliError::InvalidInput(format!(
971                "registered worktree synchronization found invalid Git evidence for '{}': {:?} at '{}'",
972                normalize_native_path_display(selected_root),
973                issue.kind,
974                normalize_native_path_display(issue.path)
975            )));
976        }
977    };
978    let control = open_atlas_store_for_project(control_db, control_root)?;
979    require_synchronization_control_identity(&control, Some(control_project))?;
980    let common = CanonicalProjectRoot::from_path(&repository.common_directory)
981        .map_err(|source| CliError::InvalidInput(source.to_string()))?;
982    let active_roots = repository
983        .worktrees
984        .iter()
985        .filter_map(|entry| match &entry.state {
986            GitWorktreeState::Active { root, .. } => {
987                CanonicalProjectRoot::from_path(&entry.administrative_directory)
988                    .ok()
989                    .map(|administrative_directory| (administrative_directory, root.as_path()))
990            }
991            GitWorktreeState::Missing { .. } | GitWorktreeState::Invalid { .. } => None,
992        })
993        .collect::<HashMap<_, _>>();
994    for registration in &mut registrations {
995        let synchronization_incomplete = || {
996            CliError::InvalidInput(format!(
997                "registered worktree '{}': its bound local atlas is unavailable, so aggregate token totals cannot be synchronized",
998                registration.alias
999            ))
1000        };
1001        if registration.git_common_directory_identity != common {
1002            if registration.project_instance_id.is_some() {
1003                return Err(synchronization_incomplete());
1004            }
1005            continue;
1006        }
1007        let root = active_roots
1008            .get(&registration.git_administrative_directory_identity)
1009            .copied()
1010            .filter(|_| {
1011                git_administrative_identity(
1012                    registration.git_administrative_directory_identity.as_path(),
1013                )
1014                .is_ok_and(|identity| identity == registration.git_administrative_identity)
1015            });
1016        let Some(root) = root else {
1017            if registration.project_instance_id.is_some() {
1018                return Err(synchronization_incomplete());
1019            }
1020            continue;
1021        };
1022        let synchronized_project = control.with_active_worktree_registration(
1023            registration.registration_id,
1024            &registration.alias,
1025            |guard| {
1026                if let Err(error) =
1027                    require_registered_worktree_lifecycle(guard.registration(), root)
1028                {
1029                    return Ok(Err(error));
1030                }
1031                let database = root.join(".projectatlas").join("projectatlas.db");
1032                match fs::symlink_metadata(&database) {
1033                    Ok(_) => {}
1034                    Err(error) if error.kind() == io::ErrorKind::NotFound => {
1035                        return Ok(if guard.registration().project_instance_id.is_some() {
1036                            Err(synchronization_incomplete())
1037                        } else {
1038                            Ok(None)
1039                        });
1040                    }
1041                    Err(error) => return Ok(Err(error.into())),
1042                }
1043                let target = match open_atlas_store_read_only_for_project(&database, root) {
1044                    Ok(target) => target,
1045                    Err(error) => return Ok(Err(error)),
1046                };
1047                let snapshot = match target.export_worktree_usage_snapshot() {
1048                    Ok(snapshot) => snapshot,
1049                    Err(error) => return Ok(Err(error.into())),
1050                };
1051                drop(target);
1052                if let Err(error) = before_snapshot_revalidation(guard.registration(), root) {
1053                    return Ok(Err(error));
1054                }
1055                if let Err(error) =
1056                    require_registered_worktree_lifecycle(guard.registration(), root)
1057                {
1058                    return Ok(Err(error));
1059                }
1060                if let Err(error) =
1061                    require_current_worktree_usage_snapshot(&database, root, &snapshot)
1062                {
1063                    return Ok(Err(error));
1064                }
1065                let project = snapshot.project_instance_id();
1066                if guard.registration().project_instance_id.is_none() {
1067                    guard.bind_project_with_usage_snapshot(root, project, &snapshot)?;
1068                } else {
1069                    guard.synchronize_usage_snapshot(&snapshot)?;
1070                }
1071                Ok(Ok(Some(project)))
1072            },
1073        )??;
1074        if let Some(project) = synchronized_project {
1075            registration.project_instance_id = Some(project);
1076        }
1077    }
1078    before_catalog_validation()?;
1079    match control.with_matching_active_worktree_catalog(&registrations, || read(&control))? {
1080        Some(value) => Ok(value),
1081        None => Err(CliError::InvalidInput(
1082                "registered worktree catalog changed during aggregate synchronization; retry the token report"
1083                    .to_string(),
1084            )),
1085    }
1086}
1087
1088/// Require the atlas currently published at this path to match a captured snapshot exactly.
1089pub(crate) fn require_current_worktree_usage_snapshot(
1090    database: &Path,
1091    root: &Path,
1092    expected: &WorktreeUsageSnapshot,
1093) -> Result<(), CliError> {
1094    let current = open_atlas_store_read_only_for_project(database, root)?;
1095    if current.export_worktree_usage_snapshot()? != *expected {
1096        return Err(CliError::InvalidInput(
1097            "worktree atlas changed after its usage snapshot was captured; retry the operation"
1098                .to_string(),
1099        ));
1100    }
1101    Ok(())
1102}
1103
1104/// Rediscover one registration immediately before a lifecycle-sensitive catalog write.
1105pub(crate) fn require_registered_worktree_lifecycle(
1106    registration: &WorktreeRegistration,
1107    expected_root: &Path,
1108) -> Result<(), CliError> {
1109    let lifecycle_changed = || {
1110        CliError::InvalidInput(format!(
1111            "registered worktree '{}': its Git administrative lifecycle changed before aggregate token synchronization",
1112            registration.alias
1113        ))
1114    };
1115    if !git_worktree_lifecycle_matches(
1116        expected_root,
1117        registration.git_common_directory_identity.as_path(),
1118        registration.git_administrative_directory_identity.as_path(),
1119        &registration.git_administrative_identity,
1120    )? {
1121        return Err(lifecycle_changed());
1122    }
1123    Ok(())
1124}
1125
1126/// Keep synchronization on the control atlas captured by an alias-routed request.
1127fn require_synchronization_control_identity(
1128    control: &AtlasStore,
1129    expected: Option<ProjectInstanceId>,
1130) -> Result<ProjectInstanceId, CliError> {
1131    let observed = control.project_instance_id()?.ok_or_else(|| {
1132        CliError::InvalidInput(
1133            "control atlas has no project identity for registered worktree synchronization"
1134                .to_string(),
1135        )
1136    })?;
1137    if expected.is_some_and(|expected| expected != observed) {
1138        return Err(CliError::InvalidInput(
1139            "control atlas identity changed before registered worktree synchronization".to_string(),
1140        ));
1141    }
1142    Ok(observed)
1143}
1144
1145/// Attach the exact alias to every typed or generic federated participant failure.
1146pub(crate) fn federated_worktree_error(mut error: CliError, worktree: &str) -> CliError {
1147    match &mut error {
1148        CliError::InitRequired(report) => report.worktree = Some(worktree.to_string()),
1149        CliError::RefreshRequired(report) => report.worktree = Some(worktree.to_string()),
1150        CliError::VerificationIncomplete(report) => report.worktree = Some(worktree.to_string()),
1151        CliError::ProjectMismatch(report) => report.worktree = Some(worktree.to_string()),
1152        _ => {
1153            return CliError::InvalidInput(format!(
1154                "federated worktree {worktree:?} failed: {error}"
1155            ));
1156        }
1157    }
1158    error
1159}
1160
1161/// Distinguish optional repair contention from database-integrity failures.
1162fn automatic_refresh_write_is_unavailable(error: &CliError) -> bool {
1163    matches!(error, CliError::Db(source) if source.is_write_unavailable())
1164}
1165
1166/// Return the typed full-refresh state for a changed derivation contract.
1167fn index_policy_refresh_required(root: &Path) -> IndexRefreshRequired {
1168    IndexRefreshRequired {
1169        project_root: lossless_project_root_display(root),
1170        worktree: None,
1171        status: IndexReadStatus::RefreshRequired,
1172        reason: IndexRefreshReason::PolicyDrift,
1173        scope: IndexRefreshScope::Full,
1174        changed: 0,
1175        added: 0,
1176        removed: 0,
1177        modified: 0,
1178        sample_paths: Vec::new(),
1179    }
1180}
1181
1182/// Detect the complete current local-source delta for one selected index.
1183#[cfg(test)]
1184fn detect_index_freshness(
1185    store: &AtlasStore,
1186    plan: &ScanRuntimePlan,
1187) -> Result<Option<IndexFreshnessDelta>, CliError> {
1188    let control = standalone_index_work_control();
1189    Ok(detect_index_freshness_controlled(store, plan, ScanLimits::default(), &control)?.delta)
1190}
1191
1192/// Detect the complete local-source delta under one cooperative work boundary.
1193fn detect_index_freshness_controlled(
1194    store: &AtlasStore,
1195    plan: &ScanRuntimePlan,
1196    scan_limits: ScanLimits,
1197    control: &IndexWorkControl,
1198) -> Result<IndexFreshnessAssessment, CliError> {
1199    let mut work = SourceVerificationWork::default();
1200    verify_index_project_root(store, &plan.root)?;
1201    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1202    verify_index_publication(store, plan)?;
1203    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1204    let scan = scan_repo_controlled_with_work(&plan.root, &plan.scan_options, scan_limits, control)
1205        .map_err(|source| source_inspection_error(&plan.root, source))?;
1206    work.filesystem_entries = scan.work.entries;
1207    work.filesystem_bytes = scan.work.source_bytes;
1208    let current_nodes = scan.nodes;
1209    let indexed_nodes = store
1210        .load_nodes()?
1211        .into_iter()
1212        .map(|indexed| indexed.node)
1213        .collect::<Vec<_>>();
1214    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1215    work.decoded_nodes = u64::try_from(indexed_nodes.len()).unwrap_or(u64::MAX);
1216    Ok(IndexFreshnessAssessment {
1217        delta: source_node_delta(&plan.root, &current_nodes, &indexed_nodes),
1218        work,
1219    })
1220}
1221
1222/// Compare a current exact scan with the source-derived nodes being validated.
1223fn verify_source_nodes_match(
1224    root: &Path,
1225    current_nodes: &[Node],
1226    indexed_nodes: &[Node],
1227) -> Result<(), CliError> {
1228    match source_node_delta(root, current_nodes, indexed_nodes) {
1229        Some(delta) => Err(CliError::RefreshRequired(Box::new(delta.report))),
1230        None => Ok(()),
1231    }
1232}
1233
1234/// Build one deterministic affected-path plan from current and indexed nodes.
1235fn source_node_delta(
1236    root: &Path,
1237    current_nodes: &[Node],
1238    indexed_nodes: &[Node],
1239) -> Option<IndexFreshnessDelta> {
1240    let current_by_path = current_nodes
1241        .iter()
1242        .map(|node| (node.path.as_str(), node))
1243        .collect::<BTreeMap<_, _>>();
1244    let indexed_by_path = indexed_nodes
1245        .iter()
1246        .map(|node| (node.path.as_str(), node))
1247        .collect::<BTreeMap<_, _>>();
1248
1249    let added_paths = current_by_path
1250        .keys()
1251        .filter(|path| !indexed_by_path.contains_key(**path))
1252        .copied()
1253        .collect::<Vec<_>>();
1254    let removed_paths = indexed_by_path
1255        .keys()
1256        .filter(|path| !current_by_path.contains_key(**path))
1257        .copied()
1258        .collect::<Vec<_>>();
1259    let mut modified_paths = Vec::new();
1260    for (path, current) in &current_by_path {
1261        let Some(indexed) = indexed_by_path.get(path) else {
1262            continue;
1263        };
1264        if !same_indexed_source(current, indexed) {
1265            modified_paths.push(*path);
1266        }
1267    }
1268    let changed = added_paths
1269        .len()
1270        .saturating_add(removed_paths.len())
1271        .saturating_add(modified_paths.len());
1272    if changed == 0 {
1273        return None;
1274    }
1275
1276    let changed_paths = added_paths
1277        .iter()
1278        .chain(&removed_paths)
1279        .chain(&modified_paths)
1280        .copied()
1281        .collect::<BTreeSet<_>>()
1282        .into_iter()
1283        .collect::<Vec<_>>();
1284    let changed_bytes = added_paths
1285        .iter()
1286        .chain(&modified_paths)
1287        .filter_map(|path| current_by_path.get(path).and_then(|node| node.size_bytes))
1288        .fold(0_u64, u64::saturating_add);
1289    let requires_full_scan = changed > NORMAL_READ_REFRESH_MAX_PATHS
1290        || changed_bytes > NORMAL_READ_REFRESH_MAX_BYTES
1291        || changed_paths
1292            .iter()
1293            .any(|path| watch_path_requires_full_scan(root, &root.join(repo_path_to_native(path))));
1294    let sample_paths = changed_paths
1295        .iter()
1296        .take(INDEX_FRESHNESS_SAMPLE_LIMIT)
1297        .map(|path| (*path).to_string())
1298        .collect();
1299    Some(IndexFreshnessDelta {
1300        report: IndexRefreshRequired {
1301            project_root: lossless_project_root_display(root),
1302            worktree: None,
1303            status: IndexReadStatus::RefreshRequired,
1304            reason: if added_paths.is_empty() && removed_paths.is_empty() {
1305                IndexRefreshReason::SourceChanged
1306            } else {
1307                IndexRefreshReason::PathsChanged
1308            },
1309            scope: if requires_full_scan {
1310                IndexRefreshScope::Full
1311            } else {
1312                IndexRefreshScope::Incremental
1313            },
1314            changed,
1315            added: added_paths.len(),
1316            removed: removed_paths.len(),
1317            modified: modified_paths.len(),
1318            sample_paths,
1319        },
1320        paths: changed_paths
1321            .into_iter()
1322            .map(|path| root.join(repo_path_to_native(path)))
1323            .collect(),
1324    })
1325}
1326
1327/// Verify that the opened database belongs to the selected canonical root.
1328fn verify_index_project_root(store: &AtlasStore, selected_root: &Path) -> Result<(), CliError> {
1329    let Some(indexed_root) = store.project_root_identity()? else {
1330        return Err(verification_incomplete(
1331            selected_root,
1332            IndexVerificationReason::ProjectIdentityUnavailable,
1333            &CliError::InvalidInput("index project root metadata is missing".to_string()),
1334        ));
1335    };
1336    let selected_root = CanonicalProjectRoot::from_path(selected_root).map_err(|source| {
1337        verification_incomplete(
1338            selected_root,
1339            IndexVerificationReason::SourceInspectionFailed,
1340            &CliError::InvalidInput(source.to_string()),
1341        )
1342    })?;
1343    if !store.project_root_identity_matches(&selected_root) {
1344        return Err(CliError::ProjectMismatch(Box::new(
1345            IndexProjectMismatch::from_native_roots(&selected_root, &indexed_root),
1346        )));
1347    }
1348    Ok(())
1349}
1350
1351/// Reject mixed or runtime-incompatible derived projections before source reads.
1352fn verify_index_publication(store: &AtlasStore, plan: &ScanRuntimePlan) -> Result<(), CliError> {
1353    let expected_fingerprint = plan.publication_contract_fingerprint();
1354    let Some(publication) = store.index_publication()? else {
1355        return Err(verification_incomplete(
1356            &plan.root,
1357            IndexVerificationReason::PublicationIncomplete,
1358            &CliError::InvalidInput(
1359                "derived index publication state is missing; run one refresh".to_string(),
1360            ),
1361        ));
1362    };
1363    if publication.state == IndexPublicationState::Updating {
1364        return Err(verification_incomplete(
1365            &plan.root,
1366            IndexVerificationReason::PublicationIncomplete,
1367            &CliError::InvalidInput(
1368                "a prior derived index publication did not complete; run one refresh".to_string(),
1369            ),
1370        ));
1371    }
1372    if publication.generation == projectatlas_core::IndexGeneration::ZERO {
1373        return Err(verification_incomplete(
1374            &plan.root,
1375            IndexVerificationReason::PublicationIncomplete,
1376            &CliError::InvalidInput(
1377                "derived index has no complete publication generation; run one refresh".to_string(),
1378            ),
1379        ));
1380    }
1381    if publication.contract_fingerprint.as_deref() != Some(expected_fingerprint.as_str()) {
1382        return Err(verification_incomplete(
1383            &plan.root,
1384            IndexVerificationReason::PublicationContractMismatch,
1385            &CliError::InvalidInput(
1386                "derived index parser or scan-policy contract changed; run one refresh".to_string(),
1387            ),
1388        ));
1389    }
1390    Ok(())
1391}
1392
1393/// Return whether symbol reuse and incremental publication share the current derivation contract.
1394fn publication_contract_matches(
1395    store: &AtlasStore,
1396    plan: &ScanRuntimePlan,
1397) -> Result<bool, CliError> {
1398    let Some(publication) = store.index_publication()? else {
1399        return Ok(false);
1400    };
1401    Ok(publication.state == IndexPublicationState::Complete
1402        && publication.generation != IndexGeneration::ZERO
1403        && publication.contract_fingerprint.as_deref()
1404            == Some(plan.publication_contract_fingerprint().as_str()))
1405}
1406
1407/// Hash the parser registry and source-selection policy that own derived rows.
1408fn index_derivation_fingerprint(
1409    scan_options: &ScanOptions,
1410    text_options: TextIndexOptions,
1411    #[cfg(feature = "optional-parser-supervisor")]
1412    optional_parser_selection: &OptionalParserPackProjectSelection,
1413) -> String {
1414    index_derivation_fingerprint_for_contract(
1415        scan_options,
1416        text_options,
1417        #[cfg(feature = "optional-parser-supervisor")]
1418        optional_parser_selection,
1419        INDEX_DERIVATION_CONTRACT_VERSION,
1420        &semantic_resolution_contract_digest(),
1421    )
1422}
1423
1424/// Hash one exact parser, semantic, and source-selection contract.
1425fn index_derivation_fingerprint_for_contract(
1426    scan_options: &ScanOptions,
1427    text_options: TextIndexOptions,
1428    #[cfg(feature = "optional-parser-supervisor")]
1429    optional_parser_selection: &OptionalParserPackProjectSelection,
1430    contract_version: &str,
1431    semantic_resolution_digest: &str,
1432) -> String {
1433    let mut hasher = Hasher::new();
1434    hash_index_contract_value(&mut hasher, "contract_version", contract_version);
1435    hash_index_contract_value(
1436        &mut hasher,
1437        "language_registry_version",
1438        &LANGUAGE_CAPABILITY_REGISTRY_VERSION.to_string(),
1439    );
1440    hash_index_contract_value(
1441        &mut hasher,
1442        "accepted_language_set_version",
1443        &ACCEPTED_LANGUAGE_CAPABILITY_SET_VERSION.to_string(),
1444    );
1445    hash_index_contract_value(
1446        &mut hasher,
1447        "language_registry_digest",
1448        &language_registry_digest(),
1449    );
1450    hash_index_contract_value(
1451        &mut hasher,
1452        "accepted_language_set_digest",
1453        &accepted_language_capability_digest(),
1454    );
1455    hash_index_contract_value(
1456        &mut hasher,
1457        "semantic_resolution_contract_digest",
1458        semantic_resolution_digest,
1459    );
1460    for value in &scan_options.exclude_dir_names {
1461        hash_index_contract_value(&mut hasher, "exclude_dir_name", value);
1462    }
1463    for value in &scan_options.exclude_dir_suffixes {
1464        hash_index_contract_value(&mut hasher, "exclude_dir_suffix", value);
1465    }
1466    for value in &scan_options.exclude_path_prefixes {
1467        hash_index_contract_value(&mut hasher, "exclude_path_prefix", value);
1468    }
1469    for (selector, language) in &scan_options.language_overrides {
1470        hash_index_contract_value(&mut hasher, "language_override_selector", selector);
1471        hash_index_contract_value(&mut hasher, "language_override_target", language);
1472    }
1473    hash_index_contract_value(
1474        &mut hasher,
1475        "text_index_max_bytes",
1476        &text_options.max_bytes.to_string(),
1477    );
1478    #[cfg(feature = "optional-parser-supervisor")]
1479    hash_index_contract_value(
1480        &mut hasher,
1481        "optional_parser_selection",
1482        optional_parser_selection
1483            .selection_key()
1484            .map_or("inactive", |selection| selection.as_str()),
1485    );
1486    hasher.finalize().to_hex().to_string()
1487}
1488
1489/// Recheck current policy and source before making staged rows visible.
1490#[cfg(test)]
1491fn revalidate_index_publication_inputs_controlled(
1492    store: &AtlasStore,
1493    plan: &ScanRuntimePlan,
1494    expected_purpose_import_fingerprint: Option<&str>,
1495    control: &IndexWorkControl,
1496) -> Result<(), CliError> {
1497    revalidate_index_publication_inputs_controlled_with_limits(
1498        store,
1499        plan,
1500        expected_purpose_import_fingerprint,
1501        control,
1502        PurposeImportLimits::default(),
1503    )
1504}
1505
1506/// Recheck publication inputs under explicit purpose limits used by focused tests.
1507#[cfg(test)]
1508fn revalidate_index_publication_inputs_controlled_with_limits(
1509    store: &AtlasStore,
1510    plan: &ScanRuntimePlan,
1511    expected_purpose_import_fingerprint: Option<&str>,
1512    control: &IndexWorkControl,
1513    purpose_limits: PurposeImportLimits,
1514) -> Result<(), CliError> {
1515    let staged_nodes = store
1516        .load_nodes()?
1517        .into_iter()
1518        .map(|indexed| indexed.node)
1519        .collect::<Vec<_>>();
1520    revalidate_staged_publication_inputs_controlled_with_limits(
1521        plan,
1522        &staged_nodes,
1523        expected_purpose_import_fingerprint,
1524        None,
1525        control,
1526        purpose_limits,
1527    )
1528}
1529
1530/// Recheck policy and exact source against one off-writer publication batch.
1531fn revalidate_staged_publication_inputs_controlled(
1532    plan: &ScanRuntimePlan,
1533    staged_nodes: &[Node],
1534    expected_purpose_import_fingerprint: Option<&str>,
1535    control: &IndexWorkControl,
1536) -> Result<(), CliError> {
1537    revalidate_staged_publication_inputs_controlled_with_limits(
1538        plan,
1539        staged_nodes,
1540        expected_purpose_import_fingerprint,
1541        None,
1542        control,
1543        PurposeImportLimits::default(),
1544    )
1545}
1546
1547/// Recheck a full scan while reusing purpose rows from exact unchanged source nodes.
1548fn revalidate_staged_publication_inputs_with_purpose_snapshot(
1549    plan: &ScanRuntimePlan,
1550    staged_nodes: &[Node],
1551    purpose_import: Option<&PurposeImportSnapshot>,
1552    control: &IndexWorkControl,
1553) -> Result<(), CliError> {
1554    revalidate_staged_publication_inputs_controlled_with_limits(
1555        plan,
1556        staged_nodes,
1557        purpose_import.map(|snapshot| snapshot.fingerprint.as_str()),
1558        purpose_import.map(|snapshot| snapshot.records.as_slice()),
1559        control,
1560        PurposeImportLimits::default(),
1561    )
1562}
1563
1564/// Recheck a staged batch under explicit purpose-input limits used by tests.
1565fn revalidate_staged_publication_inputs_controlled_with_limits(
1566    plan: &ScanRuntimePlan,
1567    staged_nodes: &[Node],
1568    expected_purpose_import_fingerprint: Option<&str>,
1569    reusable_purpose_records: Option<&[atlas_map::ImportedPurposeRecord]>,
1570    control: &IndexWorkControl,
1571    purpose_limits: PurposeImportLimits,
1572) -> Result<(), CliError> {
1573    control.check(IndexWorkStage::Publication)?;
1574    let current_plan = plan
1575        .reload_controlled_with_limits(control, purpose_limits)
1576        .map_err(|source| publication_input_error(&plan.root, source))?;
1577    control.check(IndexWorkStage::Publication)?;
1578    let staged_fingerprint = plan.publication_contract_fingerprint();
1579    let current_fingerprint = current_plan.publication_contract_fingerprint();
1580    if staged_fingerprint != current_fingerprint {
1581        return Err(verification_incomplete(
1582            &plan.root,
1583            IndexVerificationReason::PublicationContractMismatch,
1584            &CliError::InvalidInput(
1585                "derived index policy changed while publication was being built; retry the refresh"
1586                    .to_string(),
1587            ),
1588        ));
1589    }
1590    let current_nodes = scan_repo_controlled(
1591        &current_plan.root,
1592        &current_plan.scan_options,
1593        ScanLimits::default(),
1594        control,
1595    )
1596    .map_err(|source| source_inspection_error(&current_plan.root, source))?;
1597    verify_source_nodes_match(&current_plan.root, &current_nodes, staged_nodes)?;
1598    if let Some(expected_fingerprint) = expected_purpose_import_fingerprint {
1599        let current_fingerprint = if let Some(records) = reusable_purpose_records {
1600            current_plan
1601                .purpose_import_fingerprint_for_records_controlled_with_limits(
1602                    records,
1603                    control,
1604                    purpose_limits,
1605                )
1606                .map_err(|source| publication_input_error(&plan.root, source))?
1607        } else {
1608            current_plan
1609                .purpose_import_snapshot_controlled_with_limits(
1610                    &current_nodes,
1611                    control,
1612                    purpose_limits,
1613                )
1614                .map_err(|source| publication_input_error(&plan.root, source))?
1615                .fingerprint
1616        };
1617        if current_fingerprint != expected_fingerprint {
1618            return Err(verification_incomplete(
1619                &plan.root,
1620                IndexVerificationReason::PublicationContractMismatch,
1621                &CliError::InvalidInput(
1622                    "purpose-import inputs changed while publication was being built; retry the refresh"
1623                        .to_string(),
1624                ),
1625            ));
1626        }
1627    }
1628    control.check(IndexWorkStage::Publication)?;
1629    Ok(())
1630}
1631
1632/// Preserve typed work failures while adapting authored-input uncertainty.
1633fn publication_input_error(root: &Path, source: CliError) -> CliError {
1634    match source {
1635        source @ CliError::IndexWork(_) => source,
1636        other => verification_incomplete(root, IndexVerificationReason::PolicyUnavailable, &other),
1637    }
1638}
1639
1640/// Preserve typed work failures while adapting ordinary scan uncertainty.
1641fn source_inspection_error(root: &Path, source: FsError) -> CliError {
1642    match source {
1643        FsError::IndexWork(failure) => failure.into(),
1644        FsError::RepositoryBoundary { .. } => {
1645            CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1646                project_root: lossless_project_root_display(root),
1647                worktree: None,
1648                status: IndexReadStatus::VerificationIncomplete,
1649                reason: IndexVerificationReason::PolicyUnavailable,
1650                scope: IndexRefreshScope::Full,
1651                message: source.to_string(),
1652            }))
1653        }
1654        other => CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1655            project_root: lossless_project_root_display(root),
1656            worktree: None,
1657            status: IndexReadStatus::VerificationIncomplete,
1658            reason: IndexVerificationReason::SourceInspectionFailed,
1659            scope: IndexRefreshScope::Full,
1660            message: other.to_string(),
1661        })),
1662    }
1663}
1664
1665/// Commit only after the shared work boundary still permits publication.
1666fn complete_index_publication(
1667    publication: IndexPublicationGuard<'_>,
1668    control: &IndexWorkControl,
1669) -> Result<(), CliError> {
1670    control.check(IndexWorkStage::Publication)?;
1671    publication.complete()?;
1672    Ok(())
1673}
1674
1675/// Add one unambiguous field/value pair to a derived-index fingerprint.
1676fn hash_index_contract_value(hasher: &mut Hasher, field: &str, value: &str) {
1677    hasher.update(field.as_bytes());
1678    hasher.update(&[0]);
1679    hasher.update(value.as_bytes());
1680    hasher.update(&[0xff]);
1681}
1682
1683/// Add one native path to a fingerprint without a lossy text projection.
1684fn hash_index_contract_native_path(hasher: &mut Hasher, field: &str, path: &Path) {
1685    hasher.update(field.as_bytes());
1686    hasher.update(&[0]);
1687    hasher.update(path.as_os_str().as_encoded_bytes());
1688    hasher.update(&[0xff]);
1689}
1690
1691/// Convert a policy/root preflight failure into a non-destructive read refusal.
1692fn verification_incomplete(
1693    root: &Path,
1694    reason: IndexVerificationReason,
1695    source: &CliError,
1696) -> CliError {
1697    CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1698        project_root: lossless_project_root_display(root),
1699        worktree: None,
1700        status: IndexReadStatus::VerificationIncomplete,
1701        reason,
1702        scope: IndexRefreshScope::Full,
1703        message: source.to_string(),
1704    }))
1705}
1706
1707/// Compare source-derived node identity while ignoring non-semantic mtimes.
1708fn same_indexed_source(current: &Node, indexed: &Node) -> bool {
1709    current.path == indexed.path
1710        && current.kind == indexed.kind
1711        && current.parent_path == indexed.parent_path
1712        && current.extension == indexed.extension
1713        && current.language == indexed.language
1714        && current.size_bytes == indexed.size_bytes
1715        && current.content_hash == indexed.content_hash
1716}
1717
1718/// Refuse publication when source bytes no longer match their staged node.
1719fn source_changed_during_derivation(root: &Path, path: &str) -> CliError {
1720    CliError::RefreshRequired(Box::new(IndexRefreshRequired {
1721        project_root: lossless_project_root_display(root),
1722        worktree: None,
1723        status: IndexReadStatus::RefreshRequired,
1724        reason: IndexRefreshReason::SourceChanged,
1725        scope: IndexRefreshScope::Full,
1726        changed: 1,
1727        added: 0,
1728        removed: 0,
1729        modified: 1,
1730        sample_paths: vec![path.to_string()],
1731    }))
1732}
1733
1734impl<'a> PurposeInputReader<'a> {
1735    /// Create one reader whose cancellation and limits belong to the scan operation.
1736    fn new(
1737        plan: &ScanRuntimePlan,
1738        control: &'a IndexWorkControl,
1739        limits: PurposeImportLimits,
1740    ) -> Self {
1741        let mut complete_paths = BTreeSet::new();
1742        if let Some(path) = &plan.selected_config_path {
1743            complete_paths.insert(path.clone());
1744        }
1745        if let Some(config) = &plan.config {
1746            complete_paths.insert(config.map_path.clone());
1747            complete_paths.insert(config.nonsource_files_path.clone());
1748        }
1749        Self::for_complete_paths(
1750            control,
1751            limits,
1752            complete_paths,
1753            plan.config.as_ref().map_or_else(
1754                || ".purpose".to_string(),
1755                |config| config.purpose_filename().to_string(),
1756            ),
1757        )
1758    }
1759
1760    /// Create a bounded reader before a complete runtime plan is available.
1761    fn for_complete_paths(
1762        control: &'a IndexWorkControl,
1763        limits: PurposeImportLimits,
1764        complete_paths: BTreeSet<PathBuf>,
1765        purpose_filename: String,
1766    ) -> Self {
1767        Self {
1768            control,
1769            limits,
1770            complete_paths,
1771            purpose_filename,
1772            complete_digests: BTreeMap::new(),
1773        }
1774    }
1775
1776    /// Read one UTF-8 input, treating non-UTF-8 source headers as purpose-free.
1777    fn read_text(&mut self, path: &Path) -> Result<String, CliError> {
1778        self.control.check(IndexWorkStage::Publication)?;
1779        let is_complete = self.complete_paths.contains(path)
1780            || path
1781                .file_name()
1782                .is_some_and(|name| name == self.purpose_filename.as_str());
1783        let file_limit = if is_complete {
1784            self.limits.complete_file_bytes
1785        } else {
1786            self.limits.header_bytes
1787        };
1788        let mut file = fs::File::open(path).map_err(|source| CliError::Io {
1789            path: path.to_path_buf(),
1790            source,
1791        })?;
1792        let bytes = self.read_bytes(path, &mut file, file_limit, is_complete)?;
1793        if is_complete {
1794            self.complete_digests.insert(
1795                path.to_path_buf(),
1796                blake3::hash(&bytes).to_hex().to_string(),
1797            );
1798        }
1799        match String::from_utf8(bytes) {
1800            Ok(content) => Ok(content),
1801            Err(source) if !is_complete && source.utf8_error().error_len().is_some() => {
1802                Ok(String::new())
1803            }
1804            Err(source) if !is_complete && source.utf8_error().error_len().is_none() => {
1805                let valid_up_to = source.utf8_error().valid_up_to();
1806                String::from_utf8(source.into_bytes()[..valid_up_to].to_vec()).map_err(|source| {
1807                    CliError::InvalidInput(format!(
1808                        "purpose header input is not valid UTF-8 for {}: {source}",
1809                        normalize_native_path_display(path)
1810                    ))
1811                })
1812            }
1813            Err(source) => Err(CliError::InvalidInput(format!(
1814                "purpose input is not valid UTF-8 for {}: {source}",
1815                normalize_native_path_display(path)
1816            ))),
1817        }
1818    }
1819
1820    /// Read bytes with inter-chunk cancellation and aggregate accounting.
1821    fn read_bytes<R: Read>(
1822        &mut self,
1823        path: &Path,
1824        reader: &mut R,
1825        file_limit: u64,
1826        require_complete: bool,
1827    ) -> Result<Vec<u8>, CliError> {
1828        let initial_capacity = usize::try_from(file_limit)
1829            .unwrap_or(usize::MAX)
1830            .min(CONTROLLED_SOURCE_READ_BUFFER_BYTES);
1831        let mut bytes = Vec::with_capacity(initial_capacity);
1832        let mut buffer = [0_u8; CONTROLLED_SOURCE_READ_BUFFER_BYTES];
1833        loop {
1834            self.control.check(IndexWorkStage::Publication)?;
1835            let file_bytes = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
1836            let remaining = file_limit.saturating_sub(file_bytes);
1837            if remaining == 0 {
1838                if !require_complete {
1839                    break;
1840                }
1841                let read = reader
1842                    .read(&mut buffer[..1])
1843                    .map_err(|source| CliError::Io {
1844                        path: path.to_path_buf(),
1845                        source,
1846                    })?;
1847                if read == 0 {
1848                    break;
1849                }
1850                return Err(IndexWorkFailure::resource_limit(
1851                    IndexWorkStage::Publication,
1852                    IndexWorkResource::PurposeBytes,
1853                    file_limit,
1854                    file_limit.saturating_add(1),
1855                )
1856                .into());
1857            }
1858            let read_limit = usize::try_from(remaining)
1859                .unwrap_or(usize::MAX)
1860                .min(buffer.len());
1861            let read = reader
1862                .read(&mut buffer[..read_limit])
1863                .map_err(|source| CliError::Io {
1864                    path: path.to_path_buf(),
1865                    source,
1866                })?;
1867            if read == 0 {
1868                break;
1869            }
1870            self.control.consume_purpose_bytes(
1871                self.limits.total_bytes,
1872                u64::try_from(read).unwrap_or(u64::MAX),
1873            )?;
1874            bytes.extend_from_slice(&buffer[..read]);
1875        }
1876        self.control.check(IndexWorkStage::Publication)?;
1877        Ok(bytes)
1878    }
1879
1880    /// Return the digest of one complete input already read through this boundary.
1881    fn complete_digest(&self, path: &Path) -> Option<&str> {
1882        self.complete_digests.get(path).map(String::as_str)
1883    }
1884}
1885
1886impl ScanRuntimePlan {
1887    /// Resolve scan policy for one project path.
1888    pub(crate) fn for_path(
1889        config_path: Option<&Path>,
1890        path: &Path,
1891        text_index_max_bytes: Option<u64>,
1892    ) -> Result<Self, CliError> {
1893        let control = standalone_index_work_control();
1894        Self::for_path_controlled(config_path, path, text_index_max_bytes, &control)
1895    }
1896
1897    /// Resolve scan policy through the operation-owned bounded config reader.
1898    pub(crate) fn for_path_controlled(
1899        config_path: Option<&Path>,
1900        path: &Path,
1901        text_index_max_bytes: Option<u64>,
1902        control: &IndexWorkControl,
1903    ) -> Result<Self, CliError> {
1904        Self::for_path_controlled_with_limits(
1905            config_path,
1906            path,
1907            text_index_max_bytes,
1908            control,
1909            PurposeImportLimits::default(),
1910        )
1911    }
1912
1913    /// Resolve scan policy under explicit authored-input limits used by focused tests.
1914    fn for_path_controlled_with_limits(
1915        config_path: Option<&Path>,
1916        path: &Path,
1917        text_index_max_bytes: Option<u64>,
1918        control: &IndexWorkControl,
1919        purpose_limits: PurposeImportLimits,
1920    ) -> Result<Self, CliError> {
1921        control.check(IndexWorkStage::Publication)?;
1922        let root = canonical_source_project_root(path)?;
1923        let selected_config_path = selected_scan_import_config_path(config_path, &root)?;
1924        let config = if let Some(path) = selected_config_path.as_deref() {
1925            let mut complete_paths = BTreeSet::new();
1926            complete_paths.insert(path.to_path_buf());
1927            let mut reader = PurposeInputReader::for_complete_paths(
1928                control,
1929                purpose_limits,
1930                complete_paths,
1931                ".purpose".to_string(),
1932            );
1933            let text = reader.read_text(path)?;
1934            let config = load_atlas_config_from_text(path, &text)?;
1935            let config_root = canonical_project_root(&config.root)?;
1936            if config_root != root {
1937                return Err(config_root_mismatch_error(path, &config_root, &root));
1938            }
1939            Some(config)
1940        } else {
1941            None
1942        };
1943        control.check(IndexWorkStage::Publication)?;
1944        let scan_options = config.as_ref().map_or_else(
1945            ScanOptions::default,
1946            atlas_map::AtlasMapConfig::scan_options,
1947        );
1948        let text_options = text_index_options(config.as_ref(), text_index_max_bytes);
1949        #[cfg(feature = "optional-parser-supervisor")]
1950        let optional_parser_selection =
1951            OptionalParserPackLifecycle::new(&root, None)?.derive_project_selection()?;
1952        #[cfg(feature = "optional-parser-supervisor")]
1953        let scan_options = {
1954            let mut scan_options = scan_options;
1955            scan_options.admit_optional_languages =
1956                optional_parser_selection.selection_key().is_some();
1957            scan_options
1958        };
1959        Ok(Self {
1960            root,
1961            config,
1962            selected_config_path,
1963            config_path_override: config_path.map(Path::to_path_buf),
1964            scan_options,
1965            text_options,
1966            text_index_max_bytes_override: text_index_max_bytes,
1967            #[cfg(feature = "optional-parser-supervisor")]
1968            optional_parser_selection,
1969        })
1970    }
1971
1972    /// Reload the effective filesystem and text policy from current local state.
1973    fn reload(&self) -> Result<Self, CliError> {
1974        Self::for_path(
1975            self.config_path_override.as_deref(),
1976            &self.root,
1977            self.text_index_max_bytes_override,
1978        )
1979    }
1980
1981    /// Reload effective policy through the operation-owned bounded input reader.
1982    fn reload_controlled(&self, control: &IndexWorkControl) -> Result<Self, CliError> {
1983        self.reload_controlled_with_limits(control, PurposeImportLimits::default())
1984    }
1985
1986    /// Reload effective policy under explicit authored-input limits used by focused tests.
1987    fn reload_controlled_with_limits(
1988        &self,
1989        control: &IndexWorkControl,
1990        purpose_limits: PurposeImportLimits,
1991    ) -> Result<Self, CliError> {
1992        Self::for_path_controlled_with_limits(
1993            self.config_path_override.as_deref(),
1994            &self.root,
1995            self.text_index_max_bytes_override,
1996            control,
1997            purpose_limits,
1998        )
1999    }
2000
2001    /// Hash the durable parser and configured source/index policy contract.
2002    ///
2003    /// Request-scoped text limits control one scan or watcher operation but do
2004    /// not become project compatibility state that later reads must repeat.
2005    fn publication_contract_fingerprint(&self) -> String {
2006        let configured_text_options = text_index_options(self.config.as_ref(), None);
2007        index_derivation_fingerprint(
2008            &self.scan_options,
2009            configured_text_options,
2010            #[cfg(feature = "optional-parser-supervisor")]
2011            &self.optional_parser_selection,
2012        )
2013    }
2014
2015    /// Capture every purpose input from one existing controlled repository scan.
2016    fn purpose_import_snapshot_controlled(
2017        &self,
2018        nodes: &[Node],
2019        control: &IndexWorkControl,
2020    ) -> Result<PurposeImportSnapshot, CliError> {
2021        self.purpose_import_snapshot_controlled_with_limits(
2022            nodes,
2023            control,
2024            PurposeImportLimits::default(),
2025        )
2026    }
2027
2028    /// Capture purpose inputs under explicit limits used by focused tests.
2029    fn purpose_import_snapshot_controlled_with_limits(
2030        &self,
2031        nodes: &[Node],
2032        control: &IndexWorkControl,
2033        limits: PurposeImportLimits,
2034    ) -> Result<PurposeImportSnapshot, CliError> {
2035        control.check(IndexWorkStage::Publication)?;
2036        let mut hasher = Hasher::new();
2037        hash_index_contract_value(&mut hasher, "purpose_import_version", "2");
2038        let Some(config) = self.config.as_ref() else {
2039            hash_index_contract_value(&mut hasher, "selected_config", "absent");
2040            return Ok(PurposeImportSnapshot {
2041                records: Vec::new(),
2042                fingerprint: hasher.finalize().to_hex().to_string(),
2043            });
2044        };
2045        let selected_config_path = self.selected_config_path.as_deref().ok_or_else(|| {
2046            CliError::InvalidInput(
2047                "purpose import has normalized configuration without a selected config path"
2048                    .to_string(),
2049            )
2050        })?;
2051        if u64::try_from(nodes.len()).unwrap_or(u64::MAX) > limits.records {
2052            return Err(IndexWorkFailure::resource_limit(
2053                IndexWorkStage::Publication,
2054                IndexWorkResource::PurposeRecords,
2055                limits.records,
2056                u64::try_from(nodes.len()).unwrap_or(u64::MAX),
2057            )
2058            .into());
2059        }
2060        let mut reader = PurposeInputReader::new(self, control, limits);
2061        hash_publication_input_file_controlled(
2062            &mut hasher,
2063            "selected_config",
2064            selected_config_path,
2065            &mut reader,
2066        )?;
2067        let records = atlas_map::imported_purpose_records_from_nodes(config, nodes, &mut |path| {
2068            reader.read_text(path)
2069        })?;
2070        let fingerprint = finish_purpose_import_fingerprint_controlled(
2071            &mut hasher,
2072            config,
2073            &records,
2074            &mut reader,
2075            control,
2076            limits,
2077        )?;
2078        Ok(PurposeImportSnapshot {
2079            records,
2080            fingerprint,
2081        })
2082    }
2083
2084    /// Recheck external purpose inputs while reusing records from exact unchanged source nodes.
2085    fn purpose_import_fingerprint_for_records_controlled_with_limits(
2086        &self,
2087        records: &[atlas_map::ImportedPurposeRecord],
2088        control: &IndexWorkControl,
2089        limits: PurposeImportLimits,
2090    ) -> Result<String, CliError> {
2091        control.check(IndexWorkStage::Publication)?;
2092        let mut hasher = Hasher::new();
2093        hash_index_contract_value(&mut hasher, "purpose_import_version", "2");
2094        let Some(config) = self.config.as_ref() else {
2095            hash_index_contract_value(&mut hasher, "selected_config", "absent");
2096            return Ok(hasher.finalize().to_hex().to_string());
2097        };
2098        let selected_config_path = self.selected_config_path.as_deref().ok_or_else(|| {
2099            CliError::InvalidInput(
2100                "purpose import has normalized configuration without a selected config path"
2101                    .to_string(),
2102            )
2103        })?;
2104        let mut reader = PurposeInputReader::new(self, control, limits);
2105        hash_publication_input_file_controlled(
2106            &mut hasher,
2107            "selected_config",
2108            selected_config_path,
2109            &mut reader,
2110        )?;
2111        finish_purpose_import_fingerprint_controlled(
2112            &mut hasher,
2113            config,
2114            records,
2115            &mut reader,
2116            control,
2117            limits,
2118        )
2119    }
2120}
2121
2122/// Finish one purpose-import fingerprint from already normalized source records.
2123fn finish_purpose_import_fingerprint_controlled(
2124    hasher: &mut Hasher,
2125    config: &atlas_map::AtlasMapConfig,
2126    records: &[atlas_map::ImportedPurposeRecord],
2127    reader: &mut PurposeInputReader<'_>,
2128    control: &IndexWorkControl,
2129    limits: PurposeImportLimits,
2130) -> Result<String, CliError> {
2131    let record_count = u64::try_from(records.len()).unwrap_or(u64::MAX);
2132    if record_count > limits.records {
2133        return Err(IndexWorkFailure::resource_limit(
2134            IndexWorkStage::Publication,
2135            IndexWorkResource::PurposeRecords,
2136            limits.records,
2137            record_count,
2138        )
2139        .into());
2140    }
2141    hash_publication_input_file_controlled(hasher, "legacy_map", &config.map_path, reader)?;
2142    hash_publication_input_file_controlled(
2143        hasher,
2144        "nonsource_purposes",
2145        &config.nonsource_files_path,
2146        reader,
2147    )?;
2148    for record in records {
2149        control.check(IndexWorkStage::Publication)?;
2150        hash_index_contract_value(hasher, "purpose_path", &record.path);
2151        hash_index_contract_value(hasher, "purpose_summary", &record.summary);
2152    }
2153    Ok(hasher.finalize().to_hex().to_string())
2154}
2155
2156/// Resolve the exact config file selected for a scan plan without loading it.
2157fn selected_scan_import_config_path(
2158    config_path: Option<&Path>,
2159    root: &Path,
2160) -> Result<Option<PathBuf>, CliError> {
2161    if let Some(config_path) = config_path {
2162        return absolute_path(config_path).map(Some);
2163    }
2164    Ok(config_candidates_for_root(root)
2165        .into_iter()
2166        .find(|candidate| candidate.exists()))
2167}
2168
2169/// Bind one optional file's identity and exact bytes to publication input state.
2170fn hash_publication_input_file_controlled(
2171    hasher: &mut Hasher,
2172    role: &str,
2173    path: &Path,
2174    reader: &mut PurposeInputReader<'_>,
2175) -> Result<(), CliError> {
2176    hash_index_contract_native_path(hasher, role, path);
2177    if !path.exists() {
2178        hash_index_contract_value(hasher, "input_state", "missing");
2179        return Ok(());
2180    }
2181    if reader.complete_digest(path).is_none() {
2182        let _ = reader.read_text(path)?;
2183    }
2184    let digest = reader.complete_digest(path).ok_or_else(|| {
2185        CliError::InvalidInput(format!(
2186            "purpose publication input was not read completely: {}",
2187            normalize_native_path_display(path)
2188        ))
2189    })?;
2190    hash_index_contract_value(hasher, "input_state", "present");
2191    hash_index_contract_value(hasher, "input_digest", digest);
2192    Ok(())
2193}
2194
2195/// Scan command report shared by CLI and MCP adapters.
2196#[derive(Debug, Serialize)]
2197pub(crate) struct ScanReport {
2198    /// Repository overview after scan.
2199    pub(crate) overview: Overview,
2200    /// Legacy purpose records imported into the current index.
2201    pub(crate) purpose_import: PurposeImportReport,
2202    /// Persisted text search index report.
2203    pub(crate) text_index: TextIndexReport,
2204    /// Structural summaries refreshed for declaration-light files.
2205    pub(crate) structural_summaries: StructuralSummaryReport,
2206    /// Symbol graph build report.
2207    pub(crate) symbols: SymbolBuildReport,
2208}
2209
2210/// Legacy purpose import counts from a scan.
2211#[derive(Debug, Default, Serialize)]
2212pub(crate) struct PurposeImportReport {
2213    /// Purpose records imported into indexed nodes.
2214    pub(crate) imported: usize,
2215    /// Legacy purpose records skipped because the path is no longer indexed.
2216    pub(crate) skipped_stale: usize,
2217    /// Legacy purpose records skipped because a curated purpose already exists.
2218    pub(crate) skipped_existing: usize,
2219}
2220
2221/// Options for the first-run initialization bootstrap.
2222pub(crate) struct InitBootstrapOptions {
2223    /// Skip the scan/index phase.
2224    pub(crate) no_scan: bool,
2225    /// Force a scan even when future freshness checks would skip it.
2226    pub(crate) force_rescan: bool,
2227    /// Optional text index byte limit override.
2228    pub(crate) text_index_max_bytes: Option<u64>,
2229}
2230
2231/// Project initialization phase status.
2232#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
2233#[serde(rename_all = "snake_case")]
2234pub(crate) enum InitPhaseStatus {
2235    /// Resource was created during this run.
2236    Created,
2237    /// Resource already existed before this run.
2238    Exists,
2239    /// Resource was verified or phase completed.
2240    Verified,
2241    /// Phase was explicitly skipped.
2242    Skipped,
2243    /// Phase failed before the report could finish.
2244    Failed,
2245}
2246
2247/// First-run init report shared by CLI and MCP adapters.
2248#[derive(Debug, Serialize)]
2249pub(crate) struct InitSetupReport {
2250    /// Whether every init phase completed successfully.
2251    pub(crate) ok: bool,
2252    /// Canonical project root initialized by this command.
2253    pub(crate) root: Option<String>,
2254    /// Project-local directory status.
2255    pub(crate) project_dir: InitPathStatus,
2256    /// Project-local config status.
2257    pub(crate) config: InitPathStatus,
2258    /// Project-local non-source registry status.
2259    pub(crate) nonsource_files: InitPathStatus,
2260    /// Durable `SQLite` DB status.
2261    pub(crate) db: InitPathStatus,
2262    /// Generated host MCP config files.
2263    pub(crate) host_configs: Vec<InitHostConfigStatus>,
2264    /// Scan/index phase result.
2265    pub(crate) scan: InitScanPhase,
2266    /// Registered-worktree hydration result when alias init selected one.
2267    #[serde(skip_serializing_if = "Option::is_none")]
2268    pub(crate) hydration: Option<InitHydrationPhase>,
2269    /// Agent harness purpose curation handoff.
2270    pub(crate) purpose_handoff: PurposeCuratorHandoff,
2271    /// Human/agent next steps.
2272    pub(crate) next_steps: Vec<String>,
2273}
2274
2275/// Status for one path managed by init.
2276#[derive(Debug, Serialize)]
2277pub(crate) struct InitPathStatus {
2278    /// Path status.
2279    pub(crate) status: InitPhaseStatus,
2280    /// Lossless UTF-8 path, when one is available.
2281    pub(crate) path: Option<String>,
2282}
2283
2284/// Status for one generated host integration config.
2285#[derive(Debug, Serialize)]
2286pub(crate) struct InitHostConfigStatus {
2287    /// Harness/config shape name.
2288    pub(crate) harness: &'static str,
2289    /// File status.
2290    pub(crate) status: InitPhaseStatus,
2291    /// Lossless UTF-8 path, when one is available.
2292    pub(crate) path: Option<String>,
2293    /// Error text when this host config could not be generated.
2294    #[serde(skip_serializing_if = "Option::is_none")]
2295    pub(crate) error: Option<String>,
2296}
2297
2298/// Scan/index phase result for init.
2299#[derive(Debug, Serialize)]
2300pub(crate) struct InitScanPhase {
2301    /// Scan phase status.
2302    pub(crate) status: InitPhaseStatus,
2303    /// Whether scan was requested by this run.
2304    pub(crate) requested: bool,
2305    /// Whether force-rescan was requested.
2306    pub(crate) force_rescan: bool,
2307    /// Scan report when the scan ran.
2308    pub(crate) report: Option<ScanReport>,
2309    /// Error text when the scan/index phase failed.
2310    #[serde(skip_serializing_if = "Option::is_none")]
2311    pub(crate) error: Option<String>,
2312}
2313
2314/// Registered-worktree initialization source state.
2315#[derive(Debug, Serialize)]
2316pub(crate) struct InitHydrationPhase {
2317    /// Whether a reusable control baseline was activated, skipped, or rejected.
2318    pub(crate) status: InitHydrationStatus,
2319    /// Canonical control root evaluated as the optional source.
2320    #[serde(skip_serializing_if = "Option::is_none")]
2321    pub(crate) source_root: Option<String>,
2322    /// Source control-atlas identity when hydration succeeded.
2323    #[serde(skip_serializing_if = "Option::is_none")]
2324    pub(crate) source_project_instance_id: Option<String>,
2325    /// New independently writable target-atlas identity when hydration succeeded.
2326    #[serde(skip_serializing_if = "Option::is_none")]
2327    pub(crate) target_project_instance_id: Option<String>,
2328    /// Detached baseline generation when hydration succeeded.
2329    #[serde(skip_serializing_if = "Option::is_none")]
2330    pub(crate) baseline_generation: Option<u64>,
2331    /// Complete target generation activated after reconciliation.
2332    #[serde(skip_serializing_if = "Option::is_none")]
2333    pub(crate) reconciled_generation: Option<u64>,
2334    /// Visible reason ordinary initialization was used instead.
2335    #[serde(skip_serializing_if = "Option::is_none")]
2336    pub(crate) fallback_reason: Option<String>,
2337}
2338
2339/// Stable worktree hydration outcomes exposed by init.
2340#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
2341#[serde(rename_all = "snake_case")]
2342pub(crate) enum InitHydrationStatus {
2343    /// The existing valid worktree atlas was preserved.
2344    Existing,
2345    /// A control-atlas baseline was reconciled and activated.
2346    Hydrated,
2347    /// Ordinary init completed after hydration was unavailable or unsafe.
2348    Fallback,
2349}
2350
2351/// Purpose curation handoff for agent/plugin harnesses.
2352#[derive(Debug, Serialize)]
2353#[allow(
2354    clippy::struct_excessive_bools,
2355    reason = "serialized host policy exposes independent capabilities and guarantees"
2356)]
2357pub(crate) struct PurposeCuratorHandoff {
2358    /// Whether this report is intended for an agent harness.
2359    pub(crate) agent_harness_expected: bool,
2360    /// Curator execution is owned by the agent host, never the Rust server.
2361    pub(crate) execution_owner: &'static str,
2362    /// Recommended host-relative reliable subagent tier selection.
2363    pub(crate) recommended_subagent_reasoning: &'static str,
2364    /// Whether the current main agent may process the same bounded batch.
2365    pub(crate) main_agent_fallback: bool,
2366    /// Explicitly records that `ProjectAtlas` did not spawn a host agent.
2367    pub(crate) server_started_curator: bool,
2368    /// Successful maintenance should not add ordinary conversation output.
2369    pub(crate) silent_on_success: bool,
2370    /// Purpose queue page for initial curation.
2371    pub(crate) queue: PurposeCurationPage,
2372    /// Handoff instructions for plugin/agent harnesses.
2373    pub(crate) instructions: Vec<String>,
2374}
2375
2376/// Return a canonical absolute project root.
2377pub(crate) fn canonical_project_root(root: &Path) -> Result<PathBuf, CliError> {
2378    root.canonicalize().map_err(|source| CliError::Io {
2379        path: root.to_path_buf(),
2380        source,
2381    })
2382}
2383
2384/// Return a lossless UTF-8 display for one existing canonical project root.
2385pub(crate) fn lossless_project_root_display(root: &Path) -> Option<String> {
2386    CanonicalProjectRoot::from_path(root)
2387        .ok()
2388        .and_then(|root| root.display_string().ok())
2389}
2390
2391/// Return a lossless UTF-8 display for one native path.
2392pub(crate) fn lossless_native_path_display(path: &Path) -> Option<String> {
2393    projectatlas_core::lossless_native_path_display(path).ok()
2394}
2395
2396/// Return one exact source root from structural Git or non-Git evidence.
2397pub(crate) fn canonical_source_project_root(root: &Path) -> Result<PathBuf, CliError> {
2398    match projectatlas_fs::worktree::discover_repository_structure(root)? {
2399        RepositoryStructure::NonGit { selected_root } => Ok(selected_root),
2400        RepositoryStructure::Git(repository) => match repository.selection {
2401            GitRepositorySelection::Worktree { root, .. }
2402            | GitRepositorySelection::CommonManager {
2403                source_selection: GitManagerSourceSelection::Unambiguous { root },
2404            } => Ok(root),
2405            GitRepositorySelection::CommonManager { .. } => Err(CliError::WorktreeRequired(
2406                Box::new(ProjectWorktreeRequired {
2407                    project_root: lossless_project_root_display(&repository.common_directory),
2408                    status: IndexReadStatus::WorktreeRequired,
2409                }),
2410            )),
2411        },
2412        RepositoryStructure::InvalidGit {
2413            selected_root,
2414            issue,
2415        } => Err(CliError::InvalidInput(format!(
2416            "invalid structural Git evidence for '{}': {:?} at '{}'",
2417            normalize_native_path_display(selected_root),
2418            issue.kind,
2419            normalize_native_path_display(issue.path)
2420        ))),
2421    }
2422}
2423
2424/// Return a typed, non-mutating first-use handoff for one selected root.
2425pub(crate) fn index_init_required(root: &Path, database: &Path) -> CliError {
2426    CliError::InitRequired(Box::new(IndexInitRequired {
2427        project_root: lossless_project_root_display(root),
2428        database: lossless_native_path_display(database),
2429        worktree: None,
2430        status: IndexReadStatus::InitRequired,
2431    }))
2432}
2433
2434/// Load map configuration for purpose import during scan.
2435pub(crate) fn load_scan_import_config(
2436    config_path: Option<&Path>,
2437    scan_path: &Path,
2438) -> Result<Option<atlas_map::AtlasMapConfig>, CliError> {
2439    if let Some(config_path) = config_path {
2440        return Ok(Some(load_atlas_config(Some(config_path))?));
2441    }
2442    let project_config = scan_path.join(".projectatlas").join("config.toml");
2443    if project_config.exists() {
2444        return Ok(Some(load_atlas_config(Some(&project_config))?));
2445    }
2446    let flat_config = scan_path.join("projectatlas.toml");
2447    if flat_config.exists() {
2448        return Ok(Some(load_atlas_config(Some(&flat_config))?));
2449    }
2450    Ok(None)
2451}
2452
2453/// Open or create a durable index bound to one selected project root.
2454pub(crate) fn open_atlas_store_for_project(
2455    path: &Path,
2456    root: &Path,
2457) -> Result<AtlasStore, CliError> {
2458    open_atlas_store_for_project_with_location_validator(path, root, validate_database_location)
2459}
2460
2461/// Validate storage before creating the database parent and opening the store.
2462fn open_atlas_store_for_project_with_location_validator<F>(
2463    path: &Path,
2464    root: &Path,
2465    validate_location: F,
2466) -> Result<AtlasStore, CliError>
2467where
2468    F: FnOnce(&Path) -> projectatlas_db::DbResult<()>,
2469{
2470    validate_location(path).map_err(project_store_error)?;
2471    ensure_parent_dir(path)?;
2472    AtlasStore::open_for_project(path, root).map_err(project_store_error)
2473}
2474
2475/// Open a current durable index read snapshot bound to one selected root.
2476pub(crate) fn open_atlas_store_read_only_for_project(
2477    path: &Path,
2478    root: &Path,
2479) -> Result<AtlasStore, CliError> {
2480    AtlasStore::open_read_only_for_project(path, root).map_err(project_store_error)
2481}
2482
2483/// Preserve typed selected-root mismatch diagnostics across store adapters.
2484pub(crate) fn project_store_error(source: projectatlas_db::DbError) -> CliError {
2485    match source {
2486        projectatlas_db::DbError::ProjectRootMismatch {
2487            expected,
2488            found,
2489            identities,
2490        } => CliError::ProjectMismatch(Box::new(IndexProjectMismatch {
2491            status: IndexReadStatus::ProjectMismatch,
2492            worktree: None,
2493            selected_project_root: identities
2494                .as_ref()
2495                .and_then(|identities| identities.expected.display_string().ok()),
2496            indexed_project_root: identities
2497                .as_ref()
2498                .and_then(|identities| identities.found.display_string().ok()),
2499            diagnostic: Some(format!(
2500                "database project root {found:?} does not match selected root {expected:?}"
2501            )),
2502        })),
2503        other => CliError::Db(other),
2504    }
2505}
2506
2507/// Return the config path init should preserve or create for a project root.
2508pub(crate) fn init_config_path(root: &Path, explicit: Option<&Path>) -> PathBuf {
2509    if let Some(config_path) = explicit {
2510        return if config_path.is_absolute() {
2511            config_path.to_path_buf()
2512        } else {
2513            root.join(config_path)
2514        };
2515    }
2516    let nested_config = root.join(".projectatlas").join("config.toml");
2517    if nested_config.exists() {
2518        return nested_config;
2519    }
2520    let flat_config = root.join("projectatlas.toml");
2521    if flat_config.exists() {
2522        return flat_config;
2523    }
2524    nested_config
2525}
2526
2527/// Run the one-call first-run init bootstrap.
2528pub(crate) fn run_init_bootstrap(
2529    root: &Path,
2530    db_path: &Path,
2531    config_path: Option<&Path>,
2532    options: &InitBootstrapOptions,
2533) -> Result<InitSetupReport, CliError> {
2534    let root = canonical_source_project_root(root)?;
2535    if db_path.is_file() {
2536        AtlasStore::repair_missing_project_root_identity(db_path, &root)
2537            .map_err(project_store_error)?;
2538    }
2539    preflight_existing_project_binding(db_path, &root)?;
2540    let project_dir = root.join(".projectatlas");
2541    let config_file = init_config_path(&root, config_path);
2542    let nonsource_file = project_dir.join("projectatlas-nonsource-files.toon");
2543    let project_dir_existed = project_dir.exists();
2544    let config_existed = config_file.exists();
2545    let nonsource_existed = nonsource_file.exists();
2546    let db_existed = db_path.exists();
2547
2548    init_project_with_config(&root, Some(&config_file))?;
2549    let mut store = open_atlas_store_for_project(db_path, &root)?;
2550
2551    let mut ok = true;
2552    let (scan_status, scan_report, scan_error) = if options.no_scan {
2553        (InitPhaseStatus::Skipped, None, None)
2554    } else {
2555        let symbol_options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None);
2556        let control = index_work_control(&symbol_options);
2557        match ScanRuntimePlan::for_path_controlled(
2558            config_path,
2559            &root,
2560            options.text_index_max_bytes,
2561            &control,
2562        )
2563        .and_then(|plan| run_scan_pipeline_controlled(&mut store, &plan, &symbol_options, &control))
2564        {
2565            Ok(report) => (InitPhaseStatus::Verified, Some(report), None),
2566            Err(error) => {
2567                ok = false;
2568                (InitPhaseStatus::Failed, None, Some(error.to_string()))
2569            }
2570        }
2571    };
2572
2573    let purpose_query = HealthQuery {
2574        start_index: 0,
2575        limit: DEFAULT_HEALTH_LIMIT,
2576        category: None,
2577        severity: None,
2578        path_prefix: None,
2579        summary_only: false,
2580        scope: HealthScope::purpose_default(),
2581    };
2582    let purpose_queue = purpose_curation_page(&store, &purpose_query, "project-init")?;
2583    let next_steps = init_next_steps(options.no_scan, scan_error.is_some(), purpose_queue.total);
2584
2585    Ok(InitSetupReport {
2586        ok,
2587        root: lossless_project_root_display(&root),
2588        project_dir: InitPathStatus {
2589            status: init_path_status(project_dir_existed),
2590            path: lossless_native_path_display(&project_dir),
2591        },
2592        config: InitPathStatus {
2593            status: init_path_status(config_existed),
2594            path: lossless_native_path_display(&config_file),
2595        },
2596        nonsource_files: InitPathStatus {
2597            status: init_path_status(nonsource_existed),
2598            path: lossless_native_path_display(&nonsource_file),
2599        },
2600        db: InitPathStatus {
2601            status: init_path_status(db_existed),
2602            path: lossless_native_path_display(db_path),
2603        },
2604        host_configs: Vec::new(),
2605        scan: InitScanPhase {
2606            status: scan_status,
2607            requested: !options.no_scan,
2608            force_rescan: options.force_rescan,
2609            report: scan_report,
2610            error: scan_error,
2611        },
2612        hydration: None,
2613        purpose_handoff: purpose_curator_handoff(purpose_queue),
2614        next_steps,
2615    })
2616}
2617
2618/// Refuse an existing database binding before init writes.
2619///
2620/// The storage owner performs one read-only admission for both current native
2621/// identities and supported predecessor metadata. Fresh databases remain
2622/// available to the initializer, while the later writer still revalidates under
2623/// its transaction for races.
2624pub(crate) fn preflight_existing_project_binding(
2625    db_path: &Path,
2626    root: &Path,
2627) -> Result<(), CliError> {
2628    preflight_project_binding_read_only(db_path, root).map_err(project_store_error)
2629}
2630
2631/// Return created/existing status for a path.
2632pub(crate) fn init_path_status(existed: bool) -> InitPhaseStatus {
2633    if existed {
2634        InitPhaseStatus::Exists
2635    } else {
2636        InitPhaseStatus::Created
2637    }
2638}
2639
2640/// Return stable purpose handoff instructions for agent harnesses.
2641fn purpose_handoff_instructions() -> Vec<String> {
2642    vec![
2643        "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(),
2644        "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(),
2645        "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(),
2646        "Keep successful curator maintenance out of normal conversation; ProjectAtlas reports a handoff and never claims that the Rust server spawned an agent.".to_string(),
2647    ]
2648}
2649
2650/// Build one host-owned purpose-curator handoff shared by init and session brief.
2651pub(crate) fn purpose_curator_handoff(queue: PurposeCurationPage) -> PurposeCuratorHandoff {
2652    PurposeCuratorHandoff {
2653        agent_harness_expected: true,
2654        execution_owner: "agent_host",
2655        recommended_subagent_reasoning: PURPOSE_CURATOR_RECOMMENDED_REASONING,
2656        main_agent_fallback: true,
2657        server_started_curator: false,
2658        silent_on_success: true,
2659        queue,
2660        instructions: purpose_handoff_instructions(),
2661    }
2662}
2663
2664/// Return concise next steps for humans and agents.
2665pub(crate) fn init_next_steps(
2666    scan_skipped: bool,
2667    scan_failed: bool,
2668    purpose_queue_total: usize,
2669) -> Vec<String> {
2670    let mut steps = Vec::new();
2671    if scan_skipped {
2672        steps.push("Run projectatlas scan when you are ready to build the deep index.".to_string());
2673    } else if scan_failed {
2674        steps.push(
2675            "Fix the scan/index error and rerun projectatlas init or projectatlas scan."
2676                .to_string(),
2677        );
2678    }
2679    if purpose_queue_total > 0 {
2680        steps.push(
2681            "Use the purpose_handoff queue to delegate purpose creation/correction at the lowest reliable reasoning and cost tier the host supports."
2682                .to_string(),
2683        );
2684    } else {
2685        steps.push("Purpose queue is empty for the default high-impact scope.".to_string());
2686    }
2687    steps.push("Run projectatlas overview to confirm repository orientation.".to_string());
2688    steps
2689}
2690
2691/// Create the parent directory for a path when it has one.
2692pub(crate) fn ensure_parent_dir(path: &Path) -> Result<(), CliError> {
2693    let Some(parent) = path.parent() else {
2694        return Ok(());
2695    };
2696    if parent.as_os_str().is_empty() {
2697        return Ok(());
2698    }
2699    fs::create_dir_all(parent).map_err(|source| CliError::Io {
2700        path: parent.to_path_buf(),
2701        source,
2702    })
2703}
2704
2705/// Build the standard config/root mismatch error.
2706pub(crate) fn config_root_mismatch_error(
2707    config_path: &Path,
2708    config_root: &Path,
2709    selected_root: &Path,
2710) -> CliError {
2711    CliError::InvalidInput(format!(
2712        "ProjectAtlas config '{}' resolves project root '{}' outside selected project root '{}'",
2713        config_path.display(),
2714        config_root.display(),
2715        selected_root.display()
2716    ))
2717}
2718
2719/// Resolve a predecessor root only as a read-only discovery candidate.
2720///
2721/// Legacy metadata is a display-only recovery hint. The database-owned
2722/// candidate reader rejects projections without native authority before this
2723/// function constructs a path or probes any candidate configuration location.
2724fn legacy_project_root_candidate(db: &Path) -> Result<Option<PathBuf>, CliError> {
2725    if !db.exists() {
2726        return Ok(None);
2727    }
2728    let Some(project_root) = read_legacy_project_root_candidate_read_only(db)? else {
2729        return Ok(None);
2730    };
2731    Ok(Some(canonical_source_project_root(Path::new(
2732        &project_root,
2733    ))?))
2734}
2735
2736/// Resolve the default MCP project root without trusting the process cwd.
2737pub(crate) fn default_mcp_project_root(
2738    db: &Path,
2739    config_path: Option<&Path>,
2740) -> Result<PathBuf, CliError> {
2741    let legacy_root = legacy_project_root_candidate(db)?;
2742    if let Some(config_path) = config_path {
2743        let config = load_atlas_config(Some(config_path))?;
2744        let config_root = canonical_source_project_root(&config.root)?;
2745        if let Some(db_root) = project_root_from_db_path(db) {
2746            let db_root = canonical_source_project_root(&db_root)?;
2747            if config_root != db_root {
2748                return Err(config_root_mismatch_error(
2749                    config_path,
2750                    &config_root,
2751                    &db_root,
2752                ));
2753            }
2754        }
2755        return Ok(config_root);
2756    }
2757    if db.exists()
2758        && let Some(project_root) = read_project_root_identity_read_only(db)?
2759    {
2760        return canonical_source_project_root(project_root.as_path());
2761    }
2762    if let Some(project_root) = legacy_root {
2763        return Ok(project_root);
2764    }
2765    if let Some(project_root) = project_root_from_db_path(db) {
2766        return canonical_source_project_root(&project_root);
2767    }
2768    let current_dir = std::env::current_dir().map_err(|source| CliError::Io {
2769        path: PathBuf::from("."),
2770        source,
2771    })?;
2772    canonical_source_project_root(&current_dir)
2773}
2774
2775/// Resolve the default CLI project root before opening an implicit database.
2776pub(crate) fn default_cli_project_root(
2777    db: &Path,
2778    config_path: Option<&Path>,
2779    database_path_is_explicit: bool,
2780) -> Result<PathBuf, CliError> {
2781    if !database_path_is_explicit
2782        && config_path.is_none()
2783        && !db.exists()
2784        && let Some(project_root) = project_root_from_db_path(db)
2785    {
2786        return canonical_source_project_root(&project_root);
2787    }
2788    default_mcp_project_root(db, config_path)
2789}
2790
2791/// Resolve a CLI repository-root argument, using indexed state for the default `.`.
2792pub(crate) fn defaultable_cli_project_root(
2793    path: &Path,
2794    db: &Path,
2795    config_path: Option<&Path>,
2796    database_path_is_explicit: bool,
2797) -> Result<PathBuf, CliError> {
2798    if path == Path::new(".") {
2799        return default_cli_project_root(db, config_path, database_path_is_explicit);
2800    }
2801    Ok(path.to_path_buf())
2802}
2803
2804/// Infer a project root from a default `.projectatlas/projectatlas.db` path.
2805fn project_root_from_db_path(db: &Path) -> Option<PathBuf> {
2806    let parent = db.parent()?;
2807    let cache_dir_name = parent.file_name()?;
2808    if cache_dir_name != ".projectatlas" {
2809        return None;
2810    }
2811    parent
2812        .parent()
2813        .filter(|root| !root.as_os_str().is_empty())
2814        .map_or_else(|| Some(PathBuf::from(".")), |root| Some(root.to_path_buf()))
2815}
2816
2817/// Load scan options for a project root from `ProjectAtlas` config when present.
2818pub(crate) fn scan_options_for_root(
2819    config_path: Option<&Path>,
2820    root: &Path,
2821) -> Result<ScanOptions, CliError> {
2822    Ok(load_scan_import_config(config_path, root)?
2823        .as_ref()
2824        .map_or_else(
2825            ScanOptions::default,
2826            atlas_map::AtlasMapConfig::scan_options,
2827        ))
2828}
2829
2830/// Resolve text-index persistence options from command override and config.
2831pub(crate) fn text_index_options(
2832    config: Option<&atlas_map::AtlasMapConfig>,
2833    max_bytes_override: Option<u64>,
2834) -> TextIndexOptions {
2835    let max_bytes = max_bytes_override
2836        .filter(|value| *value > 0)
2837        .or_else(|| config.map(atlas_map::AtlasMapConfig::text_index_max_bytes))
2838        .unwrap_or(atlas_map::DEFAULT_TEXT_INDEX_MAX_BYTES);
2839    TextIndexOptions::new(max_bytes)
2840}
2841
2842/// Capture the last complete generation used as a publication compare-and-swap anchor.
2843fn publication_base_generation(store: &AtlasStore) -> Result<IndexGeneration, CliError> {
2844    Ok(store
2845        .index_publication()?
2846        .map_or(IndexGeneration::ZERO, |publication| publication.generation))
2847}
2848
2849/// Prepare a complete source/index batch without acquiring the `SQLite` writer.
2850fn stage_full_index_publication(
2851    store: &AtlasStore,
2852    plan: &ScanRuntimePlan,
2853    symbol_options: &SymbolBuildOptions,
2854    reuse_unchanged_symbols: bool,
2855    import_legacy_purposes: bool,
2856    control: &IndexWorkControl,
2857) -> Result<IndexPublicationBatch, CliError> {
2858    let base_generation = publication_base_generation(store)?;
2859    let contract_fingerprint = plan.publication_contract_fingerprint();
2860    let previous_hashes = reuse_unchanged_symbols
2861        .then(|| indexed_file_hashes(store))
2862        .transpose()?;
2863    let nodes = scan_repo_controlled(
2864        &plan.root,
2865        &plan.scan_options,
2866        ScanLimits::default(),
2867        control,
2868    )
2869    .map_err(|source| source_inspection_error(&plan.root, source))?;
2870    control.check(IndexWorkStage::Publication)?;
2871    let purpose_import = import_legacy_purposes
2872        .then(|| plan.purpose_import_snapshot_controlled(&nodes, control))
2873        .transpose()?;
2874    let protected_purpose_paths = protected_purpose_paths(&nodes, purpose_import.as_ref());
2875    let text_paths = nodes
2876        .iter()
2877        .filter(|node| node.kind == NodeKind::File)
2878        .map(|node| node.path.clone())
2879        .collect::<Vec<_>>();
2880    let text = stage_text_index_for_changed_paths_controlled(
2881        &plan.root,
2882        &nodes,
2883        plan.text_options,
2884        control,
2885    )?;
2886    let content_classifications = stage_file_content_classifications(&nodes, &text.rows);
2887    let retained_before_symbols =
2888        staged_publication_identity_bytes(&plan.root, &contract_fingerprint)
2889            .saturating_add(staged_string_bytes(&text_paths))
2890            .saturating_add(staged_node_bytes(&nodes))
2891            .saturating_add(staged_text_bytes(&text))
2892            .saturating_add(staged_file_content_classification_bytes(
2893                &content_classifications,
2894            ))
2895            .saturating_add(staged_purpose_bytes(purpose_import.as_ref()));
2896    let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
2897    let symbols = stage_symbols_for_nodes_with_limits(
2898        store,
2899        &plan.root,
2900        #[cfg(feature = "optional-parser-supervisor")]
2901        &plan.optional_parser_selection,
2902        &nodes,
2903        symbol_options,
2904        previous_hashes.as_ref(),
2905        None,
2906        &protected_purpose_paths,
2907        control,
2908        symbol_limits,
2909    )?;
2910    let scan_policy = RootScanPolicy::discover(&plan.root, &plan.scan_options, control)
2911        .map_err(|source| source_inspection_error(&plan.root, source))?;
2912    let graph = graph_projection::stage_full_repository_graph(
2913        store,
2914        &plan.root,
2915        base_generation,
2916        &nodes,
2917        &scan_policy,
2918        &symbols,
2919        control,
2920    )?;
2921    let structural_summaries = stage_structural_summaries_for_nodes_controlled(
2922        store,
2923        &nodes,
2924        &text.rows,
2925        Some(&symbols),
2926        &protected_purpose_paths,
2927        symbol_options.effective_workers(),
2928        control,
2929    )?;
2930    enforce_publication_staging_budget(
2931        retained_before_symbols
2932            .saturating_add(symbols.retained_bytes)
2933            .saturating_add(graph.retained_bytes())
2934            .saturating_add(structural_summaries.retained_bytes),
2935    )?;
2936    Ok(IndexPublicationBatch {
2937        base_generation,
2938        contract_fingerprint,
2939        root: plan.root.clone(),
2940        nodes: NodePublicationBatch::Full { nodes },
2941        purpose_import,
2942        text_paths,
2943        text,
2944        content_classifications,
2945        symbols,
2946        graph,
2947        structural_summaries,
2948    })
2949}
2950
2951/// Return paths whose reviewed or built-in purpose must suppress generated suggestions.
2952fn protected_purpose_paths(
2953    nodes: &[Node],
2954    purpose_import: Option<&PurposeImportSnapshot>,
2955) -> HashSet<String> {
2956    let indexed_paths = nodes
2957        .iter()
2958        .map(|node| node.path.as_str())
2959        .collect::<HashSet<_>>();
2960    let mut protected = BUILTIN_PROJECTATLAS_PURPOSES
2961        .iter()
2962        .filter(|(path, _purpose)| indexed_paths.contains(*path))
2963        .map(|(path, _purpose)| (*path).to_string())
2964        .collect::<HashSet<_>>();
2965    if let Some(snapshot) = purpose_import {
2966        protected.extend(
2967            snapshot
2968                .records
2969                .iter()
2970                .filter(|record| indexed_paths.contains(record.path.as_str()))
2971                .map(|record| record.path.clone()),
2972        );
2973    }
2974    protected
2975}
2976
2977/// Count retained node string bytes for one bounded in-memory publication batch.
2978fn staged_node_bytes(nodes: &[Node]) -> u64 {
2979    nodes.iter().fold(0_u64, |bytes, node| {
2980        bytes
2981            .saturating_add(node.path.len() as u64)
2982            .saturating_add(
2983                node.parent_path
2984                    .as_ref()
2985                    .map_or(0, |value| value.len() as u64),
2986            )
2987            .saturating_add(
2988                node.extension
2989                    .as_ref()
2990                    .map_or(0, |value| value.len() as u64),
2991            )
2992            .saturating_add(node.language.as_ref().map_or(0, |value| value.len() as u64))
2993            .saturating_add(
2994                node.content_hash
2995                    .as_ref()
2996                    .map_or(0, |value| value.len() as u64),
2997            )
2998    })
2999}
3000
3001/// Count retained persisted-text strings for one staged batch.
3002fn staged_text_bytes(text: &TextIndexRefresh) -> u64 {
3003    text.rows.iter().fold(0_u64, |bytes, row| {
3004        let bytes = bytes.saturating_add(row.path.len() as u64);
3005        row.text.as_ref().map_or(bytes, |text| {
3006            bytes
3007                .saturating_add(text.path.len() as u64)
3008                .saturating_add(
3009                    text.content_hash
3010                        .as_ref()
3011                        .map_or(0, |value| value.len() as u64),
3012                )
3013                .saturating_add(text.content.len() as u64)
3014        })
3015    })
3016}
3017
3018/// Derive one registry-owned or bounded-text fallback role per staged file.
3019fn stage_file_content_classifications(
3020    nodes: &[Node],
3021    text_rows: &[TextIndexRow],
3022) -> Vec<FileContentClassification> {
3023    let languages = nodes
3024        .iter()
3025        .filter(|node| node.kind == NodeKind::File)
3026        .map(|node| (node.path.as_str(), node.language.as_deref()))
3027        .collect::<HashMap<_, _>>();
3028    text_rows
3029        .iter()
3030        .map(|row| FileContentClassification {
3031            path: row.path.clone(),
3032            classification: content_classification(
3033                languages.get(row.path.as_str()).copied().flatten(),
3034                row.reason == TextIndexSkipReason::Indexed,
3035            ),
3036        })
3037        .collect()
3038}
3039
3040/// Count retained classification paths and stable enum spellings.
3041fn staged_file_content_classification_bytes(rows: &[FileContentClassification]) -> u64 {
3042    rows.iter().fold(0_u64, |bytes, row| {
3043        bytes
3044            .saturating_add(row.path.len() as u64)
3045            .saturating_add(row.classification.as_str().len() as u64)
3046    })
3047}
3048
3049/// Count retained legacy-purpose strings for one staged batch.
3050fn staged_purpose_bytes(purpose_import: Option<&PurposeImportSnapshot>) -> u64 {
3051    purpose_import.map_or(0, |snapshot| {
3052        snapshot
3053            .records
3054            .iter()
3055            .fold(snapshot.fingerprint.len() as u64, |bytes, record| {
3056                bytes
3057                    .saturating_add(record.path.len() as u64)
3058                    .saturating_add(record.summary.len() as u64)
3059            })
3060    })
3061}
3062
3063/// Count retained strings duplicated into one publication batch.
3064fn staged_string_bytes(values: &[String]) -> u64 {
3065    values.iter().fold(0_u64, |bytes, value| {
3066        bytes.saturating_add(value.len() as u64)
3067    })
3068}
3069
3070/// Count the selected root and derivation identity retained by one batch.
3071fn staged_publication_identity_bytes(root: &Path, contract_fingerprint: &str) -> u64 {
3072    (root.as_os_str().as_encoded_bytes().len() as u64)
3073        .saturating_add(contract_fingerprint.len() as u64)
3074}
3075
3076/// Restrict parser output to the remaining aggregate publication-staging budget.
3077fn symbol_limits_with_remaining_staging_bytes(
3078    retained_bytes: u64,
3079) -> Result<SymbolPublicationLimits, CliError> {
3080    enforce_publication_staging_budget(retained_bytes)?;
3081    Ok(SymbolPublicationLimits {
3082        output_bytes: SymbolPublicationLimits::STANDARD
3083            .output_bytes
3084            .min(MAX_PUBLICATION_STAGING_BYTES.saturating_sub(retained_bytes)),
3085        ..SymbolPublicationLimits::STANDARD
3086    })
3087}
3088
3089/// Fail before writer acquisition when retained publication state exceeds its budget.
3090fn enforce_publication_staging_budget(retained_bytes: u64) -> Result<(), CliError> {
3091    if retained_bytes > MAX_PUBLICATION_STAGING_BYTES {
3092        return Err(IndexWorkFailure::resource_limit(
3093            IndexWorkStage::Publication,
3094            IndexWorkResource::OutputBytes,
3095            MAX_PUBLICATION_STAGING_BYTES,
3096            retained_bytes,
3097        )
3098        .into());
3099    }
3100    Ok(())
3101}
3102
3103/// Build the complete expected source state after one normalized incremental delta.
3104fn expected_nodes_after_incremental(
3105    baseline_nodes: Vec<Node>,
3106    changed_nodes: &[Node],
3107    absent_paths: &[String],
3108) -> Vec<Node> {
3109    let absent_paths = absent_paths
3110        .iter()
3111        .map(String::as_str)
3112        .filter(|path| !matches!(*path, "" | "."))
3113        .collect::<HashSet<_>>();
3114    let mut expected = baseline_nodes
3115        .into_iter()
3116        .filter(|node| !repository_path_is_absent(&node.path, &absent_paths))
3117        .map(|node| (node.path.clone(), node))
3118        .collect::<BTreeMap<_, _>>();
3119    for node in changed_nodes {
3120        expected.insert(node.path.clone(), node.clone());
3121    }
3122    expected.into_values().collect()
3123}
3124
3125/// Match an exact absent repository key or one of its slash-delimited ancestors.
3126fn repository_path_is_absent(path: &str, absent_paths: &HashSet<&str>) -> bool {
3127    absent_paths.contains(path)
3128        || path
3129            .match_indices('/')
3130            .any(|(separator, _)| absent_paths.contains(&path[..separator]))
3131}
3132
3133/// Apply one fully prepared index batch in a short generation-checked transaction.
3134fn publish_index_batch(
3135    store: &mut AtlasStore,
3136    batch: IndexPublicationBatch,
3137    control: &IndexWorkControl,
3138) -> Result<IndexPublicationOutcome, CliError> {
3139    control.check(IndexWorkStage::Publication)?;
3140    let IndexPublicationBatch {
3141        base_generation,
3142        contract_fingerprint,
3143        root,
3144        nodes,
3145        purpose_import,
3146        text_paths,
3147        text,
3148        content_classifications,
3149        mut symbols,
3150        graph,
3151        structural_summaries,
3152    } = batch;
3153    graph.revalidate_document_targets(&root)?;
3154    let mut publication =
3155        store.begin_index_publication_from(&contract_fingerprint, base_generation)?;
3156    publication.set_project_root(&root)?;
3157    let indexed_nodes = match nodes {
3158        NodePublicationBatch::Full { nodes } => {
3159            publication.begin_scan_replacement()?;
3160            for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
3161                control.check(IndexWorkStage::Publication)?;
3162                publication.upsert_scan_node_batch(batch)?;
3163            }
3164            apply_file_content_classification_stage(
3165                &mut publication,
3166                &content_classifications,
3167                control,
3168            )?;
3169            control.check(IndexWorkStage::Publication)?;
3170            publication.finish_scan_replacement()?;
3171            nodes
3172        }
3173        NodePublicationBatch::Incremental {
3174            nodes,
3175            absent_paths,
3176            expected_nodes: _,
3177        } => {
3178            for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
3179                control.check(IndexWorkStage::Publication)?;
3180                publication.upsert_scan_node_batch(batch)?;
3181            }
3182            for batch in absent_paths.chunks(PUBLICATION_PATH_BATCH_SIZE) {
3183                control.check(IndexWorkStage::Publication)?;
3184                publication.mark_paths_absent(batch)?;
3185            }
3186            apply_file_content_classification_stage(
3187                &mut publication,
3188                &content_classifications,
3189                control,
3190            )?;
3191            nodes
3192        }
3193    };
3194    seed_builtin_projectatlas_purposes(&publication, &indexed_nodes)?;
3195    apply_text_index_stage(&mut publication, &text_paths, &text, control)?;
3196    let purpose_import = purpose_import.map_or_else(
3197        || Ok(PurposeImportReport::default()),
3198        |snapshot| apply_purpose_import_snapshot(&publication, &indexed_nodes, &snapshot, control),
3199    )?;
3200    apply_symbol_build_stage(&mut publication, &mut symbols, control)?;
3201    graph.apply(&mut publication, control)?;
3202    apply_structural_summary_stage(&mut publication, &structural_summaries, control)?;
3203    complete_index_publication(publication, control)?;
3204    Ok(IndexPublicationOutcome {
3205        purpose_import,
3206        text_index: text.report,
3207        structural_summaries: structural_summaries.report,
3208        symbols: symbols.report,
3209    })
3210}
3211
3212/// Apply staged file roles inside the parent generation transaction.
3213fn apply_file_content_classification_stage(
3214    publication: &mut IndexPublicationGuard<'_>,
3215    rows: &[FileContentClassification],
3216    control: &IndexWorkControl,
3217) -> Result<(), CliError> {
3218    for batch in rows.chunks(MAX_FILE_CONTENT_CLASSIFICATION_PATHS) {
3219        control.check(IndexWorkStage::Publication)?;
3220        publication.upsert_file_content_classification_batch(batch)?;
3221    }
3222    Ok(())
3223}
3224
3225/// Apply staged legacy-purpose rows without overwriting current reviewed intent.
3226fn apply_purpose_import_snapshot(
3227    store: &AtlasStore,
3228    nodes: &[Node],
3229    snapshot: &PurposeImportSnapshot,
3230    control: &IndexWorkControl,
3231) -> Result<PurposeImportReport, CliError> {
3232    let indexed_paths = nodes
3233        .iter()
3234        .map(|node| node.path.as_str())
3235        .collect::<HashSet<_>>();
3236    let mut report = PurposeImportReport::default();
3237    for record in &snapshot.records {
3238        control.check(IndexWorkStage::Publication)?;
3239        if !indexed_paths.contains(record.path.as_str()) {
3240            report.skipped_stale += 1;
3241            continue;
3242        }
3243        let Some(indexed) = store.load_node_by_path(&record.path)? else {
3244            report.skipped_stale += 1;
3245            continue;
3246        };
3247        if matches!(
3248            indexed.purpose.status,
3249            PurposeStatus::Approved | PurposeStatus::Stale
3250        ) {
3251            report.skipped_existing += 1;
3252            continue;
3253        }
3254        store.set_purpose(&record.path, &record.summary, PurposeSource::Imported)?;
3255        report.imported += 1;
3256    }
3257    Ok(report)
3258}
3259
3260/// Execute the full scan/index/symbol pipeline for a resolved project plan.
3261#[cfg(test)]
3262pub(crate) fn run_scan_pipeline(
3263    store: &mut AtlasStore,
3264    plan: &ScanRuntimePlan,
3265    symbol_options: &SymbolBuildOptions,
3266) -> Result<ScanReport, CliError> {
3267    let control = index_work_control(symbol_options);
3268    run_scan_pipeline_controlled(store, plan, symbol_options, &control)
3269}
3270
3271/// Execute the full pipeline under one cancellation and resource boundary.
3272pub(crate) fn run_scan_pipeline_controlled(
3273    store: &mut AtlasStore,
3274    plan: &ScanRuntimePlan,
3275    symbol_options: &SymbolBuildOptions,
3276    control: &IndexWorkControl,
3277) -> Result<ScanReport, CliError> {
3278    let bounded_control = bounded_index_work_control(control);
3279    let control = &bounded_control;
3280    control.check(IndexWorkStage::Publication)?;
3281    store.probe_index_publication_writer()?;
3282    let batch = stage_full_index_publication(store, plan, symbol_options, false, true, control)?;
3283    revalidate_staged_publication_inputs_with_purpose_snapshot(
3284        plan,
3285        batch.nodes.expected_nodes(),
3286        batch.purpose_import.as_ref(),
3287        control,
3288    )?;
3289    let outcome = publish_index_batch(store, batch, control)?;
3290    let overview = store.overview()?;
3291    Ok(ScanReport {
3292        overview,
3293        purpose_import: outcome.purpose_import,
3294        text_index: outcome.text_index,
3295        structural_summaries: outcome.structural_summaries,
3296        symbols: outcome.symbols,
3297    })
3298}
3299
3300/// Reconcile a copied worktree baseline through exact no-op, incremental, or full refresh.
3301pub(crate) fn reconcile_hydrated_index_controlled(
3302    store: &mut AtlasStore,
3303    plan: &ScanRuntimePlan,
3304    symbol_options: &SymbolBuildOptions,
3305    control: &IndexWorkControl,
3306) -> Result<(ScanReport, bool), CliError> {
3307    if !publication_contract_matches(store, plan)? {
3308        return run_scan_pipeline_controlled(store, plan, symbol_options, control)
3309            .map(|report| (report, false));
3310    }
3311    let assessment =
3312        detect_index_freshness_controlled(store, plan, ScanLimits::default(), control)?;
3313    let (refresh, source_unchanged) = match assessment.delta {
3314        None => (empty_index_refresh_report(plan.text_options), true),
3315        Some(delta) if delta.report.scope == IndexRefreshScope::Incremental => {
3316            let changes = WatchChangeSet {
3317                requires_full_scan: false,
3318                document_paths: delta.paths.clone(),
3319                paths: delta.paths,
3320            };
3321            let refresh = match refresh_index_for_changes_controlled(
3322                store,
3323                plan,
3324                &changes,
3325                symbol_options,
3326                control,
3327            ) {
3328                Ok(refresh) => refresh,
3329                Err(CliError::RefreshRequired(report))
3330                    if report.reason == IndexRefreshReason::DependencyClosureLimit =>
3331                {
3332                    refresh_index_controlled(store, plan, symbol_options, control)?
3333                }
3334                Err(error) => return Err(error),
3335            };
3336            (refresh, false)
3337        }
3338        Some(_delta) => (
3339            refresh_index_controlled(store, plan, symbol_options, control)?,
3340            false,
3341        ),
3342    };
3343    Ok((
3344        ScanReport {
3345            overview: store.overview()?,
3346            purpose_import: PurposeImportReport::default(),
3347            text_index: refresh.text_index,
3348            structural_summaries: refresh.structural_summaries,
3349            symbols: refresh.symbols,
3350        },
3351        source_unchanged,
3352    ))
3353}
3354
3355/// Rebuild symbol projections while keeping incomplete work non-queryable.
3356#[cfg(test)]
3357pub(crate) fn run_symbol_build_pipeline(
3358    store: &mut AtlasStore,
3359    plan: &ScanRuntimePlan,
3360    symbol_options: &SymbolBuildOptions,
3361    previous_hashes: Option<&HashMap<String, String>>,
3362) -> Result<SymbolBuildReport, CliError> {
3363    let control = index_work_control(symbol_options);
3364    run_symbol_build_pipeline_controlled(store, plan, symbol_options, previous_hashes, &control)
3365}
3366
3367/// Rebuild symbol projections under one cancellation and publication boundary.
3368pub(crate) fn run_symbol_build_pipeline_controlled(
3369    store: &mut AtlasStore,
3370    plan: &ScanRuntimePlan,
3371    symbol_options: &SymbolBuildOptions,
3372    previous_hashes: Option<&HashMap<String, String>>,
3373    control: &IndexWorkControl,
3374) -> Result<SymbolBuildReport, CliError> {
3375    let bounded_control = bounded_index_work_control(control);
3376    let control = &bounded_control;
3377    control.check(IndexWorkStage::SymbolParsing)?;
3378    verify_index_project_root(store, &plan.root)?;
3379    verify_index_publication(store, plan)?;
3380    control.check(IndexWorkStage::Publication)?;
3381    store.probe_index_publication_writer()?;
3382    let base_generation = publication_base_generation(store)?;
3383    let nodes = store
3384        .load_nodes()?
3385        .into_iter()
3386        .map(|indexed| indexed.node)
3387        .collect::<Vec<_>>();
3388    let contract_fingerprint = plan.publication_contract_fingerprint();
3389    let retained_before_symbols =
3390        staged_publication_identity_bytes(&plan.root, &contract_fingerprint)
3391            .saturating_add(staged_node_bytes(&nodes));
3392    let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
3393    let mut staged = stage_symbols_for_nodes_with_limits(
3394        store,
3395        &plan.root,
3396        #[cfg(feature = "optional-parser-supervisor")]
3397        &plan.optional_parser_selection,
3398        &nodes,
3399        symbol_options,
3400        previous_hashes,
3401        None,
3402        &HashSet::new(),
3403        control,
3404        symbol_limits,
3405    )?;
3406    let scan_policy = RootScanPolicy::discover(&plan.root, &plan.scan_options, control)
3407        .map_err(|source| source_inspection_error(&plan.root, source))?;
3408    let graph = graph_projection::stage_full_repository_graph(
3409        store,
3410        &plan.root,
3411        base_generation,
3412        &nodes,
3413        &scan_policy,
3414        &staged,
3415        control,
3416    )?;
3417    enforce_publication_staging_budget(
3418        retained_before_symbols
3419            .saturating_add(staged.retained_bytes)
3420            .saturating_add(graph.retained_bytes()),
3421    )?;
3422    revalidate_staged_publication_inputs_controlled(plan, &nodes, None, control)?;
3423    control.check(IndexWorkStage::Publication)?;
3424    let mut publication =
3425        store.begin_index_projection_refresh_from(&contract_fingerprint, base_generation)?;
3426    apply_symbol_build_stage(&mut publication, &mut staged, control)?;
3427    graph.apply(&mut publication, control)?;
3428    complete_index_publication(publication, control)?;
3429    Ok(staged.report)
3430}
3431
3432/// One optional telemetry identity owned by a CLI invocation or MCP process.
3433#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3434pub(crate) struct UsageRuntimeInstance {
3435    /// Opaque runtime-owned identity persisted with usage events.
3436    id: UsageInstanceId,
3437    /// Adapter lifecycle that owns sealing this identity.
3438    owner: UsageInstanceOwner,
3439}
3440
3441impl UsageRuntimeInstance {
3442    /// Create an opaque runtime identity when operating-system entropy is available.
3443    #[must_use]
3444    pub(crate) fn new(owner: UsageInstanceOwner) -> Option<Self> {
3445        let mut bytes = [0u8; 16];
3446        getrandom::fill(&mut bytes).ok()?;
3447        UsageInstanceId::from_bytes(bytes)
3448            .ok()
3449            .map(|id| Self { id, owner })
3450    }
3451
3452    /// Record one event using the lifecycle implied by this runtime owner.
3453    fn record(
3454        self,
3455        store: &AtlasStore,
3456        event: &projectatlas_core::telemetry::UsageEvent,
3457    ) -> Result<(), CliError> {
3458        store.record_usage_for_instance(
3459            self.id,
3460            self.owner,
3461            event,
3462            matches!(self.owner, UsageInstanceOwner::CliInvocation),
3463        )?;
3464        Ok(())
3465    }
3466
3467    /// Record one event under an exact worktree origin.
3468    pub(crate) fn record_for_worktree(
3469        self,
3470        store: &AtlasStore,
3471        registration_id: i64,
3472        event: &projectatlas_core::telemetry::UsageEvent,
3473    ) -> Result<(), CliError> {
3474        store.record_usage_for_worktree_instance(
3475            self.id,
3476            self.owner,
3477            registration_id,
3478            event,
3479            false,
3480        )?;
3481        Ok(())
3482    }
3483
3484    /// Seal this runtime instance in one selected project database.
3485    pub(crate) fn seal(self, store: &AtlasStore) -> Result<(), CliError> {
3486        store.seal_usage_instance(self.id)?;
3487        Ok(())
3488    }
3489}
3490
3491/// Record a usage event from a fast baseline estimate and actual atlas payload.
3492pub(crate) fn record_usage_estimate(
3493    store: &AtlasStore,
3494    usage_instance: Option<UsageRuntimeInstance>,
3495    session: &str,
3496    command: &str,
3497    path: Option<String>,
3498    query: Option<String>,
3499    estimated_without_projectatlas: usize,
3500    projectatlas_text: &str,
3501) -> Result<(), CliError> {
3502    record_usage_estimate_with_context(
3503        store,
3504        usage_instance,
3505        session,
3506        command,
3507        path,
3508        query,
3509        estimated_without_projectatlas,
3510        projectatlas_text,
3511        TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
3512        TOKEN_BASELINE_SELECTED_CANDIDATES,
3513        TOKEN_CONFIDENCE_INFERRED,
3514    )
3515}
3516
3517/// Record a usage event from a fast baseline estimate and explicit baseline semantics.
3518#[allow(clippy::too_many_arguments)]
3519pub(crate) fn record_usage_estimate_with_context(
3520    store: &AtlasStore,
3521    usage_instance: Option<UsageRuntimeInstance>,
3522    session: &str,
3523    command: &str,
3524    path: Option<String>,
3525    query: Option<String>,
3526    estimated_without_projectatlas: usize,
3527    projectatlas_text: &str,
3528    token_savings_bucket: &str,
3529    baseline_kind: &str,
3530    confidence: &str,
3531) -> Result<(), CliError> {
3532    let Some(usage_instance) = usage_instance.filter(|_| !telemetry_disabled()) else {
3533        return Ok(());
3534    };
3535    store.finish_index_read_snapshot()?;
3536    usage_instance.record(
3537        store,
3538        &usage_from_estimates_with_context(
3539            session,
3540            command,
3541            path,
3542            query,
3543            estimated_without_projectatlas,
3544            estimate_tokens(projectatlas_text),
3545            token_savings_bucket,
3546            baseline_kind,
3547            confidence,
3548        ),
3549    )?;
3550    Ok(())
3551}
3552
3553/// Record a broad directory-walk avoidance estimate.
3554pub(crate) fn record_directory_walk_usage_estimate(
3555    store: &AtlasStore,
3556    usage_instance: Option<UsageRuntimeInstance>,
3557    session: &str,
3558    command: &str,
3559    path: Option<String>,
3560    query: Option<String>,
3561    estimated_without_projectatlas: usize,
3562    projectatlas_text: &str,
3563) -> Result<(), CliError> {
3564    record_usage_estimate_with_context(
3565        store,
3566        usage_instance,
3567        session,
3568        command,
3569        path,
3570        query,
3571        estimated_without_projectatlas,
3572        projectatlas_text,
3573        TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
3574        TOKEN_BASELINE_DIRECTORY_WALK,
3575        TOKEN_CONFIDENCE_POLICY_ESTIMATE,
3576    )
3577}
3578
3579/// Record a usage event from baseline and emitted text unless telemetry is disabled.
3580pub(crate) fn record_usage_text(
3581    store: &AtlasStore,
3582    usage_instance: Option<UsageRuntimeInstance>,
3583    session: &str,
3584    command: &str,
3585    path: Option<String>,
3586    query: Option<String>,
3587    baseline_text: &str,
3588    projectatlas_text: &str,
3589) -> Result<(), CliError> {
3590    let Some(usage_instance) = usage_instance.filter(|_| !telemetry_disabled()) else {
3591        return Ok(());
3592    };
3593    store.finish_index_read_snapshot()?;
3594    usage_instance.record(
3595        store,
3596        &usage_from_text(
3597            session,
3598            command,
3599            path,
3600            query,
3601            baseline_text,
3602            projectatlas_text,
3603        ),
3604    )?;
3605    Ok(())
3606}
3607
3608/// Return whether telemetry writes are disabled for read-only review contexts.
3609pub(crate) fn telemetry_disabled() -> bool {
3610    truthy_env("PROJECTATLAS_NO_TELEMETRY")
3611}
3612
3613/// Estimate broad source tokens represented by indexed files with SQL aggregates.
3614pub(crate) fn estimated_source_tokens_for_indexed_files(
3615    store: &AtlasStore,
3616    folder: Option<&str>,
3617    file_pattern: Option<&str>,
3618) -> Result<usize, CliError> {
3619    let matcher = FilePathMatcher::new(file_pattern)?;
3620    let mut total = 0usize;
3621    store.visit_file_token_estimates(folder, |path, size_bytes| {
3622        if matcher.is_match(&path) {
3623            total =
3624                total.saturating_add(estimated_source_tokens_for_file_metadata(&path, size_bytes));
3625        }
3626        Ok(true)
3627    })?;
3628    Ok(total)
3629}
3630
3631/// Estimate source tokens for one indexed file without reading it.
3632pub(crate) fn estimated_source_tokens_for_file_node(node: &Node) -> usize {
3633    estimated_source_tokens_for_file_metadata(&node.path, node.size_bytes)
3634}
3635
3636/// Estimate source tokens for persisted file metadata.
3637pub(crate) fn estimated_source_tokens_for_file_metadata(
3638    path: &str,
3639    size_bytes: Option<u64>,
3640) -> usize {
3641    size_bytes.map_or_else(|| estimate_tokens(path), byte_size_to_tokens)
3642}
3643
3644/// Estimate source tokens from a byte count with the shared token heuristic.
3645pub(crate) fn byte_size_to_tokens(bytes: u64) -> usize {
3646    let token_estimate = bytes.div_ceil(4);
3647    usize::try_from(token_estimate).unwrap_or(usize::MAX)
3648}
3649
3650/// Estimate source tokens from a searched byte count.
3651pub(crate) fn byte_count_to_tokens(bytes: usize) -> usize {
3652    if bytes == 0 { 0 } else { bytes.div_ceil(4) }
3653}
3654
3655/// Load ranked folder nodes with concise reasons.
3656pub(crate) fn ranked_folder_nodes_with_reasons(
3657    store: &AtlasStore,
3658    query: &str,
3659    limit: usize,
3660) -> Result<Vec<projectatlas_core::RankedNode>, CliError> {
3661    Ok(load_ranked_folder_nodes_with_reasons(store, query, limit)?)
3662}
3663
3664/// Load ranked file nodes with concise reasons.
3665pub(crate) fn ranked_file_nodes_with_reasons(
3666    store: &AtlasStore,
3667    query: &str,
3668    folder: Option<&str>,
3669    file_pattern: Option<&str>,
3670    limit: usize,
3671    include_content: bool,
3672) -> Result<Vec<projectatlas_core::RankedNode>, CliError> {
3673    Ok(load_ranked_file_nodes_with_reasons(
3674        store,
3675        query,
3676        folder,
3677        file_pattern,
3678        limit,
3679        include_content,
3680    )?)
3681}
3682
3683/// Load ranked files with persisted classification and optional explicit selection.
3684pub(crate) fn classified_ranked_file_nodes_with_reasons(
3685    store: &AtlasStore,
3686    query: &str,
3687    folder: Option<&str>,
3688    file_pattern: Option<&str>,
3689    limit: usize,
3690    include_content: bool,
3691    content_selection: ContentSelection,
3692) -> Result<Vec<ClassifiedRankedNode>, CliError> {
3693    Ok(load_classified_ranked_file_nodes_with_reasons_service(
3694        store,
3695        query,
3696        folder,
3697        file_pattern,
3698        limit,
3699        include_content,
3700        content_selection,
3701    )?)
3702}
3703
3704/// Build a next-step report under one optional classified-content selection.
3705pub(crate) fn next_step_report_with_selection(
3706    store: &AtlasStore,
3707    query: &str,
3708    limit: Option<usize>,
3709    content_selection: ContentSelection,
3710) -> Result<NextStepReport, CliError> {
3711    Ok(build_next_report_with_selection_service(
3712        store,
3713        query,
3714        limit,
3715        content_selection,
3716    )?)
3717}
3718
3719/// Build the flattened agent-facing next-step payload.
3720pub(crate) fn next_step_report_payload(report: &NextStepReport) -> Value {
3721    json!({
3722        "query": &report.query,
3723        "folders": render_ranked_node_rows("folders", &report.folders),
3724        "files": render_classified_ranked_file_rows(&report.files),
3725        "suggestions": &report.suggestions,
3726    })
3727}
3728
3729/// Preserve existing ranked file rows while adding their persisted classification.
3730pub(crate) fn render_classified_ranked_file_rows(files: &[ClassifiedRankedNode]) -> Vec<Value> {
3731    files
3732        .iter()
3733        .map(|file| {
3734            let mut row = render_ranked_node_rows("files", std::slice::from_ref(&file.ranked))
3735                .into_iter()
3736                .next()
3737                .unwrap_or_else(|| json!({}));
3738            if let Some(object) = row.as_object_mut() {
3739                object.insert("classification".to_string(), json!(file.classification));
3740            }
3741            row
3742        })
3743        .collect()
3744}
3745
3746/// Preserve existing symbol rows while adding their owning file classification.
3747pub(crate) fn render_classified_symbol_rows(symbols: &[ClassifiedSymbol]) -> Vec<Value> {
3748    symbols
3749        .iter()
3750        .map(|classified| {
3751            let mut row = render_symbol_rows(std::slice::from_ref(&classified.symbol))
3752                .into_iter()
3753                .next()
3754                .unwrap_or_else(|| json!({}));
3755            if let Some(object) = row.as_object_mut() {
3756                object.insert(
3757                    "classification".to_string(),
3758                    json!(classified.classification),
3759                );
3760            }
3761            row
3762        })
3763        .collect()
3764}
3765
3766/// Agent-facing purpose curation queue with bounded health metadata.
3767#[derive(Debug, Serialize)]
3768#[allow(
3769    clippy::struct_excessive_bools,
3770    reason = "serialized queue paging and scope fields are independent wire facts"
3771)]
3772pub(crate) struct PurposeCurationPage {
3773    /// Selected project identity bound into every work key.
3774    pub(crate) project_instance_id: String,
3775    /// Active generation bound into every work key and state token.
3776    pub(crate) active_generation: u64,
3777    /// Host-owned task label for this bounded batch.
3778    pub(crate) task: String,
3779    /// Deterministic identity for the complete returned batch.
3780    pub(crate) work_key: String,
3781    /// Whether this page contains work a host or main agent can process.
3782    pub(crate) actionable: bool,
3783    /// Purpose policy scope; automatic handoffs are always low scope.
3784    pub(crate) curation_scope: &'static str,
3785    /// Findings after filters are applied.
3786    pub(crate) total: usize,
3787    /// Findings before filters are applied, after resolved findings are removed.
3788    pub(crate) unfiltered_total: usize,
3789    /// Findings returned in this page.
3790    pub(crate) returned: usize,
3791    /// Pagination start index used for this page.
3792    pub(crate) start_index: usize,
3793    /// Maximum findings requested for this page.
3794    pub(crate) limit: usize,
3795    /// Maximum allowed page size.
3796    pub(crate) max_limit: usize,
3797    /// Next start index when more rows are available.
3798    pub(crate) next_start_index: Option<usize>,
3799    /// Whether more rows are available.
3800    pub(crate) truncated: bool,
3801    /// Whether the queue is restricted to source-relevant paths.
3802    pub(crate) source_only: bool,
3803    /// Folder scope included in the queue.
3804    pub(crate) folder_scope: String,
3805    /// File scope included in the queue.
3806    pub(crate) file_scope: String,
3807    /// Applied category filter.
3808    pub(crate) category: String,
3809    /// Applied severity filter.
3810    pub(crate) severity: String,
3811    /// Applied path-prefix filter.
3812    pub(crate) path_prefix: String,
3813    /// Whether rows were intentionally omitted.
3814    pub(crate) summary_only: bool,
3815    /// Queue items that need agent inspection or approval.
3816    pub(crate) items: Vec<PurposeCurationItem>,
3817}
3818
3819/// One path that needs purpose curation.
3820#[derive(Debug, Serialize)]
3821pub(crate) struct PurposeCurationItem {
3822    /// Deterministic project/generation/task/path identity for duplicate coalescing.
3823    pub(crate) work_key: String,
3824    /// Opaque current-row token required for stale-safe conditional review.
3825    pub(crate) state_token: String,
3826    /// Finding severity.
3827    pub(crate) severity: String,
3828    /// Stable health finding id.
3829    pub(crate) id: String,
3830    /// Health finding category.
3831    pub(crate) category: String,
3832    /// Indexed repository-relative path.
3833    pub(crate) path: String,
3834    /// Related path for structural findings.
3835    pub(crate) related_path: String,
3836    /// Node kind when the path is still indexed.
3837    pub(crate) kind: String,
3838    /// Detected language for source files.
3839    pub(crate) language: String,
3840    /// Registry-owned content role when this item is a file; folders serialize `null` so TOON rows stay tabular.
3841    pub(crate) classification: Option<ContentClassification>,
3842    /// Current approved or suggested purpose text.
3843    pub(crate) purpose: String,
3844    /// Purpose lifecycle status.
3845    pub(crate) purpose_status: String,
3846    /// Purpose source.
3847    pub(crate) purpose_source: String,
3848    /// Whether an agent explicitly reviewed or set this purpose.
3849    pub(crate) purpose_agent_reviewed: bool,
3850    /// Priority for agent curation.
3851    pub(crate) review_priority: String,
3852    /// Stable reason explaining the priority.
3853    pub(crate) review_reason: String,
3854    /// Current deterministic content summary.
3855    pub(crate) content_summary: String,
3856    /// Recommended agent action.
3857    pub(crate) recommendation: String,
3858}
3859
3860/// One agent-reviewed purpose update requested by a batch review.
3861#[derive(Clone, Debug, Deserialize, Serialize)]
3862pub(crate) struct PurposeReviewRequest {
3863    /// Indexed repository-relative path.
3864    pub(crate) path: String,
3865    /// Agent-reviewed purpose one-liner. Required for generated suggestions.
3866    #[serde(default)]
3867    pub(crate) purpose: Option<String>,
3868    /// Confirm the currently stored non-generated purpose after inspection.
3869    #[serde(default)]
3870    pub(crate) confirm_existing: bool,
3871    /// Queue task copied from the selected purpose-curation batch.
3872    #[serde(default)]
3873    pub(crate) task: Option<String>,
3874    /// Queue item work key copied without modification.
3875    #[serde(default)]
3876    pub(crate) work_key: Option<String>,
3877    /// Queue item state token copied without modification.
3878    #[serde(default)]
3879    pub(crate) state_token: Option<String>,
3880}
3881
3882/// Batch purpose review result.
3883#[derive(Debug, Serialize)]
3884pub(crate) struct PurposeReviewReport {
3885    /// Whether the review changed the database.
3886    pub(crate) applied: bool,
3887    /// Number of requested review rows.
3888    pub(crate) total: usize,
3889    /// Number of rows changed when applied or that would change in dry-run.
3890    pub(crate) changed: usize,
3891    /// Number of rows skipped because they were already agent-reviewed with the same purpose.
3892    pub(crate) skipped: usize,
3893    /// Number of accepted, stale, or unavailable rows left unchanged.
3894    pub(crate) conflicts: usize,
3895    /// Number of rows that could not be reviewed.
3896    pub(crate) failed: usize,
3897    /// Per-path review details.
3898    pub(crate) items: Vec<PurposeReviewItem>,
3899}
3900
3901/// Per-path batch review result.
3902#[derive(Debug, Serialize)]
3903pub(crate) struct PurposeReviewItem {
3904    /// Indexed repository-relative path.
3905    pub(crate) path: String,
3906    /// Registry-owned content role when this item is a file; folders serialize `null` so TOON rows stay tabular.
3907    pub(crate) classification: Option<ContentClassification>,
3908    /// Action selected for this path.
3909    pub(crate) action: PurposeReviewAction,
3910    /// Current purpose lifecycle status.
3911    pub(crate) current_status: String,
3912    /// Current purpose source.
3913    pub(crate) current_source: String,
3914    /// Purpose that will be or was written.
3915    pub(crate) purpose: String,
3916    /// Validation or persistence error.
3917    pub(crate) error: String,
3918}
3919
3920/// Stable purpose-review action values.
3921#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
3922#[serde(rename_all = "kebab-case")]
3923pub(crate) enum PurposeReviewAction {
3924    /// The item failed validation.
3925    Error,
3926    /// The existing reviewed purpose already matches.
3927    Skip,
3928    /// The reviewed purpose was applied.
3929    Review,
3930    /// The reviewed purpose would be applied in preview mode.
3931    WouldReview,
3932    /// Project, generation, task, path, or row state changed after queue selection.
3933    Stale,
3934    /// The path now carries accepted authored intent and was not overwritten.
3935    Accepted,
3936    /// The selected path is no longer active in the index.
3937    Unavailable,
3938}
3939
3940/// Validate and optionally apply a batch of agent-reviewed purpose records.
3941pub(crate) fn review_purposes(
3942    store: &AtlasStore,
3943    requests: &[PurposeReviewRequest],
3944    apply: bool,
3945) -> Result<PurposeReviewReport, CliError> {
3946    validate_purpose_review_admission(requests)?;
3947    let has_conditional_fields = requests.iter().any(has_conditional_purpose_review_field);
3948    if apply
3949        && has_conditional_fields
3950        && !requests.iter().all(is_complete_conditional_purpose_review)
3951    {
3952        return Err(CliError::InvalidInput(
3953            "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"
3954                .to_string(),
3955        ));
3956    }
3957
3958    // Explicit correction remains item-oriented. Preflight every row and the
3959    // complete report before the first write so an admission failure cannot
3960    // partially apply an otherwise valid batch.
3961    if apply && !has_conditional_fields {
3962        let mut preview = collect_purpose_reviews(store, requests, false)?;
3963        hydrate_purpose_review_classifications(store, &mut preview)?;
3964        validate_purpose_review_report(&preview)?;
3965    }
3966
3967    let mut report = if apply && has_conditional_fields {
3968        apply_conditional_purpose_reviews(store, requests)?
3969    } else {
3970        collect_purpose_reviews(store, requests, apply)?
3971    };
3972    hydrate_purpose_review_classifications(store, &mut report)?;
3973    validate_purpose_review_report(&report)?;
3974    Ok(report)
3975}
3976
3977/// Add registry-owned file roles without allowing purpose writes to alter them.
3978fn hydrate_purpose_review_classifications(
3979    store: &AtlasStore,
3980    report: &mut PurposeReviewReport,
3981) -> Result<(), CliError> {
3982    let paths = report
3983        .items
3984        .iter()
3985        .map(|item| item.path.clone())
3986        .collect::<Vec<_>>();
3987    let file_paths = store
3988        .load_nodes_by_paths(&paths)?
3989        .into_iter()
3990        .filter(|node| node.node.kind == NodeKind::File)
3991        .map(|node| node.node.path)
3992        .collect::<Vec<_>>();
3993    let classifications = store
3994        .file_content_classifications_for_paths(&file_paths)?
3995        .into_iter()
3996        .map(|row| (row.path, row.classification))
3997        .collect::<HashMap<_, _>>();
3998    for item in &mut report.items {
3999        item.classification = classifications.get(&item.path).copied();
4000    }
4001    Ok(())
4002}
4003
4004/// Enforce shared CLI/MCP purpose-review request limits before database work.
4005pub(crate) fn validate_purpose_review_admission(
4006    requests: &[PurposeReviewRequest],
4007) -> Result<(), CliError> {
4008    if requests.is_empty() {
4009        return Err(CliError::InvalidInput(
4010            "purpose review input must contain at least one item".to_string(),
4011        ));
4012    }
4013    if requests.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
4014        return Err(CliError::InvalidInput(format!(
4015            "purpose review input contains {} items; maximum is {}",
4016            requests.len(),
4017            MAX_PURPOSE_CURATION_BATCH_ROWS
4018        )));
4019    }
4020
4021    let mut aggregate_bytes = 0usize;
4022    for (index, request) in requests.iter().enumerate() {
4023        validate_purpose_review_field(index, "path", &request.path, MAX_PURPOSE_REVIEW_PATH_BYTES)?;
4024        aggregate_bytes = aggregate_bytes
4025            .checked_add(request.path.len())
4026            .ok_or_else(|| purpose_review_input_too_large(usize::MAX))?;
4027        for (name, value) in [
4028            ("purpose", request.purpose.as_deref()),
4029            ("task", request.task.as_deref()),
4030            ("work_key", request.work_key.as_deref()),
4031            ("state_token", request.state_token.as_deref()),
4032        ] {
4033            if let Some(value) = value {
4034                validate_purpose_review_field(index, name, value, MAX_PURPOSE_REVIEW_FIELD_BYTES)?;
4035                aggregate_bytes = aggregate_bytes
4036                    .checked_add(value.len())
4037                    .ok_or_else(|| purpose_review_input_too_large(usize::MAX))?;
4038            }
4039        }
4040        if aggregate_bytes > MAX_PURPOSE_REVIEW_INPUT_BYTES {
4041            return Err(purpose_review_input_too_large(aggregate_bytes));
4042        }
4043    }
4044    Ok(())
4045}
4046
4047/// Validate one caller-controlled purpose-review string before retaining output.
4048fn validate_purpose_review_field(
4049    index: usize,
4050    name: &str,
4051    value: &str,
4052    maximum: usize,
4053) -> Result<(), CliError> {
4054    if value.len() > maximum {
4055        return Err(CliError::InvalidInput(format!(
4056            "purpose review item {index} field {name} contains {} bytes; maximum is {maximum}",
4057            value.len()
4058        )));
4059    }
4060    Ok(())
4061}
4062
4063/// Build the stable aggregate-byte admission failure.
4064fn purpose_review_input_too_large(actual: usize) -> CliError {
4065    CliError::InvalidInput(format!(
4066        "purpose review input contains {actual} aggregate string bytes; maximum is {MAX_PURPOSE_REVIEW_INPUT_BYTES}"
4067    ))
4068}
4069
4070/// Review one admitted item-oriented batch while bounding retained report data.
4071fn collect_purpose_reviews(
4072    store: &AtlasStore,
4073    requests: &[PurposeReviewRequest],
4074    apply: bool,
4075) -> Result<PurposeReviewReport, CliError> {
4076    let mut items = Vec::with_capacity(requests.len());
4077    let mut retained_bytes = 0usize;
4078    for request in requests {
4079        let item = review_purpose_request(store, request, apply)?;
4080        retained_bytes = retained_bytes
4081            .checked_add(purpose_review_item_bytes(&item)?)
4082            .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4083        if retained_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
4084            return Err(purpose_review_report_too_large(retained_bytes));
4085        }
4086        items.push(item);
4087    }
4088    Ok(summarize_purpose_review(requests.len(), apply, items))
4089}
4090
4091/// Return retained string bytes for one report row after per-field admission.
4092fn purpose_review_item_bytes(item: &PurposeReviewItem) -> Result<usize, CliError> {
4093    let mut total = 0usize;
4094    for (name, value, maximum) in [
4095        ("path", item.path.as_str(), MAX_PURPOSE_REVIEW_PATH_BYTES),
4096        (
4097            "current_status",
4098            item.current_status.as_str(),
4099            MAX_PURPOSE_REVIEW_FIELD_BYTES,
4100        ),
4101        (
4102            "current_source",
4103            item.current_source.as_str(),
4104            MAX_PURPOSE_REVIEW_FIELD_BYTES,
4105        ),
4106        (
4107            "purpose",
4108            item.purpose.as_str(),
4109            MAX_PURPOSE_REVIEW_FIELD_BYTES,
4110        ),
4111        (
4112            PURPOSE_REVIEW_REPORT_ERROR_FIELD,
4113            item.error.as_str(),
4114            MAX_PURPOSE_REVIEW_FIELD_BYTES,
4115        ),
4116    ] {
4117        if value.len() > maximum {
4118            return Err(CliError::InvalidInput(format!(
4119                "purpose review report field {name} contains {} bytes; maximum is {maximum}",
4120                value.len()
4121            )));
4122        }
4123        total = total
4124            .checked_add(value.len())
4125            .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4126    }
4127    total = total
4128        .checked_add(item.classification.map_or(0, |value| value.as_str().len()))
4129        .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4130    Ok(total)
4131}
4132
4133/// Enforce exact supported adapter output caps for one completed report.
4134fn validate_purpose_review_report(report: &PurposeReviewReport) -> Result<(), CliError> {
4135    let json_bytes = serde_json::to_string_pretty(report)?
4136        .len()
4137        .checked_add(1)
4138        .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4139    if json_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
4140        return Err(purpose_review_report_too_large(json_bytes));
4141    }
4142    let toon_bytes = render_purpose_review_report(report).len();
4143    if toon_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
4144        return Err(purpose_review_report_too_large(toon_bytes));
4145    }
4146    Ok(())
4147}
4148
4149/// Build the stable purpose-review report/output limit failure.
4150fn purpose_review_report_too_large(actual: usize) -> CliError {
4151    CliError::InvalidInput(format!(
4152        "purpose review report contains {actual} bytes; maximum is {MAX_PURPOSE_REVIEW_REPORT_BYTES}"
4153    ))
4154}
4155
4156/// Apply one host-selected conditional batch with one database writer transaction.
4157fn apply_conditional_purpose_reviews(
4158    store: &AtlasStore,
4159    requests: &[PurposeReviewRequest],
4160) -> Result<PurposeReviewReport, CliError> {
4161    let prepared = requests
4162        .iter()
4163        .map(|request| {
4164            let path = validated_repo_node_key(Path::new(&request.path)).map_err(|source| {
4165                CliError::InvalidInput(format!(
4166                    "invalid purpose review path {:?}: {source}",
4167                    request.path
4168                ))
4169            })?;
4170            let purpose = request
4171                .purpose
4172                .as_deref()
4173                .map(str::trim)
4174                .filter(|value| !value.is_empty())
4175                .ok_or_else(|| {
4176                    CliError::InvalidInput(
4177                        "conditional purpose review requires an explicit reviewed purpose"
4178                            .to_string(),
4179                    )
4180                })?
4181                .to_string();
4182            let task = request.task.clone().ok_or_else(|| {
4183                CliError::InvalidInput(
4184                    "conditional purpose review requires task, work_key, and state_token together"
4185                        .to_string(),
4186                )
4187            })?;
4188            let work_key = request.work_key.clone().ok_or_else(|| {
4189                CliError::InvalidInput(
4190                    "conditional purpose review requires task, work_key, and state_token together"
4191                        .to_string(),
4192                )
4193            })?;
4194            let state_token = request.state_token.clone().ok_or_else(|| {
4195                CliError::InvalidInput(
4196                    "conditional purpose review requires task, work_key, and state_token together"
4197                        .to_string(),
4198                )
4199            })?;
4200            Ok((
4201                path.clone(),
4202                purpose.clone(),
4203                PurposeConditionalApplyRequest {
4204                    task,
4205                    path,
4206                    work_key,
4207                    state_token,
4208                    purpose,
4209                },
4210            ))
4211        })
4212        .collect::<Result<Vec<_>, CliError>>()?;
4213    let database_requests = prepared
4214        .iter()
4215        .map(|(_, _, request)| request.clone())
4216        .collect::<Vec<_>>();
4217    let results = store.conditionally_set_purposes(&database_requests)?;
4218    let items = prepared
4219        .into_iter()
4220        .zip(results)
4221        .map(|((path, purpose, _), result)| {
4222            debug_assert_eq!(path, result.path);
4223            PurposeReviewItem {
4224                path,
4225                classification: None,
4226                action: conditional_purpose_review_action(result.state, true),
4227                current_status: result
4228                    .current_purpose
4229                    .as_ref()
4230                    .map(|purpose| purpose.status.to_string())
4231                    .unwrap_or_default(),
4232                current_source: result
4233                    .current_purpose
4234                    .as_ref()
4235                    .map(|purpose| purpose.source.to_string())
4236                    .unwrap_or_default(),
4237                purpose,
4238                error: String::new(),
4239            }
4240        })
4241        .collect::<Vec<_>>();
4242    Ok(summarize_purpose_review(requests.len(), true, items))
4243}
4244
4245/// Return whether a row carries one complete queue-bound conditional review.
4246fn is_complete_conditional_purpose_review(request: &PurposeReviewRequest) -> bool {
4247    request.task.is_some()
4248        && request.work_key.is_some()
4249        && request.state_token.is_some()
4250        && request
4251            .purpose
4252            .as_deref()
4253            .is_some_and(|purpose| !purpose.trim().is_empty())
4254}
4255
4256/// Return whether a row attempts to use queue-bound conditional review.
4257fn has_conditional_purpose_review_field(request: &PurposeReviewRequest) -> bool {
4258    request.task.is_some() || request.work_key.is_some() || request.state_token.is_some()
4259}
4260
4261/// Aggregate stable batch counters from per-path review outcomes.
4262fn summarize_purpose_review(
4263    total: usize,
4264    applied: bool,
4265    items: Vec<PurposeReviewItem>,
4266) -> PurposeReviewReport {
4267    let changed = items
4268        .iter()
4269        .filter(|item| {
4270            matches!(
4271                item.action,
4272                PurposeReviewAction::Review | PurposeReviewAction::WouldReview
4273            )
4274        })
4275        .count();
4276    let skipped = items
4277        .iter()
4278        .filter(|item| {
4279            matches!(
4280                item.action,
4281                PurposeReviewAction::Skip | PurposeReviewAction::Accepted
4282            )
4283        })
4284        .count();
4285    let conflicts = items
4286        .iter()
4287        .filter(|item| {
4288            matches!(
4289                item.action,
4290                PurposeReviewAction::Stale
4291                    | PurposeReviewAction::Accepted
4292                    | PurposeReviewAction::Unavailable
4293            )
4294        })
4295        .count();
4296    let failed = items.iter().filter(|item| !item.error.is_empty()).count();
4297    PurposeReviewReport {
4298        applied,
4299        total,
4300        changed,
4301        skipped,
4302        conflicts,
4303        failed,
4304        items,
4305    }
4306}
4307
4308/// Validate and optionally apply one agent-reviewed purpose record.
4309fn review_purpose_request(
4310    store: &AtlasStore,
4311    request: &PurposeReviewRequest,
4312    apply: bool,
4313) -> Result<PurposeReviewItem, CliError> {
4314    let path = validated_repo_node_key(Path::new(&request.path)).map_err(|source| {
4315        CliError::InvalidInput(format!(
4316            "invalid purpose review path {:?}: {source}",
4317            request.path
4318        ))
4319    })?;
4320    let conditional = match (
4321        request.task.as_deref(),
4322        request.work_key.as_deref(),
4323        request.state_token.as_deref(),
4324    ) {
4325        (Some(task), Some(work_key), Some(state_token)) => Some((task, work_key, state_token)),
4326        (None, None, None) => None,
4327        _ => {
4328            return Ok(PurposeReviewItem {
4329                path,
4330                classification: None,
4331                action: PurposeReviewAction::Error,
4332                current_status: String::new(),
4333                current_source: String::new(),
4334                purpose: request.purpose.clone().unwrap_or_default(),
4335                error:
4336                    "conditional purpose review requires task, work_key, and state_token together"
4337                        .to_string(),
4338            });
4339        }
4340    };
4341    if let Some((task, work_key, state_token)) = conditional {
4342        let reviewed_purpose = request
4343            .purpose
4344            .as_deref()
4345            .map(str::trim)
4346            .filter(|value| !value.is_empty());
4347        let Some(reviewed_purpose) = reviewed_purpose else {
4348            return Ok(PurposeReviewItem {
4349                path,
4350                classification: None,
4351                action: PurposeReviewAction::Error,
4352                current_status: String::new(),
4353                current_source: String::new(),
4354                purpose: String::new(),
4355                error: "conditional purpose review requires an explicit reviewed purpose"
4356                    .to_string(),
4357            });
4358        };
4359        let state = if apply {
4360            store.conditionally_set_purpose(task, &path, work_key, state_token, reviewed_purpose)?
4361        } else {
4362            preview_conditional_purpose_review(store, task, &path, work_key, state_token)?
4363        };
4364        let current = store.load_node_by_path(&path)?;
4365        return Ok(PurposeReviewItem {
4366            path,
4367            classification: None,
4368            action: conditional_purpose_review_action(state, apply),
4369            current_status: current
4370                .as_ref()
4371                .map(|node| node.purpose.status.to_string())
4372                .unwrap_or_default(),
4373            current_source: current
4374                .as_ref()
4375                .map(|node| node.purpose.source.to_string())
4376                .unwrap_or_default(),
4377            purpose: reviewed_purpose.to_string(),
4378            error: String::new(),
4379        });
4380    }
4381    let Some(indexed) = store.load_node_by_path(&path)? else {
4382        return Ok(PurposeReviewItem {
4383            path,
4384            classification: None,
4385            action: PurposeReviewAction::Error,
4386            current_status: String::new(),
4387            current_source: String::new(),
4388            purpose: request.purpose.clone().unwrap_or_default(),
4389            error: "path is not indexed".to_string(),
4390        });
4391    };
4392    let current_status = indexed.purpose.status.to_string();
4393    let current_source = indexed.purpose.source.to_string();
4394    let current_purpose = indexed.purpose.purpose.clone().unwrap_or_default();
4395    let explicit_purpose = request
4396        .purpose
4397        .as_deref()
4398        .map(str::trim)
4399        .filter(|value| !value.is_empty());
4400    let Some(reviewed_purpose) = explicit_purpose.or_else(|| {
4401        request
4402            .confirm_existing
4403            .then_some(current_purpose.as_str())
4404            .filter(|value| !value.trim().is_empty())
4405    }) else {
4406        return Ok(PurposeReviewItem {
4407            path,
4408            classification: None,
4409            action: PurposeReviewAction::Error,
4410            current_status,
4411            current_source,
4412            purpose: String::new(),
4413            error: "provide a reviewed purpose or set confirm_existing=true".to_string(),
4414        });
4415    };
4416
4417    if request.confirm_existing
4418        && explicit_purpose.is_none()
4419        && (indexed.purpose.status == PurposeStatus::Suggested
4420            || indexed.purpose.source == PurposeSource::Generated)
4421    {
4422        return Ok(PurposeReviewItem {
4423            path,
4424            classification: None,
4425            action: PurposeReviewAction::Error,
4426            current_status,
4427            current_source,
4428            purpose: current_purpose,
4429            error: "generated suggestions require an explicit reviewed purpose".to_string(),
4430        });
4431    }
4432
4433    let reviewed_purpose = reviewed_purpose.trim().to_string();
4434    let action = if indexed.purpose.agent_reviewed() && current_purpose == reviewed_purpose {
4435        PurposeReviewAction::Skip
4436    } else if apply {
4437        store.set_purpose(&path, &reviewed_purpose, PurposeSource::Agent)?;
4438        PurposeReviewAction::Review
4439    } else {
4440        PurposeReviewAction::WouldReview
4441    };
4442    Ok(PurposeReviewItem {
4443        path,
4444        classification: None,
4445        action,
4446        current_status,
4447        current_source,
4448        purpose: reviewed_purpose,
4449        error: String::new(),
4450    })
4451}
4452
4453/// Preview one conditional review against the current queue state without writing.
4454fn preview_conditional_purpose_review(
4455    store: &AtlasStore,
4456    task: &str,
4457    path: &str,
4458    work_key: &str,
4459    state_token: &str,
4460) -> Result<PurposeConditionalApplyState, CliError> {
4461    let batch = store.load_purpose_curation_batch(task, &[path.to_string()])?;
4462    if let Some(candidate) = batch.items.first() {
4463        return Ok(
4464            if candidate.work_key == work_key && candidate.state_token == state_token {
4465                PurposeConditionalApplyState::Applied
4466            } else {
4467                PurposeConditionalApplyState::Stale
4468            },
4469        );
4470    }
4471    Ok(store
4472        .load_node_by_path(path)?
4473        .map_or(PurposeConditionalApplyState::PathUnavailable, |_node| {
4474            PurposeConditionalApplyState::Accepted
4475        }))
4476}
4477
4478/// Map database conditional-apply state into the stable review action contract.
4479const fn conditional_purpose_review_action(
4480    state: PurposeConditionalApplyState,
4481    apply: bool,
4482) -> PurposeReviewAction {
4483    match state {
4484        PurposeConditionalApplyState::Applied if apply => PurposeReviewAction::Review,
4485        PurposeConditionalApplyState::Applied => PurposeReviewAction::WouldReview,
4486        PurposeConditionalApplyState::Stale => PurposeReviewAction::Stale,
4487        PurposeConditionalApplyState::Accepted => PurposeReviewAction::Accepted,
4488        PurposeConditionalApplyState::PathUnavailable => PurposeReviewAction::Unavailable,
4489    }
4490}
4491
4492/// Build a purpose curation queue from the bounded health page.
4493pub(crate) fn purpose_curation_page(
4494    store: &AtlasStore,
4495    query: &HealthQuery,
4496    task: &str,
4497) -> Result<PurposeCurationPage, CliError> {
4498    let page = store.purpose_curation_findings_page_current(query)?;
4499    let paths = page
4500        .findings
4501        .iter()
4502        .map(|finding| finding.path.clone())
4503        .collect::<Vec<_>>();
4504    let batch = store.load_purpose_curation_batch(task, &paths)?;
4505    let file_paths = batch
4506        .items
4507        .iter()
4508        .filter(|candidate| candidate.node.node.kind == NodeKind::File)
4509        .map(|candidate| candidate.node.node.path.clone())
4510        .collect::<Vec<_>>();
4511    let classifications = store
4512        .file_content_classifications_for_paths(&file_paths)?
4513        .into_iter()
4514        .map(|row| (row.path, row.classification))
4515        .collect::<HashMap<_, _>>();
4516    let project_instance_id = batch.project_instance_id.to_string();
4517    let active_generation = batch.active_generation.get();
4518    let task = batch.task.clone();
4519    let work_key = batch.work_key.clone();
4520    let candidates = batch
4521        .items
4522        .into_iter()
4523        .map(|candidate| (candidate.node.node.path.clone(), candidate))
4524        .collect::<HashMap<_, _>>();
4525    let items = page
4526        .findings
4527        .iter()
4528        .filter_map(|finding| {
4529            let candidate = candidates.get(&finding.path)?;
4530            let node = &candidate.node;
4531            let review_signal = purpose_review_signal(&node.node, &node.purpose);
4532            Some(PurposeCurationItem {
4533                work_key: candidate.work_key.clone(),
4534                state_token: candidate.state_token.clone(),
4535                severity: health_severity_name(finding.severity).to_string(),
4536                id: finding.id.clone(),
4537                category: finding.category.clone(),
4538                path: finding.path.clone(),
4539                related_path: finding.related_path.clone().unwrap_or_default(),
4540                kind: node.node.kind.to_string(),
4541                language: node.node.language.clone().unwrap_or_default(),
4542                classification: classifications.get(&finding.path).copied(),
4543                purpose: node.purpose.purpose.clone().unwrap_or_default(),
4544                purpose_status: node.purpose.status.to_string(),
4545                purpose_source: node.purpose.source.to_string(),
4546                purpose_agent_reviewed: node.purpose.agent_reviewed(),
4547                review_priority: review_signal.priority.to_string(),
4548                review_reason: review_signal.reason.to_string(),
4549                content_summary: node.summary.clone().unwrap_or_default(),
4550                recommendation: "Inspect bounded context, then use conditional purpose review with this task, work_key, and state_token."
4551                    .to_string(),
4552            })
4553        })
4554        .collect::<Vec<_>>();
4555    let returned = items.len();
4556    Ok(PurposeCurationPage {
4557        project_instance_id,
4558        active_generation,
4559        task,
4560        work_key,
4561        actionable: returned > 0,
4562        curation_scope: purpose_queue_curation_scope(query),
4563        total: page.total,
4564        unfiltered_total: page.unfiltered_total,
4565        returned,
4566        start_index: page.start_index,
4567        limit: page.limit,
4568        max_limit: MAX_HEALTH_LIMIT,
4569        next_start_index: health_next_start_index(&page),
4570        truncated: health_next_start_index(&page).is_some(),
4571        source_only: query.scope.is_source_focused(),
4572        folder_scope: purpose_queue_folder_scope(query).to_string(),
4573        file_scope: purpose_queue_file_scope(query).to_string(),
4574        category: query.category.clone().unwrap_or_default(),
4575        severity: query.severity.map_or("", health_severity_name).to_string(),
4576        path_prefix: query.path_prefix.clone().unwrap_or_default(),
4577        summary_only: query.summary_only,
4578        items,
4579    })
4580}
4581
4582/// Render a bounded health page as compact TOON.
4583pub(crate) fn render_health_page(page: &HealthFindingsPage, query: &HealthQuery) -> String {
4584    let rows = page
4585        .findings
4586        .iter()
4587        .map(|finding| {
4588            json!({
4589                "severity": health_severity_name(finding.severity),
4590                "id": finding.id,
4591                "category": finding.category,
4592                "path": finding.path,
4593                "related_path": finding.related_path.as_deref().unwrap_or(""),
4594                "message": finding.message,
4595                "recommendation": finding.recommendation,
4596            })
4597        })
4598        .collect::<Vec<_>>();
4599    encode_agent_payload(&json!({
4600        "health": {
4601            "total": page.total,
4602            "unfiltered_total": page.unfiltered_total,
4603            "returned": page.returned,
4604            "start_index": page.start_index,
4605            "limit": page.limit,
4606            "max_limit": MAX_HEALTH_LIMIT,
4607            "next_start_index": health_next_start_index(page),
4608            "truncated": health_next_start_index(page).is_some(),
4609            "summary_only": query.summary_only,
4610            "source_only": query.scope.is_source_focused(),
4611            "category": query.category.as_deref().unwrap_or(""),
4612            "severity": query.severity.map_or("", health_severity_name),
4613            "path_prefix": query.path_prefix.as_deref().unwrap_or(""),
4614        },
4615        "health_findings": rows,
4616    }))
4617}
4618
4619/// Render one bounded current coverage page as compact TOON.
4620pub(crate) fn render_coverage_report(report: &CoverageDiscoveryReport) -> String {
4621    encode_agent_payload(&json!({ "coverage": report }))
4622}
4623
4624/// Render a purpose curation queue as compact TOON.
4625pub(crate) fn render_purpose_curation_page(page: &PurposeCurationPage) -> String {
4626    encode_agent_payload(&json!({
4627        "purpose_curation": {
4628            "project_instance_id": page.project_instance_id,
4629            "active_generation": page.active_generation,
4630            "task": page.task,
4631            "work_key": page.work_key,
4632            "actionable": page.actionable,
4633            "curation_scope": page.curation_scope,
4634            "total": page.total,
4635            "unfiltered_total": page.unfiltered_total,
4636            "returned": page.returned,
4637            "start_index": page.start_index,
4638            "limit": page.limit,
4639            "max_limit": page.max_limit,
4640            "next_start_index": page.next_start_index,
4641            "truncated": page.truncated,
4642            "source_only": page.source_only,
4643            "folder_scope": page.folder_scope,
4644            "file_scope": page.file_scope,
4645            "category": page.category,
4646            "severity": page.severity,
4647            "path_prefix": page.path_prefix,
4648            "summary_only": page.summary_only,
4649        },
4650        "purpose_curation_items": page.items,
4651    }))
4652}
4653
4654/// Render a batch purpose review report as compact TOON.
4655pub(crate) fn render_purpose_review_report(report: &PurposeReviewReport) -> String {
4656    encode_agent_payload(&json!({
4657        "purpose_review": {
4658            "applied": report.applied,
4659            "total": report.total,
4660            "changed": report.changed,
4661            "skipped": report.skipped,
4662            "conflicts": report.conflicts,
4663            "failed": report.failed,
4664        },
4665        "purpose_review_items": report.items,
4666    }))
4667}
4668
4669/// Return the folder inclusion scope for purpose curation metadata.
4670fn purpose_queue_folder_scope(query: &HealthQuery) -> &'static str {
4671    match query.scope {
4672        HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "source_relevant",
4673        _ => "all",
4674    }
4675}
4676
4677/// Return the file inclusion scope for purpose curation metadata.
4678fn purpose_queue_file_scope(query: &HealthQuery) -> &'static str {
4679    match query.scope {
4680        HealthScope::PurposeDefault => "high_impact",
4681        HealthScope::PurposeWithAssets => "high_impact_and_assets",
4682        HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "all_source",
4683        HealthScope::All | HealthScope::PurposeStrict => "all",
4684    }
4685}
4686
4687/// Return the truthful curation tier selected by explicit queue scope flags.
4688fn purpose_queue_curation_scope(query: &HealthQuery) -> &'static str {
4689    match query.scope {
4690        HealthScope::PurposeDefault => "low",
4691        HealthScope::PurposeWithAssets => "low_with_assets",
4692        HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "medium",
4693        HealthScope::All | HealthScope::PurposeStrict => "strict",
4694    }
4695}
4696
4697/// Return a stable lowercase severity name.
4698pub(crate) fn health_severity_name(severity: Severity) -> &'static str {
4699    severity.as_str()
4700}
4701
4702/// Return the next start index for a bounded health page.
4703fn health_next_start_index(page: &HealthFindingsPage) -> Option<usize> {
4704    let page_width = page.limit.min(page.total.saturating_sub(page.start_index));
4705    let page_end = page.start_index.saturating_add(page_width);
4706    if page_width == 0 || page_end >= page.total {
4707        None
4708    } else {
4709        Some(page_end)
4710    }
4711}
4712
4713/// Estimate source tokens for repository paths referenced by symbols/relations.
4714pub(crate) fn estimated_source_tokens_for_paths<'a>(
4715    store: &AtlasStore,
4716    paths: impl Iterator<Item = &'a str>,
4717) -> Result<usize, CliError> {
4718    let mut seen = HashSet::new();
4719    let mut total = 0usize;
4720    for path in paths {
4721        if seen.insert(path.to_string()) {
4722            total = total.saturating_add(estimated_source_tokens_for_path(store, path)?);
4723        }
4724    }
4725    Ok(total)
4726}
4727
4728/// Estimate source tokens for one indexed path, falling back safely for stale rows.
4729pub(crate) fn estimated_source_tokens_for_path(
4730    store: &AtlasStore,
4731    path: &str,
4732) -> Result<usize, CliError> {
4733    if let Some(indexed) = store.load_node_by_path(path)?
4734        && indexed.node.kind == NodeKind::File
4735    {
4736        return Ok(estimated_source_tokens_for_file_node(&indexed.node));
4737    }
4738    Ok(read_indexed_file_content(store, path).map_or_else(
4739        |_| estimate_tokens(path),
4740        |content| estimate_tokens(&content),
4741    ))
4742}
4743
4744/// Persisted file-text index report.
4745#[derive(Clone, Debug, Serialize)]
4746pub(crate) struct TextIndexReport {
4747    /// File nodes considered for indexed text.
4748    pub(crate) candidates: usize,
4749    /// UTF-8 files persisted for `SQLite`-backed search.
4750    pub(crate) indexed: usize,
4751    /// Files skipped because text could not be decoded as UTF-8.
4752    pub(crate) binary_or_non_utf8: usize,
4753    /// Files skipped because they exceeded the configured text-index size cap.
4754    pub(crate) too_large: usize,
4755    /// Total files skipped from the persisted text index.
4756    pub(crate) skipped: usize,
4757    /// Maximum UTF-8 file size persisted into `SQLite` text search.
4758    pub(crate) max_bytes: u64,
4759    /// Source bytes stored in the text index.
4760    pub(crate) bytes: usize,
4761}
4762
4763/// Deterministic structural-summary refresh report.
4764#[derive(Clone, Debug, Default, Serialize)]
4765pub(crate) struct StructuralSummaryReport {
4766    /// Indexed files considered for structural summaries.
4767    pub(crate) candidates: usize,
4768    /// Files whose observed summaries were refreshed.
4769    pub(crate) summarized: usize,
4770    /// Existing observed summaries cleared because current content was not summarizable.
4771    pub(crate) cleared: usize,
4772    /// Files skipped because they exceeded the parser size limit.
4773    pub(crate) too_large: usize,
4774    /// Files skipped because content was not valid UTF-8.
4775    pub(crate) binary_or_non_utf8: usize,
4776    /// Generated purpose suggestions that still need agent review.
4777    pub(crate) purpose_suggestions: usize,
4778}
4779
4780/// Options controlling full-text persistence for `SQLite` search.
4781#[derive(Clone, Copy, Debug)]
4782pub(crate) struct TextIndexOptions {
4783    /// Maximum UTF-8 file size persisted into `SQLite` text search.
4784    pub(crate) max_bytes: u64,
4785}
4786
4787impl TextIndexOptions {
4788    /// Create text-index options from config and command overrides.
4789    pub(crate) fn new(max_bytes: u64) -> Self {
4790        Self { max_bytes }
4791    }
4792}
4793
4794/// Outcome of considering one file for persisted text search.
4795#[derive(Clone, Debug)]
4796pub(crate) struct TextIndexRow {
4797    /// Repository-relative path considered for text indexing.
4798    path: String,
4799    /// Persistable text row when the file is search-indexed.
4800    text: Option<IndexedFileText>,
4801    /// Indexing outcome for reporting.
4802    reason: TextIndexSkipReason,
4803}
4804
4805/// Persisted text refresh result plus rows reused by structural summarizers.
4806pub(crate) struct TextIndexRefresh {
4807    /// Aggregate report rendered to callers.
4808    pub(crate) report: TextIndexReport,
4809    /// Per-file text outcomes from the same scan batch.
4810    pub(crate) rows: Vec<TextIndexRow>,
4811}
4812
4813/// Text-index outcome categories.
4814#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4815pub(crate) enum TextIndexSkipReason {
4816    /// File text was persisted for search.
4817    Indexed,
4818    /// File exceeded the configured text-index size cap.
4819    TooLarge,
4820    /// File was binary or not valid UTF-8.
4821    BinaryOrNonUtf8,
4822}
4823
4824/// Symbol graph build report.
4825#[derive(Debug, Serialize)]
4826pub(crate) struct SymbolBuildReport {
4827    /// Indexed file candidates considered for symbols.
4828    pub(crate) candidates: usize,
4829    /// Files parsed during this build.
4830    pub(crate) parsed: usize,
4831    /// Files skipped because they were unchanged and already had symbols.
4832    pub(crate) unchanged: usize,
4833    /// Files skipped because they exceeded the configured size limit.
4834    pub(crate) too_large: usize,
4835    /// Files skipped because content was not valid UTF-8.
4836    pub(crate) binary_or_non_utf8: usize,
4837    /// Files skipped because the build deadline was reached.
4838    pub(crate) timed_out: usize,
4839    /// Worker thread count used for parser work.
4840    pub(crate) max_workers: usize,
4841    /// Optional timeout seconds requested for parser work.
4842    pub(crate) timeout_seconds: Option<u64>,
4843    /// Symbols persisted.
4844    pub(crate) symbols: usize,
4845    /// Relations persisted.
4846    pub(crate) relations: usize,
4847    /// Node summaries refreshed from symbol graphs.
4848    pub(crate) summaries: usize,
4849    /// Generated purpose suggestions that still need agent review.
4850    pub(crate) purpose_suggestions: usize,
4851}
4852
4853/// Filesystem and derived facts prepared before acquiring the `SQLite` writer.
4854struct IndexPublicationBatch {
4855    /// Complete publication generation observed before source preparation.
4856    base_generation: IndexGeneration,
4857    /// Derivation contract bound to every staged projection.
4858    contract_fingerprint: String,
4859    /// Canonical selected source root.
4860    root: PathBuf,
4861    /// Full or affected-path node mutation plus the final expected source state.
4862    nodes: NodePublicationBatch,
4863    /// Optional legacy-purpose inputs consumed by a full scan.
4864    purpose_import: Option<PurposeImportSnapshot>,
4865    /// Repository paths whose persisted source text must be replaced.
4866    text_paths: Vec<String>,
4867    /// Prepared persisted source-text rows and report.
4868    text: TextIndexRefresh,
4869    /// Prepared registry-owned or bounded-text fallback roles.
4870    content_classifications: Vec<FileContentClassification>,
4871    /// Prepared symbol graph, summary, and suggestion mutations.
4872    symbols: SymbolBuildStage,
4873    /// Prepared normalized repository graph and canonical resolution-key mutation.
4874    graph: graph_projection::StagedRepositoryGraph,
4875    /// Prepared structural-summary and suggestion mutations.
4876    structural_summaries: StructuralSummaryStage,
4877}
4878
4879/// Node mutations owned by one full or incremental publication.
4880enum NodePublicationBatch {
4881    /// Replace the complete observed source tree.
4882    Full {
4883        /// Complete staged node set.
4884        nodes: Vec<Node>,
4885    },
4886    /// Apply a bounded changed-path delta.
4887    Incremental {
4888        /// Added or modified nodes.
4889        nodes: Vec<Node>,
4890        /// Deleted paths and descendants to mark absent.
4891        absent_paths: Vec<String>,
4892        /// Complete expected source state used only for pre-publication revalidation.
4893        expected_nodes: Vec<Node>,
4894    },
4895}
4896
4897impl NodePublicationBatch {
4898    /// Return the complete source state that must still exist before publication.
4899    fn expected_nodes(&self) -> &[Node] {
4900        match self {
4901            Self::Full { nodes } => nodes,
4902            Self::Incremental { expected_nodes, .. } => expected_nodes,
4903        }
4904    }
4905}
4906
4907/// Reports produced after a staged batch commits successfully.
4908struct IndexPublicationOutcome {
4909    /// Legacy-purpose import decisions made against current authored state.
4910    purpose_import: PurposeImportReport,
4911    /// Persisted source-text report.
4912    text_index: TextIndexReport,
4913    /// Deterministic structural-summary report.
4914    structural_summaries: StructuralSummaryReport,
4915    /// Deep symbol graph report.
4916    symbols: SymbolBuildReport,
4917}
4918
4919/// Symbol mutations retained outside the `SQLite` writer transaction.
4920struct SymbolBuildStage {
4921    /// Aggregate symbol build report.
4922    report: SymbolBuildReport,
4923    /// Deterministically ordered projection changes.
4924    changes: Vec<SymbolProjectionChange>,
4925    /// Retained parser-output string bytes admitted by the resource boundary.
4926    retained_bytes: u64,
4927    /// Source identity details captured while sanitizing parser output.
4928    identity_admission: graph_projection::GraphIdentityAdmission,
4929}
4930
4931/// One closed symbol projection mutation.
4932enum SymbolProjectionChange {
4933    /// Persist one successfully parsed graph and its derived metadata.
4934    Parsed(SymbolParseSuccess),
4935    /// Clear stale symbol output for a skipped source file.
4936    Clear {
4937        /// Repository-relative path.
4938        path: String,
4939        /// Detected language used to preserve structural summaries where applicable.
4940        language: Option<String>,
4941    },
4942}
4943
4944/// Structural summary mutations retained outside the `SQLite` writer transaction.
4945struct StructuralSummaryStage {
4946    /// Aggregate structural-summary report.
4947    report: StructuralSummaryReport,
4948    /// Deterministically ordered summary changes.
4949    changes: Vec<StructuralSummaryChange>,
4950    /// Retained summary and suggestion string bytes.
4951    retained_bytes: u64,
4952}
4953
4954/// One file's closed structural-summary derivation.
4955#[derive(Default)]
4956struct StructuralSummaryDerivation {
4957    /// Optional persistence mutation for this file.
4958    change: Option<StructuralSummaryChange>,
4959    /// Observed summaries derived or reused.
4960    summarized: usize,
4961    /// Existing observed summaries cleared.
4962    cleared: usize,
4963    /// Files cleared because they exceeded the parser limit.
4964    too_large: usize,
4965    /// Files cleared because their content was not valid text.
4966    binary_or_non_utf8: usize,
4967    /// Unapproved purpose suggestions derived from observed summaries.
4968    purpose_suggestions: usize,
4969    /// String bytes retained until publication.
4970    retained_bytes: u64,
4971}
4972
4973/// One closed structural-summary projection mutation.
4974enum StructuralSummaryChange {
4975    /// Replace one observed summary and optional unreviewed purpose suggestion.
4976    Set {
4977        /// Repository-relative path.
4978        path: String,
4979        /// Deterministic observed summary.
4980        summary: String,
4981        /// Optional generated purpose suggestion.
4982        purpose_suggestion: Option<String>,
4983    },
4984    /// Clear a stale observed summary.
4985    Clear {
4986        /// Repository-relative path.
4987        path: String,
4988    },
4989}
4990
4991/// Watch command report.
4992#[derive(Debug, Serialize)]
4993pub(crate) struct WatchReport {
4994    /// Watcher mode.
4995    pub(crate) mode: String,
4996    /// Completed refresh cycles.
4997    pub(crate) cycles: usize,
4998    /// Whether the command ran a single refresh and exited.
4999    pub(crate) once: bool,
5000    /// Reason the watcher fell back from event mode, if any.
5001    #[serde(skip_serializing_if = "Option::is_none")]
5002    pub(crate) fallback_reason: Option<String>,
5003    /// Last persisted text search index report.
5004    pub(crate) text_index: TextIndexReport,
5005    /// Last structural summary refresh report.
5006    pub(crate) structural_summaries: StructuralSummaryReport,
5007    /// Last symbol refresh report.
5008    pub(crate) last_symbols: SymbolBuildReport,
5009}
5010
5011/// Debounced filesystem changes observed by watcher mode.
5012#[derive(Debug, Default)]
5013pub(crate) struct WatchChangeSet {
5014    /// Whether a full scan is required for correctness.
5015    requires_full_scan: bool,
5016    /// Relevant native paths from event batches.
5017    paths: HashSet<PathBuf>,
5018    /// Safe repository paths that can invalidate document-target resolution
5019    /// even when the target is not itself indexable.
5020    document_paths: HashSet<PathBuf>,
5021}
5022
5023impl WatchChangeSet {
5024    /// Return whether there is work to refresh.
5025    fn has_changes(&self) -> bool {
5026        self.requires_full_scan || !self.paths.is_empty() || !self.document_paths.is_empty()
5027    }
5028
5029    /// Merge another event batch into this set.
5030    fn merge(&mut self, other: Self) {
5031        self.requires_full_scan |= other.requires_full_scan;
5032        self.paths.extend(other.paths);
5033        self.document_paths.extend(other.document_paths);
5034    }
5035}
5036
5037/// Legacy purpose cleanup report.
5038#[derive(Debug, Serialize)]
5039pub(crate) struct LegacyPurposeReport {
5040    /// Whether files were modified.
5041    pub(crate) applied: bool,
5042    /// Number of `.purpose` files found.
5043    pub(crate) purpose_files_found: usize,
5044    /// Number of `.purpose` files removed.
5045    pub(crate) purpose_files_removed: usize,
5046    /// Source header candidates found.
5047    pub(crate) source_header_candidates: Vec<String>,
5048    /// Legacy purpose file paths.
5049    pub(crate) purpose_files: Vec<String>,
5050}
5051
5052/// Local settings report.
5053#[derive(Debug, Serialize)]
5054pub(crate) struct SettingsReport {
5055    /// Runtime cache directory that owns local `ProjectAtlas` state.
5056    pub(crate) cache_dir: PathStatus,
5057    /// `SQLite` database file status.
5058    pub(crate) db: PathStatus,
5059    /// `SQLite` write-ahead log file status.
5060    pub(crate) db_wal: PathStatus,
5061    /// `SQLite` shared-memory sidecar file status.
5062    pub(crate) db_shm: PathStatus,
5063    /// `SQLite` rollback journal sidecar file status.
5064    pub(crate) db_journal: PathStatus,
5065    /// Project-local MCP configuration file status.
5066    pub(crate) mcp_config: PathStatus,
5067    /// Config file used for map/lint/scan imports, when discovered.
5068    pub(crate) config_path: Option<String>,
5069    /// Repository root used by map/lint config.
5070    pub(crate) repo_root: Option<String>,
5071    /// Source that selected the repository root.
5072    pub(crate) root_detection_source: String,
5073    /// Whether config and DB root metadata agree.
5074    pub(crate) root_verified: bool,
5075    /// Root mismatches that should be fixed before trusting the binding.
5076    pub(crate) root_mismatches: Vec<String>,
5077    /// Generated map path.
5078    pub(crate) map_path: Option<String>,
5079    /// Non-source summary path.
5080    pub(crate) nonsource_files_path: Option<String>,
5081    /// Default output format.
5082    pub(crate) default_format: String,
5083    /// Default search case sensitivity.
5084    pub(crate) default_search_case_sensitive: bool,
5085    /// Source used by search commands.
5086    pub(crate) search_source: String,
5087    /// Maximum UTF-8 file size persisted into `SQLite` text search.
5088    pub(crate) text_index_max_bytes: u64,
5089    /// Watcher runtime status.
5090    pub(crate) watcher: WatchStatusReport,
5091    /// Current index statistics, if the index exists.
5092    pub(crate) index: Option<SettingsIndexStats>,
5093    /// Content-free telemetry retention and maintenance state, when the index exists.
5094    pub(crate) telemetry: Option<TelemetryRetentionState>,
5095    /// Read-only schema, publication, coverage, and `SQLite` operating diagnostics.
5096    pub(crate) database: DatabaseSettingsReport,
5097    /// Content-free language capability registry identity and derived counts.
5098    pub(crate) language_registry: LanguageRegistryReport,
5099    /// Closed classified-content navigation surface supported by this runtime.
5100    pub(crate) classified_navigation: SettingsClassifiedNavigationReport,
5101    /// Digest of the currently implemented provider-backed relation contract.
5102    pub(crate) semantic_relation_contract_digest: String,
5103    /// Versioned accepted relation-family inventory and lifecycle state.
5104    pub(crate) relation_family_inventory: RelationFamilyInventoryReport,
5105    /// Typed search-mode readiness without an implicit index build.
5106    pub(crate) search: SettingsSearchReport,
5107    /// Content-free optional parser-pack lifecycle state.
5108    pub(crate) optional_parser_pack: OptionalParserSettingsReport,
5109}
5110
5111/// Content-free classified navigation capabilities shared by CLI and MCP settings.
5112#[derive(Clone, Debug, Serialize)]
5113pub(crate) struct SettingsClassifiedNavigationReport {
5114    /// Closed persisted content roles.
5115    pub(crate) classifications: [ContentClassification; 5],
5116    /// Caller-visible explicit selection values.
5117    pub(crate) selections: [ContentSelection; 3],
5118    /// Canonical stored document relation.
5119    pub(crate) document_relation: &'static str,
5120    /// Inbound adapter view over the same stored relation.
5121    pub(crate) inbound_document_view: &'static str,
5122}
5123
5124/// Return the closed classified navigation contract without inspecting private data.
5125pub(crate) fn classified_navigation_capabilities() -> SettingsClassifiedNavigationReport {
5126    SettingsClassifiedNavigationReport {
5127        classifications: [
5128            ContentClassification::Source,
5129            ContentClassification::Documentation,
5130            ContentClassification::ConfigurationData,
5131            ContentClassification::OtherText,
5132            ContentClassification::Opaque,
5133        ],
5134        selections: [
5135            ContentSelection::Source,
5136            ContentSelection::Documentation,
5137            ContentSelection::Both,
5138        ],
5139        document_relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents).as_str(),
5140        inbound_document_view: "documented_by",
5141    }
5142}
5143
5144/// Readiness of one settings capability.
5145#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
5146#[serde(rename_all = "snake_case")]
5147pub(crate) enum SettingsCapabilityState {
5148    /// The capability has usable persisted state.
5149    Ready,
5150    /// The capability is implemented but the selected index has no rows yet.
5151    Empty,
5152    /// The capability is not available in the current runtime/index combination.
5153    Unavailable,
5154}
5155
5156/// Typed settings projection for one search mode.
5157#[derive(Debug, Serialize)]
5158pub(crate) struct SettingsSearchModeReport {
5159    /// Current readiness.
5160    pub(crate) state: SettingsCapabilityState,
5161}
5162
5163/// Typed lexical-search settings projection.
5164#[derive(Debug, Serialize)]
5165pub(crate) struct SettingsLexicalSearchReport {
5166    /// Current readiness.
5167    pub(crate) state: SettingsCapabilityState,
5168    /// Authoritative source searched by the correctness path.
5169    pub(crate) source: &'static str,
5170    /// Whether returned candidates are verified against persisted exact text.
5171    pub(crate) exact_verification: bool,
5172}
5173
5174/// Content-free readiness for supported and planned search modes.
5175#[derive(Debug, Serialize)]
5176pub(crate) struct SettingsSearchReport {
5177    /// Compatible default when no explicit mode is supplied.
5178    pub(crate) default_mode: &'static str,
5179    /// Deterministic persisted-text search.
5180    pub(crate) lexical: SettingsLexicalSearchReport,
5181    /// Optional FTS candidate acceleration.
5182    pub(crate) fts: SettingsSearchModeReport,
5183    /// Optional semantic retrieval.
5184    pub(crate) semantic: SettingsSearchModeReport,
5185    /// Optional lexical-complete hybrid retrieval.
5186    pub(crate) hybrid: SettingsSearchModeReport,
5187}
5188
5189/// Optional parser state present in both feature-enabled and default-core-only builds.
5190#[derive(Debug, Serialize)]
5191pub(crate) struct OptionalParserSettingsReport {
5192    /// Whether the supervised optional-parser lifecycle is compiled into this binary.
5193    pub(crate) compiled: bool,
5194    /// Bounded lifecycle metadata when the supervisor feature is present.
5195    #[cfg(feature = "optional-parser-supervisor")]
5196    pub(crate) lifecycle: OptionalParserPackLifecycleReport,
5197    /// Honest state for a binary compiled without the optional lifecycle.
5198    #[cfg(not(feature = "optional-parser-supervisor"))]
5199    pub(crate) state: &'static str,
5200}
5201
5202/// Filesystem status for a diagnostic path.
5203#[derive(Debug, Serialize)]
5204pub(crate) struct PathStatus {
5205    /// Lossless UTF-8 path, when one is available.
5206    pub(crate) path: Option<String>,
5207    /// Whether the path exists.
5208    pub(crate) exists: bool,
5209    /// File size in bytes when the path is an existing file.
5210    pub(crate) size_bytes: Option<u64>,
5211}
5212
5213/// Indexed state summary for settings diagnostics.
5214#[derive(Debug, Serialize)]
5215pub(crate) struct SettingsIndexStats {
5216    /// Canonical project root stored in the index metadata.
5217    pub(crate) project_root: Option<String>,
5218    /// Indexed file count.
5219    pub(crate) files: usize,
5220    /// Indexed folder count.
5221    pub(crate) folders: usize,
5222    /// Missing purpose count.
5223    pub(crate) missing_purposes: usize,
5224    /// Stale purpose count.
5225    pub(crate) stale_purposes: usize,
5226    /// Suggested purpose count.
5227    pub(crate) suggested_purposes: usize,
5228    /// Persisted searchable text rows.
5229    pub(crate) indexed_text_files: usize,
5230    /// Persisted searchable text bytes.
5231    pub(crate) indexed_text_bytes: usize,
5232    /// Persisted symbol count.
5233    pub(crate) symbols: usize,
5234    /// Persisted symbol relation count.
5235    pub(crate) relations: usize,
5236    /// Token telemetry event count.
5237    pub(crate) token_calls: usize,
5238    /// Unresolved structural health finding count.
5239    pub(crate) health_findings: usize,
5240}
5241
5242/// Watcher status report.
5243#[derive(Debug, Serialize)]
5244pub(crate) struct WatchStatusReport {
5245    /// Whether a watcher implementation is available in this binary.
5246    pub(crate) available: bool,
5247    /// Whether a watcher is active.
5248    pub(crate) active: bool,
5249    /// Watcher mode.
5250    pub(crate) mode: String,
5251    /// Whether event-backed watching is available.
5252    pub(crate) event_backend_available: bool,
5253    /// Operational recommendation.
5254    pub(crate) recommendation: String,
5255}
5256
5257/// Runtime index/cache cleanup report.
5258#[derive(Debug, Serialize)]
5259pub(crate) struct ResetIndexReport {
5260    /// Whether files were modified.
5261    pub(crate) applied: bool,
5262    /// Whether the command only previewed paths.
5263    pub(crate) dry_run: bool,
5264    /// Runtime files selected for cleanup.
5265    files: Vec<PathStatus>,
5266    /// Number of selected files removed.
5267    pub(crate) removed: usize,
5268}
5269
5270/// Build settings diagnostics shared by CLI and MCP.
5271pub(crate) fn build_settings_report(
5272    db: &Path,
5273    config_path: Option<&Path>,
5274    format: OutputFormat,
5275) -> Result<SettingsReport, CliError> {
5276    let absolute_db = absolute_path(db)?;
5277    let resolved_config = resolved_mcp_config_path(&absolute_db, config_path)?;
5278    let config = if let Some(config_path) = resolved_config.as_deref() {
5279        load_atlas_config(Some(config_path))?
5280    } else {
5281        let project_root = default_mcp_project_root(&absolute_db, None)?;
5282        load_atlas_config_for_root(&project_root)?
5283    };
5284    let config_root_identity = CanonicalProjectRoot::from_path(&config.root).ok();
5285    let cache_dir = absolute_db
5286        .parent()
5287        .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
5288    let database = database_settings_report(&absolute_db)?;
5289    let (index, telemetry, file_text_fts, db_root_identity, db_root_matches_config) =
5290        if database.schema.compatibility == DatabaseSchemaCompatibility::Current {
5291            let store = AtlasStore::open_read_only(&absolute_db)?;
5292            let db_root_identity = store.project_root_identity()?;
5293            let db_root_matches_config = config_root_identity
5294                .as_ref()
5295                .is_some_and(|config_root| store.project_root_identity_matches(config_root));
5296            let snapshot_publication = store.index_publication()?;
5297            if settings_publication_matches(
5298                database.publication.as_ref(),
5299                snapshot_publication.as_ref(),
5300            ) {
5301                (
5302                    Some(settings_index_stats(&store)?),
5303                    Some(store.telemetry_retention_state()?),
5304                    Some(store.file_text_fts_state()?),
5305                    db_root_identity,
5306                    db_root_matches_config,
5307                )
5308            } else {
5309                (None, None, None, db_root_identity, db_root_matches_config)
5310            }
5311        } else {
5312            (None, None, None, None, false)
5313        };
5314    let repo_root = config_root_identity
5315        .as_ref()
5316        .and_then(|root| root.display_string().ok());
5317    let mut root_mismatches = Vec::new();
5318    if let (Some(db_root), Some(config_root)) =
5319        (db_root_identity.as_ref(), config_root_identity.as_ref())
5320    {
5321        if !db_root_matches_config {
5322            root_mismatches.push(format!(
5323                "db root {:?} does not match config root {:?}",
5324                db_root
5325                    .display_string()
5326                    .unwrap_or_else(|_| "<native display unavailable>".to_string()),
5327                config_root
5328                    .display_string()
5329                    .unwrap_or_else(|_| "<native display unavailable>".to_string())
5330            ));
5331        }
5332    } else if db_root_identity.is_some() != config_root_identity.is_some() {
5333        root_mismatches
5334            .push("db root and config root do not both have a usable native identity".to_string());
5335    }
5336    let root_detection_source = if resolved_config.is_some() {
5337        "config"
5338    } else if db_root_identity.is_some() {
5339        "db"
5340    } else {
5341        "db-path-or-cwd"
5342    }
5343    .to_string();
5344    let lexical_publication_ready = database.publication.as_ref().is_some_and(|publication| {
5345        publication.state == IndexPublicationState::Complete
5346            && publication.generation != IndexGeneration::ZERO
5347            && publication.contract_fingerprint_state == DatabasePublicationContractState::Valid
5348    });
5349    let lexical_state = match (lexical_publication_ready, index.as_ref()) {
5350        (true, Some(stats)) if stats.indexed_text_files == 0 => SettingsCapabilityState::Empty,
5351        (true, Some(_)) => SettingsCapabilityState::Ready,
5352        _ => SettingsCapabilityState::Unavailable,
5353    };
5354    let fts_state = match (lexical_publication_ready, file_text_fts.as_ref()) {
5355        (true, Some(state)) if !state.synchronized => SettingsCapabilityState::Unavailable,
5356        (true, Some(state)) if state.source_rows == 0 => SettingsCapabilityState::Empty,
5357        (true, Some(_)) => SettingsCapabilityState::Ready,
5358        _ => SettingsCapabilityState::Unavailable,
5359    };
5360    let search = SettingsSearchReport {
5361        default_mode: "lexical",
5362        lexical: SettingsLexicalSearchReport {
5363            state: lexical_state,
5364            source: "persisted_text",
5365            exact_verification: true,
5366        },
5367        fts: SettingsSearchModeReport { state: fts_state },
5368        semantic: SettingsSearchModeReport {
5369            state: SettingsCapabilityState::Unavailable,
5370        },
5371        hybrid: SettingsSearchModeReport {
5372            state: SettingsCapabilityState::Unavailable,
5373        },
5374    };
5375    #[cfg(feature = "optional-parser-supervisor")]
5376    let optional_parser_pack = OptionalParserSettingsReport {
5377        compiled: true,
5378        lifecycle: OptionalParserPackLifecycle::new(&config.root, None)?.status()?,
5379    };
5380    #[cfg(not(feature = "optional-parser-supervisor"))]
5381    let optional_parser_pack = OptionalParserSettingsReport {
5382        compiled: false,
5383        state: "compiled_unavailable",
5384    };
5385    Ok(SettingsReport {
5386        cache_dir: path_status(&cache_dir)?,
5387        db: path_status(&absolute_db)?,
5388        db_wal: path_status(&db_sidecar_path(&absolute_db, "wal"))?,
5389        db_shm: path_status(&db_sidecar_path(&absolute_db, "shm"))?,
5390        db_journal: path_status(&db_sidecar_path(&absolute_db, "journal"))?,
5391        mcp_config: path_status(&mcp_config_path_for_db(&absolute_db))?,
5392        config_path: resolved_config
5393            .as_deref()
5394            .and_then(lossless_native_path_display),
5395        repo_root,
5396        root_detection_source,
5397        root_verified: root_mismatches.is_empty(),
5398        root_mismatches,
5399        map_path: lossless_native_path_display(&config.map_path),
5400        nonsource_files_path: lossless_native_path_display(&config.nonsource_files_path),
5401        default_format: format!("{format:?}").to_ascii_lowercase(),
5402        default_search_case_sensitive: false,
5403        search_source: "sqlite-file-text".to_string(),
5404        text_index_max_bytes: config.text_index_max_bytes(),
5405        watcher: watcher_status_report(false),
5406        index,
5407        telemetry,
5408        database,
5409        language_registry: language_registry_report(),
5410        classified_navigation: classified_navigation_capabilities(),
5411        semantic_relation_contract_digest: semantic_resolution_contract_digest(),
5412        relation_family_inventory: relation_family_inventory_report(),
5413        search,
5414        optional_parser_pack,
5415    })
5416}
5417
5418/// Reject mixed settings projections when publication changed between read snapshots.
5419fn settings_publication_matches(
5420    diagnostic: Option<&DatabasePublicationReport>,
5421    snapshot: Option<&IndexPublication>,
5422) -> bool {
5423    match (diagnostic, snapshot) {
5424        (None, None) => true,
5425        (Some(diagnostic), Some(snapshot))
5426            if diagnostic.state == snapshot.state
5427                && diagnostic.generation == snapshot.generation =>
5428        {
5429            match diagnostic.contract_fingerprint_state {
5430                DatabasePublicationContractState::Missing => {
5431                    snapshot.contract_fingerprint.is_none()
5432                }
5433                DatabasePublicationContractState::Valid => {
5434                    diagnostic.contract_fingerprint.as_deref()
5435                        == snapshot.contract_fingerprint.as_deref()
5436                }
5437                DatabasePublicationContractState::Invalid => false,
5438            }
5439        }
5440        _ => false,
5441    }
5442}
5443
5444/// Build index statistics for settings diagnostics.
5445pub(crate) fn settings_index_stats(store: &AtlasStore) -> Result<SettingsIndexStats, CliError> {
5446    let overview = store.overview()?;
5447    let health_findings = store.unresolved_health_finding_count_current()?;
5448    Ok(SettingsIndexStats {
5449        project_root: store
5450            .project_root_identity()?
5451            .and_then(|root| root.display_string().ok()),
5452        files: overview.files,
5453        folders: overview.folders,
5454        missing_purposes: overview.missing_purposes,
5455        stale_purposes: overview.stale_purposes,
5456        suggested_purposes: overview.suggested_purposes,
5457        indexed_text_files: store.file_text_count()?,
5458        indexed_text_bytes: store.file_text_byte_count()?,
5459        symbols: store.symbol_count()?,
5460        relations: store.symbol_relation_count()?,
5461        token_calls: store.token_overview(None)?.calls,
5462        health_findings,
5463    })
5464}
5465
5466/// Preview or remove local runtime index/cache files.
5467pub(crate) fn reset_index_files(
5468    db: &Path,
5469    apply: bool,
5470    dry_run: bool,
5471    include_mcp_config: bool,
5472) -> Result<ResetIndexReport, CliError> {
5473    let targets = reset_index_targets(db, include_mcp_config)?;
5474    let files = targets
5475        .iter()
5476        .map(|path| path_status(path))
5477        .collect::<Result<Vec<_>, _>>()?;
5478    let should_apply = apply && !dry_run;
5479    let mut removed = 0;
5480    if should_apply {
5481        for target in &targets {
5482            if target.is_file() {
5483                fs::remove_file(target).map_err(|source| CliError::Io {
5484                    path: target.clone(),
5485                    source,
5486                })?;
5487                removed += 1;
5488            }
5489        }
5490    }
5491    Ok(ResetIndexReport {
5492        applied: should_apply,
5493        dry_run: !should_apply,
5494        files,
5495        removed,
5496    })
5497}
5498
5499/// Remove registered reset targets only after staging and lifecycle revalidation.
5500pub(crate) fn reset_index_files_with_revalidation(
5501    db: &Path,
5502    include_mcp_config: bool,
5503    mut revalidate: impl FnMut() -> Result<(), CliError>,
5504) -> Result<ResetIndexReport, CliError> {
5505    let targets = reset_index_targets(db, include_mcp_config)?;
5506    let files = targets
5507        .iter()
5508        .map(|path| path_status(path))
5509        .collect::<Result<Vec<_>, _>>()?;
5510    if !targets.iter().any(|target| target.is_file()) {
5511        revalidate()?;
5512        return Ok(ResetIndexReport {
5513            applied: true,
5514            dry_run: false,
5515            files,
5516            removed: 0,
5517        });
5518    }
5519
5520    let absolute_db = absolute_path(db)?;
5521    let parent = absolute_db.parent().ok_or_else(|| {
5522        CliError::InvalidInput("reset database path has no parent directory".to_string())
5523    })?;
5524    let recovery = tempfile::Builder::new()
5525        .prefix("reset-recovery-")
5526        .tempdir_in(parent)
5527        .map_err(|source| CliError::Io {
5528            path: parent.to_path_buf(),
5529            source,
5530        })?
5531        .keep();
5532    let mut staged = Vec::with_capacity(targets.len());
5533    for (index, target) in targets.iter().enumerate() {
5534        if !target.is_file() {
5535            continue;
5536        }
5537        let recovery_file = recovery.join(format!("{index:02}.reset"));
5538        if let Err(source) = move_reset_file_noclobber(target, &recovery_file) {
5539            let operation = revalidate().err().unwrap_or_else(|| CliError::Io {
5540                path: target.clone(),
5541                source,
5542            });
5543            return Err(restore_staged_reset_files(&recovery, &staged, operation));
5544        }
5545        staged.push((target.clone(), recovery_file));
5546    }
5547
5548    if let Err(error) = revalidate() {
5549        return Err(restore_staged_reset_files(&recovery, &staged, error));
5550    }
5551
5552    let removed = staged.len();
5553    for (_, recovery_file) in &staged {
5554        if let Err(source) = fs::remove_file(recovery_file) {
5555            return Err(CliError::Io {
5556                path: recovery,
5557                source,
5558            });
5559        }
5560    }
5561    fs::remove_dir(&recovery).map_err(|source| CliError::Io {
5562        path: recovery,
5563        source,
5564    })?;
5565    Ok(ResetIndexReport {
5566        applied: true,
5567        dry_run: false,
5568        files,
5569        removed,
5570    })
5571}
5572
5573/// Return the fixed file inventory owned by one index reset.
5574fn reset_index_targets(db: &Path, include_mcp_config: bool) -> Result<Vec<PathBuf>, CliError> {
5575    let absolute_db = absolute_path(db)?;
5576    let mut targets = vec![
5577        absolute_db.clone(),
5578        db_sidecar_path(&absolute_db, "wal"),
5579        db_sidecar_path(&absolute_db, "shm"),
5580        db_sidecar_path(&absolute_db, "journal"),
5581    ];
5582    if include_mcp_config {
5583        targets.push(mcp_config_path_for_db(&absolute_db));
5584    }
5585    targets.sort();
5586    targets.dedup();
5587    Ok(targets)
5588}
5589
5590/// Move one existing reset target without overwriting another path.
5591fn move_reset_file_noclobber(source: &Path, destination: &Path) -> io::Result<()> {
5592    let temporary = tempfile::TempPath::try_from_path(source)?;
5593    match temporary.persist_noclobber(destination) {
5594        Ok(()) => Ok(()),
5595        Err(mut error) => {
5596            error.path.disable_cleanup(true);
5597            Err(error.error)
5598        }
5599    }
5600}
5601
5602/// Restore staged reset files without overwriting a replacement lifecycle.
5603fn restore_staged_reset_files(
5604    recovery: &Path,
5605    staged: &[(PathBuf, PathBuf)],
5606    operation: CliError,
5607) -> CliError {
5608    for (target, recovery_file) in staged.iter().rev() {
5609        if let Err(source) = move_reset_file_noclobber(recovery_file, target) {
5610            let kind = source.kind();
5611            return CliError::Io {
5612                path: recovery.to_path_buf(),
5613                source: io::Error::new(
5614                    kind,
5615                    format!("{operation}; reset recovery restore failed: {source}"),
5616                ),
5617            };
5618        }
5619    }
5620    // Preserve the owning failure; only an empty recovery directory can remain here.
5621    drop(fs::remove_dir(recovery));
5622    operation
5623}
5624
5625/// Resolve the config path that should travel with generated MCP configs.
5626pub(crate) fn resolved_mcp_config_path(
5627    db: &Path,
5628    config: Option<&Path>,
5629) -> Result<Option<PathBuf>, CliError> {
5630    let legacy_root = legacy_project_root_candidate(db)?;
5631    if let Some(path) = config {
5632        return Ok(Some(absolute_path(path)?));
5633    }
5634    let mut candidate_roots = Vec::new();
5635    if db.exists()
5636        && let Some(project_root) = read_project_root_identity_read_only(db)?
5637    {
5638        candidate_roots.push(project_root.into_path());
5639    }
5640    if let Some(project_root) = legacy_root {
5641        candidate_roots.push(project_root);
5642    }
5643    let absolute_db = absolute_path(db)?;
5644    if let Some(project_root) = project_root_from_db_path(&absolute_db) {
5645        candidate_roots.push(project_root);
5646    }
5647    for root in candidate_roots {
5648        for candidate in config_candidates_for_root(&root) {
5649            if candidate.exists() {
5650                return Ok(Some(absolute_path(&candidate)?));
5651            }
5652        }
5653    }
5654    Ok(None)
5655}
5656
5657/// Return supported config paths for one project root.
5658fn config_candidates_for_root(root: &Path) -> [PathBuf; 2] {
5659    [
5660        root.join(".projectatlas").join("config.toml"),
5661        root.join("projectatlas.toml"),
5662    ]
5663}
5664
5665/// Return an absolute path without requiring the target to exist.
5666pub(crate) fn absolute_path(path: &Path) -> Result<PathBuf, CliError> {
5667    if path.is_absolute() {
5668        return Ok(path.to_path_buf());
5669    }
5670    let current_dir = std::env::current_dir().map_err(|source| CliError::Io {
5671        path: PathBuf::from("."),
5672        source,
5673    })?;
5674    Ok(current_dir.join(path))
5675}
5676
5677/// Return a diagnostic status for one path.
5678pub(crate) fn path_status(path: &Path) -> Result<PathStatus, CliError> {
5679    let absolute = absolute_path(path)?;
5680    let metadata = fs::metadata(&absolute).ok();
5681    Ok(PathStatus {
5682        path: lossless_native_path_display(&absolute),
5683        exists: metadata.is_some(),
5684        size_bytes: metadata
5685            .as_ref()
5686            .and_then(|metadata| metadata.is_file().then_some(metadata.len())),
5687    })
5688}
5689
5690/// Return the path to a `SQLite` sidecar file.
5691pub(crate) fn db_sidecar_path(db: &Path, suffix: &str) -> PathBuf {
5692    let mut sidecar = db.as_os_str().to_os_string();
5693    sidecar.push(format!("-{suffix}"));
5694    PathBuf::from(sidecar)
5695}
5696
5697/// Return the project-local MCP config path associated with a database path.
5698pub(crate) fn mcp_config_path_for_db(db: &Path) -> PathBuf {
5699    db.parent().map_or_else(
5700        || PathBuf::from("projectatlas.mcp.json"),
5701        |parent| parent.join("projectatlas.mcp.json"),
5702    )
5703}
5704
5705/// Build a watcher status report from a lightweight runtime probe.
5706pub(crate) fn watcher_status_report(active: bool) -> WatchStatusReport {
5707    let notify_available = notify_runtime_available();
5708    let mode = if notify_available {
5709        WATCH_MODE_NOTIFY
5710    } else {
5711        WATCH_MODE_POLLING
5712    };
5713    let recommendation = if notify_available {
5714        "Run `projectatlas watch --once` for one refresh or `projectatlas watch` for event-backed refresh with portable polling fallback."
5715    } else {
5716        "Run `projectatlas watch --once` for one refresh or `projectatlas watch` for portable polling refresh."
5717    };
5718    WatchStatusReport {
5719        available: true,
5720        active,
5721        mode: mode.to_string(),
5722        event_backend_available: notify_available,
5723        recommendation: recommendation.to_string(),
5724    }
5725}
5726
5727/// Typed SQLite-owned portion of one lint result.
5728#[derive(Debug, Eq, PartialEq, Serialize)]
5729pub(crate) struct DatabaseLintReport {
5730    /// Purpose strictness used to select blocking health categories.
5731    pub(crate) purpose_level: String,
5732    /// Blocking findings returned within the bounded page.
5733    pub(crate) shown: usize,
5734    /// Total matching health findings before category blocking policy.
5735    pub(crate) total: usize,
5736    /// Typed blocking health findings.
5737    pub(crate) findings: Vec<HealthFinding>,
5738}
5739
5740impl DatabaseLintReport {
5741    /// Render the compatibility text from typed health findings.
5742    fn render_text(&self) -> Result<String, CliError> {
5743        if self.findings.is_empty() {
5744            return Ok(String::new());
5745        }
5746        let mut report = format!(
5747            "ProjectAtlas SQLite index health findings (purpose-level {}, showing {} of {}):\n",
5748            self.purpose_level, self.shown, self.total
5749        );
5750        for finding in &self.findings {
5751            writeln!(
5752                &mut report,
5753                "- [{}] {}: {}",
5754                finding.category, finding.path, finding.recommendation
5755            )
5756            .map_err(|source| CliError::Output(io::Error::other(source.to_string())))?;
5757        }
5758        Ok(report)
5759    }
5760}
5761
5762/// Shared typed lint result consumed by CLI and MCP adapters.
5763#[derive(Debug, Eq, PartialEq, Serialize)]
5764pub(crate) struct LintReport {
5765    /// Whether every selected lint check passed.
5766    pub(crate) ok: bool,
5767    /// CLI-compatible process exit code.
5768    pub(crate) exit_code: i32,
5769    /// Map and filesystem-owned lint facts.
5770    pub(crate) map: atlas_map::MapLintReport,
5771    /// SQLite-owned lint facts when an index exists.
5772    pub(crate) index: Option<DatabaseLintReport>,
5773    /// Human-readable compatibility rendering of the same facts.
5774    pub(crate) report: String,
5775}
5776
5777/// Build one shared typed lint result for CLI and MCP callers.
5778pub(crate) fn lint_project(
5779    config: &atlas_map::AtlasMapConfig,
5780    db: &Path,
5781    config_path: Option<&Path>,
5782    options: atlas_map::LintOptions,
5783    purpose_level: PurposeLintLevel,
5784) -> Result<LintReport, CliError> {
5785    let map = atlas_map::lint_map(config, options)?;
5786    let index = lint_database_if_present(db, &config.root, config_path, purpose_level)?;
5787    let exit_code = map.exit_code().max(i32::from(
5788        index
5789            .as_ref()
5790            .is_some_and(|report| !report.findings.is_empty()),
5791    ));
5792    let mut report = map.render_text();
5793    if let Some(index) = &index {
5794        let index_text = index.render_text()?;
5795        if !index_text.is_empty() {
5796            if !report.is_empty() && !report.ends_with('\n') {
5797                report.push('\n');
5798            }
5799            report.push_str(&index_text);
5800        }
5801    }
5802    Ok(LintReport {
5803        ok: exit_code == 0,
5804        exit_code,
5805        map,
5806        index,
5807        report,
5808    })
5809}
5810
5811/// Build typed lint facts for an existing `SQLite` index.
5812pub(crate) fn lint_database_if_present(
5813    db: &Path,
5814    root: &Path,
5815    config_path: Option<&Path>,
5816    purpose_level: PurposeLintLevel,
5817) -> Result<Option<DatabaseLintReport>, CliError> {
5818    match db.try_exists() {
5819        Ok(false) => return Ok(None),
5820        Ok(true) => {}
5821        Err(source) => {
5822            return Err(CliError::Io {
5823                path: db.to_path_buf(),
5824                source,
5825            });
5826        }
5827    }
5828    let control = standalone_index_work_control();
5829    let exact = open_exact_saved_source_matches_index_controlled(db, root, config_path, &control)?;
5830    let store = &exact.store;
5831    let query = purpose_level.health_query();
5832    let page = store.unresolved_health_findings_page_current(&query)?;
5833    let blocking = page
5834        .findings
5835        .into_iter()
5836        .filter(|finding| purpose_level.blocks_category(finding.category.as_str()))
5837        .collect::<Vec<_>>();
5838    let report = DatabaseLintReport {
5839        purpose_level: purpose_level.as_str().to_string(),
5840        shown: blocking.len(),
5841        total: page.total,
5842        findings: blocking,
5843    };
5844    store.finish_index_read_snapshot()?;
5845    Ok(Some(report))
5846}
5847
5848/// Purpose curation strictness used by `projectatlas lint`.
5849#[derive(Clone, Copy, Debug, Eq, PartialEq)]
5850pub(crate) enum PurposeLintLevel {
5851    /// Advisory first-pass curation scope for folders and high-impact files.
5852    Low,
5853    /// Also require agent review for all source files.
5854    Medium,
5855    /// Require agent review for every indexed file and folder.
5856    Strict,
5857}
5858
5859impl PurposeLintLevel {
5860    /// Stable CLI/report label.
5861    pub(crate) fn as_str(self) -> &'static str {
5862        match self {
5863            Self::Low => "low",
5864            Self::Medium => "medium",
5865            Self::Strict => "strict",
5866        }
5867    }
5868
5869    /// Convert lint strictness into the bounded DB health query.
5870    fn health_query(self) -> HealthQuery {
5871        let scope = match self {
5872            Self::Low => HealthScope::purpose_default(),
5873            Self::Medium => HealthScope::purpose_with_source_files(),
5874            Self::Strict => HealthScope::purpose_strict(),
5875        };
5876        HealthQuery {
5877            start_index: 0,
5878            limit: MAX_HEALTH_LIMIT,
5879            category: None,
5880            severity: Some(Severity::Warning),
5881            path_prefix: None,
5882            summary_only: false,
5883            scope,
5884        }
5885    }
5886
5887    /// Return whether a category should make lint fail at this strictness.
5888    fn blocks_category(self, category: &str) -> bool {
5889        match category {
5890            CATEGORY_STALE_PURPOSE
5891            | CATEGORY_DUPLICATE_PURPOSE
5892            | CATEGORY_REPEATED_TEMPORARY_FOLDER => true,
5893            CATEGORY_MISSING_PURPOSE
5894            | CATEGORY_SUGGESTED_PURPOSE_REVIEW
5895            | CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => self != Self::Low,
5896            _ => false,
5897        }
5898    }
5899}
5900
5901/// Return whether the platform watcher can be constructed in this process.
5902pub(crate) fn notify_runtime_available() -> bool {
5903    let (sender, _receiver) = mpsc::channel();
5904    RecommendedWatcher::new(
5905        move |result: notify::Result<Event>| {
5906            if sender.send(result).is_err() {
5907                // Receiver shutdown only means this status probe is done.
5908            }
5909        },
5910        Config::default(),
5911    )
5912    .is_ok()
5913}
5914
5915/// Options controlling source parsing during symbol graph builds.
5916#[derive(Clone, Copy, Debug)]
5917pub(crate) struct SymbolBuildOptions {
5918    /// Maximum file size parsed for symbols.
5919    pub(crate) max_bytes: u64,
5920    /// Optional maximum worker threads for parser work.
5921    max_workers: Option<usize>,
5922    /// Optional deadline for starting parser work.
5923    timeout: Option<Duration>,
5924    /// Serialized timeout value for reports.
5925    pub(crate) timeout_seconds: Option<u64>,
5926}
5927
5928impl SymbolBuildOptions {
5929    /// Create symbol build options from CLI/MCP values.
5930    pub(crate) fn new(
5931        max_bytes: u64,
5932        max_workers: Option<usize>,
5933        timeout_seconds: Option<u64>,
5934    ) -> Self {
5935        Self {
5936            max_bytes: max_bytes.min(MAX_SYMBOL_FILE_BYTES),
5937            max_workers: max_workers.filter(|workers| *workers > 0),
5938            timeout: timeout_seconds.map(Duration::from_secs),
5939            timeout_seconds,
5940        }
5941    }
5942
5943    /// Apply a worker ceiling without weakening a tighter caller limit.
5944    #[must_use]
5945    pub(crate) fn with_worker_ceiling(mut self, max_workers: usize) -> Self {
5946        let ceiling = max_workers.max(1);
5947        self.max_workers = Some(
5948            self.max_workers
5949                .map_or(ceiling, |workers| workers.min(ceiling)),
5950        );
5951        self
5952    }
5953
5954    /// Return the worker count that will be reported.
5955    pub(crate) fn reported_workers(self) -> usize {
5956        self.effective_workers()
5957    }
5958
5959    /// Derive the worker count from caller policy, host availability, and the safety ceiling.
5960    fn effective_workers(self) -> usize {
5961        let available = thread::available_parallelism().map_or(1, usize::from);
5962        self.max_workers
5963            .unwrap_or(available)
5964            .min(available)
5965            .min(INDEX_WORKER_SAFE_CEILING)
5966    }
5967
5968    /// Return whether the parser build deadline has elapsed.
5969    pub(crate) fn is_timed_out(self, started_at: Instant) -> bool {
5970        self.timeout
5971            .is_some_and(|timeout| started_at.elapsed() >= timeout)
5972    }
5973}
5974
5975/// Bound a worker pool by its work cardinality and runtime ceiling.
5976fn worker_count_for_work(work_items: usize, max_workers: usize) -> usize {
5977    work_items.min(max_workers.clamp(1, INDEX_WORKER_SAFE_CEILING))
5978}
5979
5980/// Aggregate rows and retained string bytes admitted by one symbol publication.
5981#[derive(Clone, Copy, Debug)]
5982struct SymbolPublicationLimits {
5983    /// Maximum symbol rows persisted by the operation.
5984    symbol_rows: u64,
5985    /// Maximum relation rows persisted by the operation.
5986    relation_rows: u64,
5987    /// Maximum retained parser-output string bytes persisted by the operation.
5988    output_bytes: u64,
5989}
5990
5991impl SymbolPublicationLimits {
5992    /// Durable process-safe limits used by CLI and MCP indexing operations.
5993    const STANDARD: Self = Self {
5994        symbol_rows: 2_000_000,
5995        relation_rows: 8_000_000,
5996        output_bytes: MAX_PUBLICATION_STAGING_BYTES,
5997    };
5998}
5999
6000/// Create one indexing boundary with the caller timeout capped by the safe default.
6001pub(crate) fn index_work_control(options: &SymbolBuildOptions) -> IndexWorkControl {
6002    IndexWorkControl::new(IndexCancellation::new(), options.timeout)
6003        .with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT)
6004        .with_worker_ceiling(options.effective_workers())
6005}
6006
6007/// Create a bounded work boundary for runtime paths without symbol options.
6008pub(crate) fn standalone_index_work_control() -> IndexWorkControl {
6009    IndexWorkControl::new(IndexCancellation::new(), Some(DEFAULT_INDEX_WORK_TIMEOUT))
6010}
6011
6012/// Apply the runtime's safe whole-operation deadline without weakening caller bounds.
6013fn bounded_index_work_control(control: &IndexWorkControl) -> IndexWorkControl {
6014    control.with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT)
6015}
6016
6017/// Source file queued for symbol parsing.
6018#[derive(Clone, Debug)]
6019pub(crate) struct SymbolParseJob {
6020    /// Repository-relative file path.
6021    pub(crate) path: String,
6022    /// Native absolute file path.
6023    native_path: PathBuf,
6024    /// Content hash captured by the staged filesystem scan.
6025    expected_content_hash: String,
6026    /// Detected language name.
6027    language: Option<String>,
6028    /// Existing node summary fallback.
6029    fallback_summary: Option<String>,
6030    /// Whether a generated purpose suggestion should be written or refreshed.
6031    purpose_needs_suggestion: bool,
6032}
6033
6034/// Successful parser output waiting for sequential DB persistence.
6035#[derive(Debug)]
6036pub(crate) struct SymbolParseSuccess {
6037    /// Repository-relative file path.
6038    pub(crate) path: String,
6039    /// Extracted symbol graph.
6040    graph: SymbolGraph,
6041    /// Bounded Markdown headings and explicit document candidates from the same parse.
6042    markdown_facts: Option<Box<MarkdownFacts>>,
6043    /// File-level parser kept independent from fact-level parser provenance.
6044    source_parser: ParserKind,
6045    /// Observed one-line source summary.
6046    summary: String,
6047    /// Whether the existing parser worker derived the summary through the structural adapter.
6048    summary_is_structural: bool,
6049    /// Optional generated purpose suggestion.
6050    purpose_suggestion: Option<String>,
6051}
6052
6053/// Outcome from one parser worker.
6054#[derive(Debug)]
6055pub(crate) enum SymbolParseOutcome {
6056    /// Source parsed successfully.
6057    Parsed(SymbolParseSuccess),
6058    /// File was skipped because it was not UTF-8 source text.
6059    BinaryOrNonUtf8 {
6060        /// Repository-relative file path.
6061        path: String,
6062    },
6063    /// A supported document failed its typed format, archive, or parser boundary.
6064    InvalidInput {
6065        /// Repository-relative file path.
6066        path: String,
6067        /// Bounded extraction diagnostic.
6068        message: String,
6069    },
6070    /// Source bytes changed after the staged filesystem scan.
6071    SourceChanged {
6072        /// Repository-relative file path.
6073        path: String,
6074    },
6075    /// Source read failed.
6076    Io {
6077        /// Native path that failed to read.
6078        path: PathBuf,
6079        /// Source IO error.
6080        source: io::Error,
6081    },
6082    /// Cooperative parsing work was canceled or reached its deadline.
6083    IndexWork(IndexWorkFailure),
6084}
6085
6086/// Failure from one cancellation-aware bounded source read.
6087#[derive(Debug)]
6088enum SourceReadFailure {
6089    /// The source file could not be opened or read.
6090    Io(io::Error),
6091    /// The shared indexing operation was canceled or reached its deadline.
6092    IndexWork(IndexWorkFailure),
6093    /// The source grew beyond the caller's admitted byte count.
6094    LimitExceeded {
6095        /// First observed byte count beyond the limit.
6096        observed: u64,
6097    },
6098}
6099
6100/// Read at most one admitted source-byte budget while checking cooperative stop state.
6101fn read_source_bytes_controlled(
6102    path: &Path,
6103    max_bytes: u64,
6104    stage: IndexWorkStage,
6105    control: &IndexWorkControl,
6106) -> Result<Vec<u8>, SourceReadFailure> {
6107    control.check(stage).map_err(SourceReadFailure::IndexWork)?;
6108    let mut file = fs::File::open(path).map_err(SourceReadFailure::Io)?;
6109    let mut bytes = Vec::new();
6110    let mut buffer = [0_u8; CONTROLLED_SOURCE_READ_BUFFER_BYTES];
6111    loop {
6112        control.check(stage).map_err(SourceReadFailure::IndexWork)?;
6113        let count = file.read(&mut buffer).map_err(SourceReadFailure::Io)?;
6114        if count == 0 {
6115            break;
6116        }
6117        let observed = u64::try_from(bytes.len())
6118            .unwrap_or(u64::MAX)
6119            .saturating_add(count as u64);
6120        if observed > max_bytes {
6121            return Err(SourceReadFailure::LimitExceeded { observed });
6122        }
6123        bytes.extend_from_slice(&buffer[..count]);
6124    }
6125    control.check(stage).map_err(SourceReadFailure::IndexWork)?;
6126    Ok(bytes)
6127}
6128
6129/// Build selected symbol graphs under explicit aggregate publication limits.
6130#[cfg(test)]
6131fn build_symbols_for_paths_with_limits(
6132    store: &mut AtlasStore,
6133    root: &Path,
6134    options: &SymbolBuildOptions,
6135    previous_hashes: Option<&HashMap<String, String>>,
6136    target_paths: Option<&HashSet<String>>,
6137    control: &IndexWorkControl,
6138    limits: SymbolPublicationLimits,
6139) -> Result<SymbolBuildReport, CliError> {
6140    let nodes = if let Some(paths) = target_paths {
6141        let mut sorted_paths = paths.iter().cloned().collect::<Vec<_>>();
6142        sorted_paths.sort();
6143        store
6144            .load_nodes_by_paths(&sorted_paths)?
6145            .into_iter()
6146            .map(|indexed| indexed.node)
6147            .collect::<Vec<_>>()
6148    } else {
6149        store
6150            .load_nodes()?
6151            .into_iter()
6152            .map(|indexed| indexed.node)
6153            .collect::<Vec<_>>()
6154    };
6155    #[cfg(feature = "optional-parser-supervisor")]
6156    let optional_parser_selection =
6157        OptionalParserPackLifecycle::new(root, None)?.derive_project_selection()?;
6158    let staged = stage_symbols_for_nodes_with_limits(
6159        store,
6160        root,
6161        #[cfg(feature = "optional-parser-supervisor")]
6162        &optional_parser_selection,
6163        &nodes,
6164        options,
6165        previous_hashes,
6166        target_paths,
6167        &HashSet::new(),
6168        control,
6169        limits,
6170    )?;
6171    let mut staged = staged;
6172    apply_symbol_build_stage(store, &mut staged, control)?;
6173    Ok(staged.report)
6174}
6175
6176/// Keep document admission at its parser-owned ceiling and ordinary source at caller policy.
6177fn source_input_byte_limit(
6178    path: &str,
6179    language: Option<&str>,
6180    observed: Option<u64>,
6181    source_limit: u64,
6182    stage: IndexWorkStage,
6183) -> Result<u64, IndexWorkFailure> {
6184    if document_format_for_path(path, language).is_none() {
6185        return Ok(source_limit);
6186    }
6187    let maximum = MAX_DOCUMENT_COMPRESSED_BYTES as u64;
6188    if let Some(observed) = observed.filter(|observed| *observed > maximum) {
6189        return Err(IndexWorkFailure::resource_limit(
6190            stage,
6191            IndexWorkResource::SourceBytes,
6192            maximum,
6193            observed,
6194        ));
6195    }
6196    Ok(maximum)
6197}
6198
6199/// Build selected symbol mutations without acquiring the `SQLite` writer.
6200#[allow(clippy::too_many_arguments)]
6201fn stage_symbols_for_nodes_with_limits(
6202    store: &AtlasStore,
6203    root: &Path,
6204    #[cfg(feature = "optional-parser-supervisor")]
6205    optional_parser_selection: &OptionalParserPackProjectSelection,
6206    nodes: &[Node],
6207    options: &SymbolBuildOptions,
6208    previous_hashes: Option<&HashMap<String, String>>,
6209    target_paths: Option<&HashSet<String>>,
6210    protected_purpose_paths: &HashSet<String>,
6211    control: &IndexWorkControl,
6212    limits: SymbolPublicationLimits,
6213) -> Result<SymbolBuildStage, CliError> {
6214    control.check(IndexWorkStage::SymbolParsing)?;
6215    #[cfg(feature = "optional-parser-supervisor")]
6216    let admit_optional_languages = optional_parser_selection.selection_key().is_some();
6217    #[cfg(not(feature = "optional-parser-supervisor"))]
6218    let admit_optional_languages = false;
6219    let root = root.canonicalize().map_err(|source| CliError::Io {
6220        path: root.to_path_buf(),
6221        source,
6222    })?;
6223    let considered_paths = nodes
6224        .iter()
6225        .filter(|node| node.kind == NodeKind::File)
6226        .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6227        .map(|node| node.path.clone())
6228        .collect::<Vec<_>>();
6229    let previously_parsed_paths = store.source_parse_metadata_paths_for_paths(&considered_paths)?;
6230    let mut candidate_paths = nodes
6231        .iter()
6232        .filter(|node| node.kind == NodeKind::File)
6233        .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6234        .filter(|node| {
6235            is_symbol_candidate_for_admission(
6236                &node.path,
6237                node.language.as_deref(),
6238                admit_optional_languages,
6239            )
6240        })
6241        .map(|node| node.path.clone())
6242        .collect::<Vec<_>>();
6243    candidate_paths.sort();
6244    let existing_nodes = store
6245        .load_nodes_by_paths(&candidate_paths)?
6246        .into_iter()
6247        .map(|indexed| (indexed.node.path.clone(), indexed))
6248        .collect::<HashMap<_, _>>();
6249    let symbol_counts = store.symbol_counts_for_paths(&candidate_paths)?;
6250    let mut report = SymbolBuildReport {
6251        candidates: 0,
6252        parsed: 0,
6253        unchanged: 0,
6254        too_large: 0,
6255        binary_or_non_utf8: 0,
6256        timed_out: 0,
6257        max_workers: options.reported_workers(),
6258        timeout_seconds: options.timeout_seconds,
6259        symbols: 0,
6260        relations: 0,
6261        summaries: 0,
6262        purpose_suggestions: 0,
6263    };
6264    let mut jobs = Vec::new();
6265    let mut changes = Vec::new();
6266    let mut output_bytes = 0_u64;
6267    for node in nodes
6268        .iter()
6269        .filter(|node| node.kind == NodeKind::File)
6270        .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6271        .filter(|node| {
6272            !is_symbol_candidate_for_admission(
6273                &node.path,
6274                node.language.as_deref(),
6275                admit_optional_languages,
6276            ) && previously_parsed_paths.contains(&node.path)
6277        })
6278    {
6279        output_bytes = checked_symbol_publication_usage(
6280            output_bytes,
6281            node.path.len() as u64 + node.language.as_ref().map_or(0, String::len) as u64,
6282            limits.output_bytes,
6283            IndexWorkResource::OutputBytes,
6284        )?;
6285        changes.push(SymbolProjectionChange::Clear {
6286            path: node.path.clone(),
6287            language: node.language.clone(),
6288        });
6289    }
6290    for node in nodes
6291        .iter()
6292        .filter(|node| node.kind == NodeKind::File)
6293        .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6294        .filter(|node| {
6295            is_symbol_candidate_for_admission(
6296                &node.path,
6297                node.language.as_deref(),
6298                admit_optional_languages,
6299            )
6300        })
6301    {
6302        control.check(IndexWorkStage::SymbolParsing)?;
6303        report.candidates += 1;
6304        let max_bytes = source_input_byte_limit(
6305            &node.path,
6306            node.language.as_deref(),
6307            node.size_bytes,
6308            options.max_bytes,
6309            IndexWorkStage::SymbolParsing,
6310        )?;
6311        if node.size_bytes.is_some_and(|size| size > max_bytes) {
6312            output_bytes = checked_symbol_publication_usage(
6313                output_bytes,
6314                node.path.len() as u64 + node.language.as_ref().map_or(0, String::len) as u64,
6315                limits.output_bytes,
6316                IndexWorkResource::OutputBytes,
6317            )?;
6318            changes.push(SymbolProjectionChange::Clear {
6319                path: node.path.clone(),
6320                language: node.language.clone(),
6321            });
6322            report.too_large += 1;
6323            continue;
6324        }
6325        let symbol_count = symbol_counts.get(&node.path).copied().unwrap_or_default();
6326        if node.content_hash.as_ref().is_some_and(|hash| {
6327            previous_hashes.and_then(|hashes| hashes.get(&node.path)) == Some(hash)
6328        }) {
6329            let has_source_index =
6330                symbol_count > 0 || store.load_source_parse_metadata(&node.path)?.is_some();
6331            if has_source_index {
6332                report.unchanged += 1;
6333                continue;
6334            }
6335        }
6336        let existing = existing_nodes.get(&node.path);
6337        jobs.push(SymbolParseJob {
6338            path: node.path.clone(),
6339            native_path: root.join(repo_path_to_native(&node.path)),
6340            expected_content_hash: node
6341                .content_hash
6342                .clone()
6343                .ok_or_else(|| source_changed_during_derivation(&root, &node.path))?,
6344            language: node.language.clone(),
6345            fallback_summary: existing.and_then(|indexed| indexed.summary.clone()),
6346            purpose_needs_suggestion: !protected_purpose_paths.contains(&node.path)
6347                && existing.is_none_or(|indexed| {
6348                    matches!(
6349                        indexed.purpose.status,
6350                        PurposeStatus::Missing | PurposeStatus::Suggested
6351                    )
6352                }),
6353        });
6354        if jobs.len() > MAX_SYMBOL_PARSE_JOBS {
6355            return Err(IndexWorkFailure::resource_limit(
6356                IndexWorkStage::SymbolParsing,
6357                IndexWorkResource::SymbolJobs,
6358                MAX_SYMBOL_PARSE_JOBS as u64,
6359                jobs.len() as u64,
6360            )
6361            .into());
6362        }
6363    }
6364    report.max_workers = worker_count_for_work(jobs.len(), report.max_workers);
6365    if !jobs.is_empty() {
6366        let pool = ThreadPoolBuilder::new()
6367            .num_threads(report.max_workers)
6368            .build()
6369            .map_err(|source| {
6370                CliError::InvalidInput(format!("symbol worker pool failed: {source}"))
6371            })?;
6372        #[cfg(feature = "optional-parser-supervisor")]
6373        let outcomes = optional_parser_runtime::parse_symbol_jobs_controlled(
6374            &root,
6375            optional_parser_selection,
6376            &pool,
6377            &jobs,
6378            options,
6379            control,
6380        )?;
6381        #[cfg(not(feature = "optional-parser-supervisor"))]
6382        let outcomes = parse_symbol_job_batches_controlled(&pool, &jobs, options, control)?;
6383        for outcome in outcomes {
6384            match outcome {
6385                SymbolParseOutcome::Parsed(parsed) => {
6386                    let next_symbols = checked_symbol_publication_usage(
6387                        report.symbols as u64,
6388                        parsed.graph.symbols.len() as u64,
6389                        limits.symbol_rows,
6390                        IndexWorkResource::SymbolRows,
6391                    )?;
6392                    let next_relations = checked_symbol_publication_usage(
6393                        report.relations as u64,
6394                        parsed.graph.relations.len() as u64,
6395                        limits.relation_rows,
6396                        IndexWorkResource::RelationRows,
6397                    )?;
6398                    let next_output_bytes = checked_symbol_publication_usage(
6399                        output_bytes,
6400                        symbol_parse_output_bytes(&parsed),
6401                        limits.output_bytes,
6402                        IndexWorkResource::OutputBytes,
6403                    )?;
6404                    report.summaries += 1;
6405                    if parsed.purpose_suggestion.is_some() {
6406                        report.purpose_suggestions += 1;
6407                    }
6408                    report.parsed += 1;
6409                    report.symbols = next_symbols as usize;
6410                    report.relations = next_relations as usize;
6411                    output_bytes = next_output_bytes;
6412                    changes.push(SymbolProjectionChange::Parsed(parsed));
6413                }
6414                SymbolParseOutcome::BinaryOrNonUtf8 { path } => {
6415                    let language = nodes
6416                        .iter()
6417                        .find(|node| node.path == path)
6418                        .and_then(|node| node.language.clone());
6419                    output_bytes = checked_symbol_publication_usage(
6420                        output_bytes,
6421                        path.len() as u64 + language.as_ref().map_or(0, String::len) as u64,
6422                        limits.output_bytes,
6423                        IndexWorkResource::OutputBytes,
6424                    )?;
6425                    changes.push(SymbolProjectionChange::Clear { path, language });
6426                    report.binary_or_non_utf8 += 1;
6427                }
6428                SymbolParseOutcome::InvalidInput { path, message } => {
6429                    return Err(CliError::InvalidInput(format!(
6430                        "document extraction failed for {path}: {message}"
6431                    )));
6432                }
6433                SymbolParseOutcome::SourceChanged { path } => {
6434                    return Err(source_changed_during_derivation(&root, &path));
6435                }
6436                SymbolParseOutcome::Io { path, source } => {
6437                    return Err(CliError::Io { path, source });
6438                }
6439                SymbolParseOutcome::IndexWork(failure) => return Err(failure.into()),
6440            }
6441        }
6442    }
6443    control.check(IndexWorkStage::SymbolParsing)?;
6444    let mut staged = SymbolBuildStage {
6445        report,
6446        changes,
6447        retained_bytes: output_bytes,
6448        identity_admission: graph_projection::GraphIdentityAdmission::default(),
6449    };
6450    staged.identity_admission = graph_projection::admit_symbol_build_stage(&mut staged, control)?;
6451    Ok(staged)
6452}
6453
6454/// Parse all built-in symbol jobs in bounded Rayon batches.
6455#[cfg(not(feature = "optional-parser-supervisor"))]
6456fn parse_symbol_job_batches_controlled(
6457    pool: &rayon::ThreadPool,
6458    jobs: &[SymbolParseJob],
6459    options: &SymbolBuildOptions,
6460    control: &IndexWorkControl,
6461) -> Result<Vec<SymbolParseOutcome>, CliError> {
6462    let mut outcomes = Vec::with_capacity(jobs.len());
6463    for batch in jobs.chunks(SYMBOL_PARSE_BATCH_SIZE) {
6464        control.check(IndexWorkStage::SymbolParsing)?;
6465        outcomes.extend(parse_symbol_jobs_controlled(pool, batch, options, control)?);
6466    }
6467    Ok(outcomes)
6468}
6469
6470/// Apply prepared symbol mutations inside the parent publication transaction.
6471fn apply_symbol_build_stage(
6472    store: &mut AtlasStore,
6473    staged: &mut SymbolBuildStage,
6474    control: &IndexWorkControl,
6475) -> Result<(), CliError> {
6476    if !staged.identity_admission.source_admitted() {
6477        staged.identity_admission = graph_projection::admit_symbol_build_stage(staged, control)?;
6478    }
6479    for change in &staged.changes {
6480        control.check(IndexWorkStage::Publication)?;
6481        match change {
6482            SymbolProjectionChange::Parsed(parsed) => {
6483                store.set_node_summary(&parsed.path, &parsed.summary)?;
6484                if let Some(suggestion) = parsed.purpose_suggestion.as_deref() {
6485                    store.set_suggested_purpose(&parsed.path, suggestion)?;
6486                }
6487                let mut metadata = SourceParseMetadata::from_graph(&parsed.graph);
6488                metadata.parser = parsed.source_parser;
6489                store.replace_symbol_graph_with_metadata(&parsed.graph, &metadata)?;
6490            }
6491            SymbolProjectionChange::Clear { path, language } => {
6492                clear_skipped_symbol_index(store, path, language.as_deref())?;
6493            }
6494        }
6495    }
6496    control.check(IndexWorkStage::Publication)?;
6497    Ok(())
6498}
6499
6500/// Parse one bounded symbol batch under the shared work boundary.
6501#[cfg(not(feature = "optional-parser-supervisor"))]
6502fn parse_symbol_jobs_controlled(
6503    pool: &rayon::ThreadPool,
6504    jobs: &[SymbolParseJob],
6505    options: &SymbolBuildOptions,
6506    control: &IndexWorkControl,
6507) -> Result<Vec<SymbolParseOutcome>, CliError> {
6508    control.check(IndexWorkStage::SymbolParsing)?;
6509    Ok(pool.install(|| {
6510        jobs.par_iter()
6511            .map(|job| parse_symbol_job_controlled(job, options, control))
6512            .collect::<Vec<_>>()
6513    }))
6514}
6515
6516/// Admit one aggregate symbol-publication resource before persistence.
6517fn checked_symbol_publication_usage(
6518    current: u64,
6519    added: u64,
6520    limit: u64,
6521    resource: IndexWorkResource,
6522) -> Result<u64, CliError> {
6523    let observed = current.saturating_add(added);
6524    if observed > limit {
6525        return Err(IndexWorkFailure::resource_limit(
6526            IndexWorkStage::SymbolParsing,
6527            resource,
6528            limit,
6529            observed,
6530        )
6531        .into());
6532    }
6533    Ok(observed)
6534}
6535
6536/// Count retained string bytes in one parser output without serializing a second copy.
6537fn symbol_parse_output_bytes(parsed: &SymbolParseSuccess) -> u64 {
6538    let graph = &parsed.graph;
6539    let mut bytes = graph.path.len() as u64
6540        + graph.language.as_ref().map_or(0, String::len) as u64
6541        + parsed.summary.len() as u64
6542        + parsed.purpose_suggestion.as_ref().map_or(0, String::len) as u64;
6543    for symbol in &graph.symbols {
6544        bytes = bytes.saturating_add(
6545            symbol.path.len() as u64
6546                + symbol.language.as_ref().map_or(0, String::len) as u64
6547                + symbol.name.len() as u64
6548                + symbol.signature.len() as u64
6549                + symbol.documentation.as_ref().map_or(0, String::len) as u64
6550                + symbol.parent.as_ref().map_or(0, String::len) as u64
6551                + symbol.detail.as_ref().map_or(0, String::len) as u64,
6552        );
6553    }
6554    for relation in &graph.relations {
6555        bytes = bytes.saturating_add(
6556            relation.path.len() as u64
6557                + relation.source_name.len() as u64
6558                + relation.target_name.len() as u64
6559                + relation.context.len() as u64,
6560        );
6561    }
6562    if let Some(facts) = &parsed.markdown_facts {
6563        for heading in &facts.headings {
6564            bytes = bytes
6565                .saturating_add(heading.text.len() as u64)
6566                .saturating_add(heading.slug.len() as u64);
6567        }
6568        for candidate in &facts.link_candidates {
6569            bytes = bytes
6570                .saturating_add(candidate.selector.len() as u64)
6571                .saturating_add(candidate.label.as_ref().map_or(0, String::len) as u64)
6572                .saturating_add(candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64);
6573        }
6574    }
6575    bytes
6576}
6577
6578/// Parse one source file into a symbol graph.
6579#[cfg(test)]
6580pub(crate) fn parse_symbol_job(
6581    job: &SymbolParseJob,
6582    options: &SymbolBuildOptions,
6583    started_at: Instant,
6584) -> SymbolParseOutcome {
6585    let control = options
6586        .timeout
6587        .and_then(|timeout| started_at.checked_add(timeout))
6588        .map_or_else(
6589            || IndexWorkControl::new(IndexCancellation::new(), None),
6590            |deadline| IndexWorkControl::with_deadline(IndexCancellation::new(), deadline),
6591        );
6592    parse_symbol_job_controlled(job, options, &control)
6593}
6594
6595/// Parse one source file while observing cancellation before and during parsing.
6596fn parse_symbol_job_controlled(
6597    job: &SymbolParseJob,
6598    options: &SymbolBuildOptions,
6599    control: &IndexWorkControl,
6600) -> SymbolParseOutcome {
6601    if options.is_timed_out(control.started_at()) {
6602        return SymbolParseOutcome::IndexWork(IndexWorkFailure::DeadlineExceeded {
6603            stage: IndexWorkStage::SymbolParsing,
6604        });
6605    }
6606    if let Err(failure) = control.check(IndexWorkStage::SymbolParsing) {
6607        return SymbolParseOutcome::IndexWork(failure);
6608    }
6609    let bytes = match admit_symbol_job_bytes(job, options, control) {
6610        Ok(bytes) => bytes,
6611        Err(outcome) => return *outcome,
6612    };
6613    if document_format_for_path(&job.path, job.language.as_deref()).is_some() {
6614        return parse_document_symbol_job(job, &bytes, options, control);
6615    }
6616    let Ok(content) = String::from_utf8(bytes) else {
6617        return SymbolParseOutcome::BinaryOrNonUtf8 {
6618            path: job.path.clone(),
6619        };
6620    };
6621    parse_admitted_symbol_job(job, &content, None, options, control)
6622}
6623
6624/// Read, bound, and hash-check one source exactly once for symbol staging.
6625fn admit_symbol_job_bytes(
6626    job: &SymbolParseJob,
6627    options: &SymbolBuildOptions,
6628    control: &IndexWorkControl,
6629) -> Result<Vec<u8>, Box<SymbolParseOutcome>> {
6630    let max_bytes = source_input_byte_limit(
6631        &job.path,
6632        job.language.as_deref(),
6633        None,
6634        options.max_bytes,
6635        IndexWorkStage::SymbolParsing,
6636    )
6637    .map_err(|failure| Box::new(SymbolParseOutcome::IndexWork(failure)))?;
6638    let bytes = match read_source_bytes_controlled(
6639        &job.native_path,
6640        max_bytes,
6641        IndexWorkStage::SymbolParsing,
6642        control,
6643    ) {
6644        Ok(bytes) => bytes,
6645        Err(SourceReadFailure::Io(source)) => {
6646            return Err(Box::new(SymbolParseOutcome::Io {
6647                path: job.native_path.clone(),
6648                source,
6649            }));
6650        }
6651        Err(SourceReadFailure::IndexWork(failure)) => {
6652            return Err(Box::new(SymbolParseOutcome::IndexWork(failure)));
6653        }
6654        Err(SourceReadFailure::LimitExceeded { observed }) => {
6655            return Err(Box::new(SymbolParseOutcome::IndexWork(
6656                IndexWorkFailure::resource_limit(
6657                    IndexWorkStage::SymbolParsing,
6658                    IndexWorkResource::SourceBytes,
6659                    max_bytes,
6660                    observed,
6661                ),
6662            )));
6663        }
6664    };
6665    if let Err(failure) = control.check(IndexWorkStage::SymbolParsing) {
6666        return Err(Box::new(SymbolParseOutcome::IndexWork(failure)));
6667    }
6668    if blake3::hash(&bytes).to_hex().as_str() != job.expected_content_hash {
6669        return Err(Box::new(SymbolParseOutcome::SourceChanged {
6670            path: job.path.clone(),
6671        }));
6672    }
6673    Ok(bytes)
6674}
6675
6676/// Parse one admitted PDF/DOCX byte stream into the existing publication shape.
6677fn parse_document_symbol_job(
6678    job: &SymbolParseJob,
6679    bytes: &[u8],
6680    _options: &SymbolBuildOptions,
6681    control: &IndexWorkControl,
6682) -> SymbolParseOutcome {
6683    let facts = match extract_document_symbol_facts_controlled(
6684        bytes,
6685        &job.path,
6686        job.language.as_deref(),
6687        control,
6688    ) {
6689        Ok(facts) => facts,
6690        Err(error) => return document_parse_error_outcome(&job.path, error),
6691    };
6692    let summary = document_summary_from_facts(&facts);
6693    let graph = facts.symbol_graph(&job.path, job.language.as_deref());
6694    let purpose_suggestion = job
6695        .purpose_needs_suggestion
6696        .then(|| suggest_file_purpose(&job.path, &summary));
6697    SymbolParseOutcome::Parsed(SymbolParseSuccess {
6698        path: job.path.clone(),
6699        graph,
6700        markdown_facts: None,
6701        source_parser: ParserKind::Structural,
6702        summary,
6703        summary_is_structural: false,
6704        purpose_suggestion,
6705    })
6706}
6707
6708/// Preserve typed cancellation/resource failures while surfacing malformed documents as input errors.
6709fn document_parse_error_outcome(path: &str, error: DocumentExtractionError) -> SymbolParseOutcome {
6710    match error {
6711        DocumentExtractionError::Work(failure) => SymbolParseOutcome::IndexWork(failure),
6712        DocumentExtractionError::ResourceLimit {
6713            limit,
6714            observed,
6715            maximum,
6716        } => {
6717            let resource = match limit {
6718                DocumentLimit::FactCount => IndexWorkResource::SymbolRows,
6719                DocumentLimit::ExecutionFuel => IndexWorkResource::ParserFuel,
6720                DocumentLimit::OutputBytes => IndexWorkResource::OutputBytes,
6721                DocumentLimit::EntryCount | DocumentLimit::NestingDepth => {
6722                    IndexWorkResource::Entries
6723                }
6724                DocumentLimit::InputBytes
6725                | DocumentLimit::CompressedBytes
6726                | DocumentLimit::ExpandedBytes
6727                | DocumentLimit::MemoryBytes => IndexWorkResource::SourceBytes,
6728            };
6729            SymbolParseOutcome::IndexWork(IndexWorkFailure::resource_limit(
6730                IndexWorkStage::SymbolParsing,
6731                resource,
6732                maximum as u64,
6733                observed as u64,
6734            ))
6735        }
6736        other => SymbolParseOutcome::InvalidInput {
6737            path: path.to_owned(),
6738            message: other.to_string(),
6739        },
6740    }
6741}
6742
6743/// Map one document extraction failure to the navigation boundary's typed error contract.
6744fn document_navigation_error(path: &str, error: DocumentExtractionError) -> CliError {
6745    match error {
6746        DocumentExtractionError::Work(failure) => failure.into(),
6747        DocumentExtractionError::ResourceLimit {
6748            limit,
6749            observed,
6750            maximum,
6751        } => {
6752            let resource = match limit {
6753                DocumentLimit::FactCount => IndexWorkResource::SymbolRows,
6754                DocumentLimit::ExecutionFuel => IndexWorkResource::ParserFuel,
6755                DocumentLimit::OutputBytes => IndexWorkResource::OutputBytes,
6756                DocumentLimit::EntryCount | DocumentLimit::NestingDepth => {
6757                    IndexWorkResource::Entries
6758                }
6759                DocumentLimit::InputBytes
6760                | DocumentLimit::CompressedBytes
6761                | DocumentLimit::ExpandedBytes
6762                | DocumentLimit::MemoryBytes => IndexWorkResource::SourceBytes,
6763            };
6764            IndexWorkFailure::resource_limit(
6765                IndexWorkStage::TextIndex,
6766                resource,
6767                maximum as u64,
6768                observed as u64,
6769            )
6770            .into()
6771        }
6772        other => CliError::InvalidInput(format!("document extraction failed for {path}: {other}")),
6773    }
6774}
6775
6776/// Extract conservative facts from admitted source and retain independent source provenance.
6777fn parse_admitted_symbol_job(
6778    job: &SymbolParseJob,
6779    content: &str,
6780    source_parser: Option<ParserKind>,
6781    options: &SymbolBuildOptions,
6782    control: &IndexWorkControl,
6783) -> SymbolParseOutcome {
6784    if options.is_timed_out(control.started_at()) {
6785        return SymbolParseOutcome::IndexWork(IndexWorkFailure::DeadlineExceeded {
6786            stage: IndexWorkStage::SymbolParsing,
6787        });
6788    }
6789    let (observed_source_parser, graph, markdown_facts) = if job
6790        .language
6791        .as_deref()
6792        .and_then(language_capability)
6793        .is_some_and(|capability| capability.symbol_parser == SymbolParserOwner::Markdown)
6794    {
6795        let facts = match extract_markdown_facts_controlled(content, control) {
6796            Ok(facts) => facts,
6797            Err(failure) => return SymbolParseOutcome::IndexWork(failure),
6798        };
6799        let graph = facts.symbol_graph(&job.path, job.language.as_deref());
6800        (graph.parser, graph, Some(Box::new(facts)))
6801    } else {
6802        let (parser, graph) = match extract_symbol_graph_with_source_controlled(
6803            &job.path,
6804            job.language.as_deref(),
6805            content,
6806            control,
6807        ) {
6808            Ok(graph) => graph,
6809            Err(failure) => return SymbolParseOutcome::IndexWork(failure),
6810        };
6811        (parser, graph, None)
6812    };
6813    let source_parser = source_parser.unwrap_or(observed_source_parser);
6814    let structural_summary = if let Some(facts) = &markdown_facts {
6815        markdown_summary_from_facts(
6816            facts,
6817            content
6818                .lines()
6819                .filter(|line| !line.trim().is_empty())
6820                .count(),
6821        )
6822    } else if graph.symbols.is_empty() {
6823        structural_summary_for_path(&job.path, job.language.as_deref(), content)
6824    } else {
6825        None
6826    };
6827    let summary_is_structural = structural_summary.is_some();
6828    let summary = structural_summary
6829        .unwrap_or_else(|| summarize_symbol_graph(&graph, job.fallback_summary.as_deref()));
6830    let purpose_suggestion = job
6831        .purpose_needs_suggestion
6832        .then(|| suggest_file_purpose(&job.path, &summary));
6833    SymbolParseOutcome::Parsed(SymbolParseSuccess {
6834        path: job.path.clone(),
6835        graph,
6836        markdown_facts,
6837        source_parser,
6838        summary,
6839        summary_is_structural,
6840        purpose_suggestion,
6841    })
6842}
6843
6844/// Return an empty symbol build report.
6845pub(crate) fn empty_symbol_build_report() -> SymbolBuildReport {
6846    SymbolBuildReport {
6847        candidates: 0,
6848        parsed: 0,
6849        unchanged: 0,
6850        too_large: 0,
6851        binary_or_non_utf8: 0,
6852        timed_out: 0,
6853        max_workers: 0,
6854        timeout_seconds: None,
6855        symbols: 0,
6856        relations: 0,
6857        summaries: 0,
6858        purpose_suggestions: 0,
6859    }
6860}
6861
6862/// Return an empty text-index report for a no-op refresh.
6863fn empty_text_index_report(options: TextIndexOptions) -> TextIndexReport {
6864    TextIndexReport {
6865        candidates: 0,
6866        indexed: 0,
6867        binary_or_non_utf8: 0,
6868        too_large: 0,
6869        skipped: 0,
6870        max_bytes: options.max_bytes,
6871        bytes: 0,
6872    }
6873}
6874
6875/// Create a deterministic one-line content summary from extracted symbols.
6876pub(crate) fn summarize_symbol_graph(graph: &SymbolGraph, fallback: Option<&str>) -> String {
6877    if graph.symbols.is_empty() {
6878        if let Some(fallback) = fallback.filter(|summary| !is_scanner_fallback_summary(summary)) {
6879            return fallback.to_string();
6880        }
6881        let language = observed_language_label(graph.language.as_deref());
6882        if observed_content_noun(graph.language.as_deref()) == "document" {
6883            return format!("{language} document with no headings found.");
6884        }
6885        return format!("{language} source file with no declarations found.");
6886    }
6887    let language = observed_language_label(graph.language.as_deref());
6888    let primary_names = primary_symbol_names(graph, 4);
6889    let primary_kinds = primary_symbol_kinds(graph);
6890    let imports = relation_targets(graph, RelationKind::Imports, 2);
6891    let dependencies = relation_targets(graph, RelationKind::DependsOn, 3);
6892    if !dependencies.is_empty() {
6893        let subject = observed_manifest_subject(&language);
6894        return format!(
6895            "{subject} declaring {} and depending on {}.",
6896            primary_names.join(", "),
6897            dependencies.join(", ")
6898        );
6899    }
6900    if !imports.is_empty() {
6901        return format!(
6902            "{language} source defining {} {} with imports {}.",
6903            primary_kinds,
6904            primary_names.join(", "),
6905            imports.join(", ")
6906        );
6907    }
6908    if observed_content_noun(graph.language.as_deref()) == "document" {
6909        return format!(
6910            "{language} document with {} {}.",
6911            primary_kinds,
6912            primary_names.join(", ")
6913        );
6914    }
6915    format!(
6916        "{language} source defining {} {}.",
6917        primary_kinds,
6918        primary_names.join(", ")
6919    )
6920}
6921
6922/// Return the truthful noun for registry-classified source versus documentation.
6923fn observed_content_noun(language: Option<&str>) -> &'static str {
6924    if content_classification(language, true)
6925        == projectatlas_core::language::ContentClassification::Documentation
6926    {
6927        "document"
6928    } else {
6929        "source"
6930    }
6931}
6932
6933/// Return a readable language label for agent-facing content summaries.
6934fn observed_language_label(language: Option<&str>) -> String {
6935    match language.unwrap_or("source") {
6936        "cargo-manifest" => "cargo manifest".to_string(),
6937        "cargo-lock" => "cargo lock".to_string(),
6938        "rust-build-script" => "rust build script".to_string(),
6939        "objective-c" => "Objective-C".to_string(),
6940        "csharp" => "C#".to_string(),
6941        "cpp" => "C++".to_string(),
6942        other => other.replace('-', " "),
6943    }
6944}
6945
6946/// Return the subject phrase for manifest-style content summaries.
6947fn observed_manifest_subject(language: &str) -> String {
6948    if language.contains("manifest") {
6949        language.to_string()
6950    } else {
6951        format!("{language} manifest")
6952    }
6953}
6954
6955/// Return a compact phrase describing the most important symbol kinds.
6956pub(crate) fn primary_symbol_kinds(graph: &SymbolGraph) -> String {
6957    let mut function_like = 0_usize;
6958    let mut type_like = 0_usize;
6959    let mut manifest_like = 0_usize;
6960    let mut value_like = 0_usize;
6961    let mut heading_like = 0_usize;
6962    for symbol in &graph.symbols {
6963        match symbol.kind {
6964            SymbolKind::Function | SymbolKind::Method => function_like += 1,
6965            SymbolKind::Class
6966            | SymbolKind::Struct
6967            | SymbolKind::Enum
6968            | SymbolKind::Trait
6969            | SymbolKind::Interface
6970            | SymbolKind::Type => type_like += 1,
6971            SymbolKind::Package | SymbolKind::Workspace | SymbolKind::Dependency => {
6972                manifest_like += 1;
6973            }
6974            SymbolKind::Value => value_like += 1,
6975            SymbolKind::Heading => heading_like += 1,
6976            SymbolKind::Module | SymbolKind::Import | SymbolKind::Unknown => {}
6977        }
6978    }
6979    if manifest_like > 0 && function_like == 0 && type_like == 0 {
6980        return "manifest entries".to_string();
6981    }
6982    if value_like > 0 && function_like == 0 && type_like == 0 {
6983        return value_only_symbol_kind_label(graph, value_like);
6984    }
6985    if heading_like > 0 && function_like == 0 && type_like == 0 {
6986        return if heading_like == 1 {
6987            "heading".to_string()
6988        } else {
6989            "headings".to_string()
6990        };
6991    }
6992    match (type_like, function_like) {
6993        (0, 0) => "symbols".to_string(),
6994        (0, 1) => "function".to_string(),
6995        (0, _) => "functions".to_string(),
6996        (1, 0) => "type".to_string(),
6997        (_, 0) => "types".to_string(),
6998        (1, 1) => "type and function".to_string(),
6999        (1, _) => "type and functions".to_string(),
7000        (_, 1) => "types and function".to_string(),
7001        (_, _) => "types and functions".to_string(),
7002    }
7003}
7004
7005/// Return the right value-only summary noun for the indexed language.
7006pub(crate) fn value_only_symbol_kind_label(graph: &SymbolGraph, count: usize) -> String {
7007    let language = graph.language.as_deref().unwrap_or_default();
7008    let binding_language = matches!(
7009        language,
7010        "javascript" | "typescript" | "tsx" | "vue" | "svelte"
7011    ) || graph
7012        .symbols
7013        .iter()
7014        .any(|symbol| symbol.detail.as_deref() == Some("fallback-composition-binding"));
7015    let singular = if binding_language { "binding" } else { "value" };
7016    let plural = if binding_language {
7017        "bindings"
7018    } else {
7019        "values"
7020    };
7021    if count == 1 {
7022        singular.to_string()
7023    } else {
7024        plural.to_string()
7025    }
7026}
7027
7028/// Return stable names for the most important declaration symbols.
7029pub(crate) fn primary_symbol_names(graph: &SymbolGraph, limit: usize) -> Vec<String> {
7030    let has_primary_definitions = graph.symbols.iter().any(|symbol| {
7031        matches!(
7032            symbol.kind,
7033            SymbolKind::Function
7034                | SymbolKind::Method
7035                | SymbolKind::Class
7036                | SymbolKind::Struct
7037                | SymbolKind::Enum
7038                | SymbolKind::Trait
7039                | SymbolKind::Interface
7040                | SymbolKind::Type
7041        )
7042    });
7043    let mut names = graph
7044        .symbols
7045        .iter()
7046        .filter(|symbol| {
7047            if has_primary_definitions && symbol.kind == SymbolKind::Value {
7048                return false;
7049            }
7050            !matches!(
7051                symbol.kind,
7052                SymbolKind::Import
7053                    | SymbolKind::Dependency
7054                    | SymbolKind::Module
7055                    | SymbolKind::Unknown
7056            )
7057        })
7058        .map(|symbol| symbol.name.clone())
7059        .collect::<Vec<_>>();
7060    if names.is_empty() {
7061        names = graph
7062            .symbols
7063            .iter()
7064            .map(|symbol| symbol.name.clone())
7065            .collect::<Vec<_>>();
7066    }
7067    names.sort();
7068    names.dedup();
7069    names.truncate(limit);
7070    if names.is_empty() {
7071        vec!["indexed symbols".to_string()]
7072    } else {
7073        names
7074    }
7075}
7076
7077/// Return relation targets for one relation kind.
7078pub(crate) fn relation_targets(
7079    graph: &SymbolGraph,
7080    kind: RelationKind,
7081    limit: usize,
7082) -> Vec<String> {
7083    let mut targets = graph
7084        .relations
7085        .iter()
7086        .filter(|relation| relation.kind == kind)
7087        .map(|relation| relation.target_name.clone())
7088        .collect::<Vec<_>>();
7089    targets.sort();
7090    targets.dedup();
7091    targets.truncate(limit);
7092    targets
7093}
7094
7095/// Create a generated file-purpose suggestion from a path and content summary.
7096pub(crate) fn suggest_file_purpose(path: &str, summary: &str) -> String {
7097    let subject = path_context_subject(path);
7098    if let Some(text) = summary
7099        .strip_prefix("pdf document text: ")
7100        .or_else(|| summary.strip_prefix("docx document text: "))
7101    {
7102        format!("Document {text}")
7103    } else if summary.contains("dataset manifest") {
7104        if let Some(datasets) = summary_between(summary, " including ", " and keys") {
7105            format!("Define the {subject} dataset manifest for {datasets}.")
7106        } else {
7107            format!("Define the {subject} dataset manifest.")
7108        }
7109    } else if let Some(workflow) = summary_between(summary, "yaml workflow ", " triggered") {
7110        format!("Define the {workflow} workflow.")
7111    } else if summary.contains("manifest") {
7112        if let Some(package) = summary_between(summary, " manifest for ", " with ") {
7113            format!("Define the {package} manifest.")
7114        } else {
7115            format!("Define the {subject} manifest.")
7116        }
7117    } else if let Some(title) = summary_between(summary, "document titled ", " with ") {
7118        format!("Document {title}.")
7119    } else if summary.contains("stylesheet") {
7120        format!("Style the {subject} stylesheet.")
7121    } else if summary.contains("config") {
7122        format!("Configure the {subject}.")
7123    } else if is_gradle_build_script(path) {
7124        if let Some(declarations) = summary_primary_declarations(summary) {
7125            format!("Define Gradle build tasks around {declarations}.")
7126        } else {
7127            "Configure the Gradle build.".to_string()
7128        }
7129    } else if let Some(declarations) = summary_primary_declarations(summary) {
7130        format!("Implement the {subject} source around {declarations}.")
7131    } else if summary.contains("source") {
7132        format!("Implement the {subject} source.")
7133    } else {
7134        format!("Implement the {subject}.")
7135    }
7136}
7137
7138/// Return whether a path is a Gradle build script rather than ordinary Kotlin/Groovy source.
7139fn is_gradle_build_script(path: &str) -> bool {
7140    let normalized = path.replace('\\', "/");
7141    normalized.ends_with("build.gradle") || normalized.ends_with("build.gradle.kts")
7142}
7143
7144/// Return a path-aware subject phrase for a generated purpose suggestion.
7145fn path_context_subject(path: &str) -> String {
7146    let normalized = path.replace('\\', "/");
7147    let mut segments = normalized
7148        .split('/')
7149        .filter(|segment| !segment.is_empty() && *segment != ".")
7150        .collect::<Vec<_>>();
7151    let Some(file_name) = segments.pop() else {
7152        return "path".to_string();
7153    };
7154    let stem = file_name
7155        .rsplit_once('.')
7156        .map_or(file_name, |(stem, _)| stem);
7157    let stem_words = path_segment_words(stem);
7158    let parent_words = segments
7159        .iter()
7160        .rev()
7161        .find(|segment| !is_generic_context_segment(segment))
7162        .map(|segment| path_segment_words(segment));
7163    match parent_words {
7164        Some(parent) if !parent.is_empty() && parent != stem_words => {
7165            format!("{parent} {stem_words}")
7166        }
7167        _ => stem_words,
7168    }
7169}
7170
7171/// Convert one path segment into readable lowercase words.
7172fn path_segment_words(segment: &str) -> String {
7173    let mut words = String::new();
7174    let mut previous_lowercase = false;
7175    for character in segment.chars() {
7176        if character == '-' || character == '_' || character == '.' {
7177            push_word_space(&mut words);
7178            previous_lowercase = false;
7179            continue;
7180        }
7181        if character.is_uppercase() && previous_lowercase {
7182            push_word_space(&mut words);
7183        }
7184        words.extend(character.to_lowercase());
7185        previous_lowercase = character.is_lowercase() || character.is_ascii_digit();
7186    }
7187    let words = words.trim();
7188    if words.is_empty() {
7189        "path".to_string()
7190    } else {
7191        words.to_string()
7192    }
7193}
7194
7195/// Append one word separator when the phrase already has content.
7196fn push_word_space(words: &mut String) {
7197    if !words.ends_with(' ') && !words.is_empty() {
7198        words.push(' ');
7199    }
7200}
7201
7202/// Return whether a path segment is too generic to add useful purpose context.
7203fn is_generic_context_segment(segment: &str) -> bool {
7204    matches!(
7205        segment.to_ascii_lowercase().as_str(),
7206        "src"
7207            | "source"
7208            | "sources"
7209            | "app"
7210            | "apps"
7211            | "lib"
7212            | "libs"
7213            | "crate"
7214            | "crates"
7215            | "package"
7216            | "packages"
7217            | "test"
7218            | "tests"
7219            | "spec"
7220            | "specs"
7221            | "fixture"
7222            | "fixtures"
7223            | "example"
7224            | "examples"
7225            | "script"
7226            | "scripts"
7227    )
7228}
7229
7230/// Extract primary declaration names from a deterministic content summary.
7231fn summary_primary_declarations(summary: &str) -> Option<String> {
7232    let after_marker = summary
7233        .split_once(" source defining ")
7234        .map(|(_, value)| value)
7235        .or_else(|| summary.split_once(" declaring ").map(|(_, value)| value))?;
7236    let declaration_clause = trim_summary_clause(after_marker);
7237    let names = strip_declaration_kind_prefix(declaration_clause)
7238        .split(',')
7239        .map(str::trim)
7240        .filter(|name| !name.is_empty())
7241        .take(3)
7242        .map(ToOwned::to_owned)
7243        .collect::<Vec<_>>();
7244    if names.is_empty() {
7245        None
7246    } else {
7247        Some(join_human_names(&names))
7248    }
7249}
7250
7251/// Trim trailing summary details from a declaration clause.
7252fn trim_summary_clause(value: &str) -> &str {
7253    value
7254        .split(" with imports ")
7255        .next()
7256        .unwrap_or(value)
7257        .split(" and depending on ")
7258        .next()
7259        .unwrap_or(value)
7260        .trim_end_matches('.')
7261        .trim()
7262}
7263
7264/// Remove the deterministic symbol-kind phrase before the primary names.
7265fn strip_declaration_kind_prefix(value: &str) -> &str {
7266    const PREFIXES: &[&str] = &[
7267        "types and functions ",
7268        "type and functions ",
7269        "types and function ",
7270        "type and function ",
7271        "manifest entries ",
7272        "functions ",
7273        "function ",
7274        "types ",
7275        "type ",
7276        "bindings ",
7277        "binding ",
7278        "values ",
7279        "value ",
7280        "symbols ",
7281    ];
7282    PREFIXES
7283        .iter()
7284        .find_map(|prefix| value.strip_prefix(prefix))
7285        .unwrap_or(value)
7286}
7287
7288/// Join declaration names as a compact human phrase.
7289fn join_human_names(names: &[String]) -> String {
7290    match names {
7291        [] => String::new(),
7292        [one] => one.clone(),
7293        [first, second] => format!("{first} and {second}"),
7294        [first, second, third, ..] => format!("{first}, {second}, and {third}"),
7295    }
7296}
7297
7298/// Return a non-empty substring between two markers.
7299fn summary_between<'a>(summary: &'a str, start: &str, end: &str) -> Option<&'a str> {
7300    let after_start = summary.split_once(start)?.1;
7301    let value = after_start.split_once(end)?.0.trim();
7302    (!value.is_empty()).then_some(value)
7303}
7304
7305/// Return whether a language should be parsed for symbols.
7306pub(crate) fn is_symbol_candidate(path: &str, language: Option<&str>) -> bool {
7307    let Some(language) = language else {
7308        return path.ends_with("Cargo.toml")
7309            || path.ends_with("Cargo.lock")
7310            || Path::new(path)
7311                .extension()
7312                .and_then(|extension| extension.to_str())
7313                .is_some_and(|extension| {
7314                    ["vue", "ps1", "psm1", "psd1"]
7315                        .iter()
7316                        .any(|expected| extension.eq_ignore_ascii_case(expected))
7317                });
7318    };
7319    language_capability(language)
7320        .is_none_or(|capability| capability.symbol_parser != SymbolParserOwner::Unavailable)
7321}
7322
7323/// Apply the project-selected optional-language boundary to symbol work admission.
7324fn is_symbol_candidate_for_admission(
7325    path: &str,
7326    language: Option<&str>,
7327    admit_optional_languages: bool,
7328) -> bool {
7329    if !admit_optional_languages
7330        && language
7331            .and_then(language_capability)
7332            .is_some_and(|capability| capability.optional_pack.is_some())
7333    {
7334        return false;
7335    }
7336    is_symbol_candidate(path, language)
7337}
7338
7339/// Clear stale symbol output while preserving structural summaries when present.
7340fn clear_skipped_symbol_index(
7341    store: &AtlasStore,
7342    path: &str,
7343    language: Option<&str>,
7344) -> Result<(), CliError> {
7345    if is_structural_summary_candidate(path, language) {
7346        store.clear_symbol_graph_for_path(path)?;
7347    } else {
7348        store.clear_source_index_for_path(path)?;
7349    }
7350    Ok(())
7351}
7352
7353/// Normalize and validate a user-supplied path as a repository-relative file key.
7354pub(crate) fn validated_file_key(file: &Path) -> Result<String, CliError> {
7355    validated_repo_file_key(file).map_err(|source| CliError::InvalidInput(source.to_string()))
7356}
7357
7358/// Normalize a folder filter into the repository path convention.
7359pub(crate) fn normalized_folder_filter(folder: &str) -> Result<String, CliError> {
7360    let trimmed = folder.trim().trim_end_matches(['/', '\\']);
7361    if trimmed.is_empty() || trimmed == "." {
7362        return Ok(".".to_string());
7363    }
7364    validated_file_key(Path::new(trimmed)).map_err(|_error| {
7365        CliError::InvalidInput(format!(
7366            "folder filter {folder:?} must be a project-relative path"
7367        ))
7368    })
7369}
7370
7371/// Validate that a path belongs to the indexed project file set.
7372pub(crate) fn validated_indexed_file_key(
7373    store: &AtlasStore,
7374    file: &Path,
7375) -> Result<String, CliError> {
7376    let file_key = validated_file_key(file)?;
7377    let indexed = store
7378        .load_node_by_path(&file_key)?
7379        .ok_or_else(|| CliError::InvalidInput(format!("file {file_key:?} is not indexed")))?;
7380    if indexed.node.kind != NodeKind::File {
7381        return Err(CliError::InvalidInput(format!(
7382            "path {file_key:?} is not an indexed file"
7383        )));
7384    }
7385    Ok(file_key)
7386}
7387
7388/// Load the project root recorded by the latest scan.
7389pub(crate) fn indexed_project_root(store: &AtlasStore) -> Result<PathBuf, CliError> {
7390    store
7391        .project_root_identity()?
7392        .map(CanonicalProjectRoot::into_path)
7393        .ok_or_else(|| {
7394            CliError::InvalidInput(
7395                "indexed project root is missing; run projectatlas scan <project-root> first"
7396                    .to_string(),
7397            )
7398        })
7399}
7400
7401/// Build an absolute native path for a previously validated indexed file key.
7402pub(crate) fn indexed_native_path(store: &AtlasStore, file_key: &str) -> Result<PathBuf, CliError> {
7403    Ok(indexed_project_root(store)?.join(repo_path_to_native(file_key)))
7404}
7405
7406/// Read content for a previously validated indexed file key.
7407pub(crate) fn read_indexed_file_content(
7408    store: &AtlasStore,
7409    file_key: &str,
7410) -> Result<String, CliError> {
7411    let native = indexed_native_path(store, file_key)?;
7412    let indexed = store.load_node_by_path(file_key)?.ok_or_else(|| {
7413        CliError::InvalidInput(format!("indexed file {file_key:?} was not found"))
7414    })?;
7415    let project_root = lossless_project_root_display(&indexed_project_root(store)?);
7416    let metadata = match fs::metadata(&native) {
7417        Ok(metadata) => metadata,
7418        Err(source) if source.kind() == io::ErrorKind::NotFound => {
7419            return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7420                project_root,
7421                worktree: None,
7422                status: IndexReadStatus::RefreshRequired,
7423                reason: IndexRefreshReason::PathsChanged,
7424                scope: IndexRefreshScope::Full,
7425                changed: 1,
7426                added: 0,
7427                removed: 1,
7428                modified: 0,
7429                sample_paths: vec![file_key.to_string()],
7430            })));
7431        }
7432        Err(source) => {
7433            return Err(CliError::VerificationIncomplete(Box::new(
7434                IndexVerificationIncomplete {
7435                    project_root,
7436                    worktree: None,
7437                    status: IndexReadStatus::VerificationIncomplete,
7438                    reason: IndexVerificationReason::SourceInspectionFailed,
7439                    scope: IndexRefreshScope::Full,
7440                    message: format!("failed to read '{}': {source}", native.display()),
7441                },
7442            )));
7443        }
7444    };
7445    if indexed
7446        .node
7447        .size_bytes
7448        .is_some_and(|indexed_bytes| indexed_bytes != metadata.len())
7449    {
7450        return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7451            project_root,
7452            worktree: None,
7453            status: IndexReadStatus::RefreshRequired,
7454            reason: IndexRefreshReason::SourceChanged,
7455            scope: IndexRefreshScope::Full,
7456            changed: 1,
7457            added: 0,
7458            removed: 0,
7459            modified: 1,
7460            sample_paths: vec![file_key.to_string()],
7461        })));
7462    }
7463    if metadata.len() > MAX_INDEXED_NAVIGATION_SOURCE_BYTES {
7464        return Err(CliError::VerificationIncomplete(Box::new(
7465            IndexVerificationIncomplete {
7466                project_root,
7467                worktree: None,
7468                status: IndexReadStatus::VerificationIncomplete,
7469                reason: IndexVerificationReason::SourceTooLarge,
7470                scope: IndexRefreshScope::Full,
7471                message: format!(
7472                    "indexed file {file_key:?} contains {} bytes; bounded navigation reads admit at most {MAX_INDEXED_NAVIGATION_SOURCE_BYTES} bytes",
7473                    metadata.len()
7474                ),
7475            },
7476        )));
7477    }
7478    let file = fs::File::open(&native).map_err(|source| {
7479        CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
7480            project_root: project_root.clone(),
7481            worktree: None,
7482            status: IndexReadStatus::VerificationIncomplete,
7483            reason: IndexVerificationReason::SourceInspectionFailed,
7484            scope: IndexRefreshScope::Full,
7485            message: format!("failed to open '{}': {source}", native.display()),
7486        }))
7487    })?;
7488    let mut bytes = Vec::with_capacity(metadata.len() as usize);
7489    file.take(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1)
7490        .read_to_end(&mut bytes)
7491        .map_err(|source| {
7492            CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
7493                project_root: project_root.clone(),
7494                worktree: None,
7495                status: IndexReadStatus::VerificationIncomplete,
7496                reason: IndexVerificationReason::SourceInspectionFailed,
7497                scope: IndexRefreshScope::Full,
7498                message: format!("failed to read '{}': {source}", native.display()),
7499            }))
7500        })?;
7501    if bytes.len() as u64 != metadata.len()
7502        || bytes.len() as u64 > MAX_INDEXED_NAVIGATION_SOURCE_BYTES
7503    {
7504        return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7505            project_root,
7506            worktree: None,
7507            status: IndexReadStatus::RefreshRequired,
7508            reason: IndexRefreshReason::SourceChanged,
7509            scope: IndexRefreshScope::Full,
7510            changed: 1,
7511            added: 0,
7512            removed: 0,
7513            modified: 1,
7514            sample_paths: vec![file_key.to_string()],
7515        })));
7516    }
7517    let current_hash = blake3::hash(&bytes).to_hex().to_string();
7518    if indexed.node.content_hash.as_deref() != Some(current_hash.as_str()) {
7519        return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7520            project_root,
7521            worktree: None,
7522            status: IndexReadStatus::RefreshRequired,
7523            reason: IndexRefreshReason::SourceChanged,
7524            scope: IndexRefreshScope::Full,
7525            changed: 1,
7526            added: 0,
7527            removed: 0,
7528            modified: 1,
7529            sample_paths: vec![file_key.to_string()],
7530        })));
7531    }
7532    if document_format_for_path(file_key, indexed.node.language.as_deref()).is_some() {
7533        return extract_document_text_controlled(
7534            &bytes,
7535            file_key,
7536            indexed.node.language.as_deref(),
7537            &standalone_index_work_control(),
7538        )
7539        .map(|facts| facts.text)
7540        .map_err(|error| document_navigation_error(file_key, error));
7541    }
7542    String::from_utf8(bytes).map_err(|source| {
7543        CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
7544            project_root,
7545            worktree: None,
7546            status: IndexReadStatus::VerificationIncomplete,
7547            reason: IndexVerificationReason::SourceInspectionFailed,
7548            scope: IndexRefreshScope::Full,
7549            message: format!("indexed file {file_key:?} is not valid UTF-8: {source}"),
7550        }))
7551    })
7552}
7553
7554/// Run the watcher refresh loop.
7555pub(crate) fn run_watch_loop(
7556    store: &mut AtlasStore,
7557    plan: &ScanRuntimePlan,
7558    once: bool,
7559    poll_seconds: u64,
7560    max_cycles: usize,
7561    symbol_options: &SymbolBuildOptions,
7562) -> Result<WatchReport, CliError> {
7563    if once {
7564        return run_single_watch_refresh(store, plan, symbol_options);
7565    }
7566    run_watch_with_polling_fallback(
7567        store,
7568        plan,
7569        poll_seconds,
7570        max_cycles,
7571        symbol_options,
7572        |store| run_notify_watch_loop(store, plan, poll_seconds, max_cycles, symbol_options),
7573    )
7574}
7575
7576/// Run an event-backed watcher and preserve current changes through polling fallback.
7577fn run_watch_with_polling_fallback<F>(
7578    store: &mut AtlasStore,
7579    plan: &ScanRuntimePlan,
7580    poll_seconds: u64,
7581    max_cycles: usize,
7582    symbol_options: &SymbolBuildOptions,
7583    run_notify: F,
7584) -> Result<WatchReport, CliError>
7585where
7586    F: FnOnce(&mut AtlasStore) -> Result<WatchReport, CliError>,
7587{
7588    match run_notify(store) {
7589        Ok(report) => Ok(report),
7590        Err(error @ CliError::RefreshRequired(_)) => Err(error),
7591        Err(error) => run_polling_watch_loop(
7592            store,
7593            plan,
7594            poll_seconds,
7595            max_cycles,
7596            symbol_options,
7597            Some(error.to_string()),
7598        ),
7599    }
7600}
7601
7602/// Run one deterministic watcher refresh and exit.
7603pub(crate) fn run_single_watch_refresh(
7604    store: &mut AtlasStore,
7605    plan: &ScanRuntimePlan,
7606    symbol_options: &SymbolBuildOptions,
7607) -> Result<WatchReport, CliError> {
7608    let control = index_work_control(symbol_options);
7609    run_single_watch_refresh_controlled(store, plan, symbol_options, &control)
7610}
7611
7612/// Run one watcher refresh under one cancellation and publication boundary.
7613pub(crate) fn run_single_watch_refresh_controlled(
7614    store: &mut AtlasStore,
7615    plan: &ScanRuntimePlan,
7616    symbol_options: &SymbolBuildOptions,
7617    control: &IndexWorkControl,
7618) -> Result<WatchReport, CliError> {
7619    let bounded_control = bounded_index_work_control(control);
7620    let control = &bounded_control;
7621    control.check(IndexWorkStage::RepositoryTraversal)?;
7622    let current_plan = plan.reload_controlled(control)?;
7623    let last_refresh = refresh_index_controlled(store, &current_plan, symbol_options, control)?;
7624    Ok(WatchReport {
7625        mode: WATCH_MODE_ONCE.to_string(),
7626        cycles: 1,
7627        once: true,
7628        fallback_reason: None,
7629        text_index: last_refresh.text_index,
7630        structural_summaries: last_refresh.structural_summaries,
7631        last_symbols: last_refresh.symbols,
7632    })
7633}
7634
7635/// Run an event-backed watcher loop with `notify`.
7636pub(crate) fn run_notify_watch_loop(
7637    store: &mut AtlasStore,
7638    plan: &ScanRuntimePlan,
7639    poll_seconds: u64,
7640    max_cycles: usize,
7641    symbol_options: &SymbolBuildOptions,
7642) -> Result<WatchReport, CliError> {
7643    let mut current_plan = plan.reload()?;
7644    let watch_root = current_plan
7645        .root
7646        .canonicalize()
7647        .map_err(|source| CliError::Io {
7648            path: current_plan.root.clone(),
7649            source,
7650        })?;
7651    let (sender, receiver) = mpsc::sync_channel(WATCH_EVENT_QUEUE_CAPACITY);
7652    let continuity_lost = Arc::new(AtomicBool::new(false));
7653    let callback_continuity_lost = Arc::clone(&continuity_lost);
7654    let mut watcher = RecommendedWatcher::new(
7655        move |result: notify::Result<Event>| {
7656            match sender.try_send(result) {
7657                Ok(()) => {}
7658                Err(TrySendError::Full(_result)) => {
7659                    callback_continuity_lost.store(true, Ordering::Release);
7660                }
7661                Err(TrySendError::Disconnected(_result)) => {
7662                    // Receiver shutdown means the command is exiting.
7663                }
7664            }
7665        },
7666        Config::default(),
7667    )
7668    .map_err(|source| CliError::Watcher(source.to_string()))?;
7669    watcher
7670        .watch(&watch_root, RecursiveMode::Recursive)
7671        .map_err(|source| CliError::Watcher(source.to_string()))?;
7672    let debounce = Duration::from_secs(poll_seconds.max(1));
7673    let mut cycles = 0;
7674    let mut last_refresh = refresh_index(store, &current_plan, symbol_options)?;
7675    cycles += 1;
7676    while max_cycles == 0 || cycles < max_cycles {
7677        let changes = wait_for_index_event_with_continuity(
7678            &receiver,
7679            &watch_root,
7680            debounce,
7681            &current_plan.scan_options,
7682            &continuity_lost,
7683        )?;
7684        if changes.has_changes() {
7685            current_plan = plan.reload()?;
7686            last_refresh =
7687                refresh_index_for_changes(store, &current_plan, &changes, symbol_options)?;
7688            cycles += 1;
7689        }
7690    }
7691    Ok(WatchReport {
7692        mode: WATCH_MODE_NOTIFY.to_string(),
7693        cycles,
7694        once: false,
7695        fallback_reason: None,
7696        text_index: last_refresh.text_index,
7697        structural_summaries: last_refresh.structural_summaries,
7698        last_symbols: last_refresh.symbols,
7699    })
7700}
7701
7702/// Wait for one bounded event batch and preserve local queue-overflow uncertainty.
7703fn wait_for_index_event_with_continuity(
7704    receiver: &mpsc::Receiver<notify::Result<Event>>,
7705    root: &Path,
7706    debounce: Duration,
7707    scan_options: &ScanOptions,
7708    continuity_lost: &AtomicBool,
7709) -> Result<WatchChangeSet, CliError> {
7710    let mut changes = notify_result_changes(
7711        root,
7712        scan_options,
7713        receiver.recv().map_err(|source| {
7714            CliError::Watcher(format!("watch event channel disconnected: {source}"))
7715        })?,
7716    )?;
7717    loop {
7718        match receiver.recv_timeout(debounce) {
7719            Ok(result) => {
7720                changes.merge(notify_result_changes(root, scan_options, result)?);
7721            }
7722            Err(RecvTimeoutError::Timeout) => break,
7723            Err(RecvTimeoutError::Disconnected) => {
7724                return Err(CliError::Watcher(
7725                    "watch event channel disconnected".to_string(),
7726                ));
7727            }
7728        }
7729    }
7730    if continuity_lost.swap(false, Ordering::AcqRel) {
7731        changes.requires_full_scan = true;
7732    }
7733    Ok(changes)
7734}
7735
7736/// Convert a `notify` result into index-relevant changes.
7737pub(crate) fn notify_result_changes(
7738    root: &Path,
7739    scan_options: &ScanOptions,
7740    result: notify::Result<Event>,
7741) -> Result<WatchChangeSet, CliError> {
7742    let event = result.map_err(|source| CliError::Watcher(source.to_string()))?;
7743    Ok(notify_event_changes(root, scan_options, &event))
7744}
7745
7746/// Convert a `notify` event into index-relevant changes.
7747pub(crate) fn notify_event_changes(
7748    root: &Path,
7749    scan_options: &ScanOptions,
7750    event: &Event,
7751) -> WatchChangeSet {
7752    if !event_kind_affects_index(event.kind) {
7753        return WatchChangeSet::default();
7754    }
7755    let mut changes = WatchChangeSet {
7756        requires_full_scan: event.need_rescan(),
7757        paths: HashSet::new(),
7758        document_paths: HashSet::new(),
7759    };
7760    for path in &event.paths {
7761        let candidate = absolute_watch_path(root, path);
7762        if safe_watch_relative_path(root, &candidate).is_some() {
7763            changes.document_paths.insert(candidate.clone());
7764        }
7765        if watch_path_requires_full_scan(root, &candidate) {
7766            changes.requires_full_scan = true;
7767            changes.paths.insert(candidate);
7768            continue;
7769        }
7770        let Some(index_path) = normalized_watch_index_path(root, path, scan_options) else {
7771            continue;
7772        };
7773        if matches!(
7774            event.kind,
7775            EventKind::Modify(notify::event::ModifyKind::Name(_))
7776        ) || watch_path_requires_full_scan(root, &index_path)
7777        {
7778            changes.requires_full_scan = true;
7779        }
7780        changes.paths.insert(index_path);
7781    }
7782    changes
7783}
7784
7785/// Return whether a `notify` event kind can change indexed content.
7786pub(crate) fn event_kind_affects_index(kind: EventKind) -> bool {
7787    !matches!(kind, EventKind::Access(_))
7788}
7789
7790/// Return whether a native event path belongs to indexed repository content.
7791#[cfg(test)]
7792pub(crate) fn watch_path_affects_index(
7793    root: &Path,
7794    path: &Path,
7795    scan_options: &ScanOptions,
7796) -> bool {
7797    normalized_watch_index_path(root, path, scan_options).is_some()
7798}
7799
7800/// Return one repository-contained native path after watcher normalization and policy checks.
7801fn normalized_watch_index_path(
7802    root: &Path,
7803    path: &Path,
7804    scan_options: &ScanOptions,
7805) -> Option<PathBuf> {
7806    let candidate = absolute_watch_path(root, path);
7807    let relative = safe_watch_relative_path(root, &candidate)?;
7808    if relative == "." {
7809        return Some(root.to_path_buf());
7810    }
7811    let policy_path = if candidate.strip_prefix(root).is_ok() {
7812        candidate.clone()
7813    } else {
7814        match candidate.try_exists() {
7815            Ok(true) => candidate.clone(),
7816            Ok(false) => root.join(repo_path_to_native(&relative)),
7817            Err(_) => return None,
7818        }
7819    };
7820    // Unknown ignore state should not admit a path into the incremental index.
7821    let Ok(gitignore_ignored) = gitignore_excludes_path(root, &policy_path) else {
7822        return None;
7823    };
7824    if gitignore_ignored {
7825        return None;
7826    }
7827    if relative.split('/').any(|component| component == ".purpose")
7828        || scan_options.excludes_relative_path(&relative)
7829    {
7830        return None;
7831    }
7832    Some(candidate)
7833}
7834
7835/// Return a safe normalized repository path for a watcher event.
7836fn safe_watch_relative_path(root: &Path, candidate: &Path) -> Option<String> {
7837    let relative = normalize_repo_path(root, candidate)
7838        .ok()
7839        .or_else(|| native_display_relative_path(root, candidate))?;
7840    valid_watch_relative_path(relative)
7841}
7842
7843/// Reconcile equivalent native paths when Windows extended prefixes differ.
7844fn native_display_relative_path(root: &Path, candidate: &Path) -> Option<String> {
7845    let root = normalize_native_path_display_str(root.to_str()?);
7846    let candidate = normalize_native_path_display_str(candidate.to_str()?);
7847    let root = if root == "/" {
7848        root.as_str()
7849    } else {
7850        root.trim_end_matches('/')
7851    };
7852    if candidate == root || cfg!(windows) && candidate.eq_ignore_ascii_case(root) {
7853        return Some(".".to_string());
7854    }
7855    let prefix = if root == "/" {
7856        "/".to_string()
7857    } else {
7858        format!("{root}/")
7859    };
7860    if let Some(relative) = candidate.strip_prefix(&prefix) {
7861        return Some(relative.to_string());
7862    }
7863    #[cfg(windows)]
7864    {
7865        let prefix_candidate = candidate.get(..prefix.len())?;
7866        if prefix_candidate.eq_ignore_ascii_case(&prefix) {
7867            return candidate.get(prefix.len()..).map(ToOwned::to_owned);
7868        }
7869    }
7870    None
7871}
7872
7873/// Reject empty, current-directory, and parent traversal path components.
7874fn valid_watch_relative_path(relative: String) -> Option<String> {
7875    if relative == "." {
7876        return Some(relative);
7877    }
7878    if relative
7879        .split('/')
7880        .any(|component| component.is_empty() || component == "." || component == "..")
7881    {
7882        return None;
7883    }
7884    Some(relative)
7885}
7886
7887/// Return an absolute path for a watcher event path.
7888pub(crate) fn absolute_watch_path(root: &Path, path: &Path) -> PathBuf {
7889    if path.is_absolute() {
7890        path.to_path_buf()
7891    } else {
7892        root.join(path)
7893    }
7894}
7895
7896/// Return whether a path event requires a full scan for correctness.
7897pub(crate) fn watch_path_requires_full_scan(root: &Path, path: &Path) -> bool {
7898    let Some(relative) = safe_watch_relative_path(root, path) else {
7899        return false;
7900    };
7901    if relative == "." {
7902        return true;
7903    }
7904    path.is_dir()
7905        || matches!(relative.rsplit('/').next(), Some(".gitignore" | ".ignore"))
7906        || relative == ".git"
7907        || relative.ends_with("/.git")
7908        || relative == ".git/info/exclude"
7909        || relative.ends_with("/.git/info/exclude")
7910        || matches!(
7911            relative.rsplit('/').next(),
7912            Some("tsconfig.json" | "jsconfig.json")
7913        )
7914        || index_policy_path(relative.as_str())
7915}
7916
7917/// Return whether one repository-relative path owns derived-index policy.
7918fn index_policy_path(relative: &str) -> bool {
7919    CORE_INDEX_POLICY_PATHS.contains(&relative)
7920        || cfg!(feature = "optional-parser-supervisor") && {
7921            #[cfg(feature = "optional-parser-supervisor")]
7922            {
7923                relative == OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH
7924            }
7925            #[cfg(not(feature = "optional-parser-supervisor"))]
7926            {
7927                false
7928            }
7929        }
7930}
7931
7932/// Run the portable polling watcher fallback loop.
7933pub(crate) fn run_polling_watch_loop(
7934    store: &mut AtlasStore,
7935    plan: &ScanRuntimePlan,
7936    poll_seconds: u64,
7937    max_cycles: usize,
7938    symbol_options: &SymbolBuildOptions,
7939    fallback_reason: Option<String>,
7940) -> Result<WatchReport, CliError> {
7941    let mut cycles = 0;
7942    let mut current_plan = plan.reload()?;
7943    let mut last_refresh = refresh_index(store, &current_plan, symbol_options)?;
7944    cycles += 1;
7945    while max_cycles == 0 || cycles < max_cycles {
7946        thread::sleep(Duration::from_secs(poll_seconds.max(1)));
7947        current_plan = plan.reload()?;
7948        last_refresh = refresh_index(store, &current_plan, symbol_options)?;
7949        cycles += 1;
7950    }
7951    Ok(WatchReport {
7952        mode: WATCH_MODE_POLLING.to_string(),
7953        cycles,
7954        once: false,
7955        fallback_reason,
7956        text_index: last_refresh.text_index,
7957        structural_summaries: last_refresh.structural_summaries,
7958        last_symbols: last_refresh.symbols,
7959    })
7960}
7961
7962/// Combined refresh output for watcher and one-shot refresh paths.
7963pub(crate) struct IndexRefreshReport {
7964    /// Persisted text search index refresh report.
7965    pub(crate) text_index: TextIndexReport,
7966    /// Structural summary refresh report.
7967    pub(crate) structural_summaries: StructuralSummaryReport,
7968    /// Deep symbol graph refresh report.
7969    symbols: SymbolBuildReport,
7970}
7971
7972/// Refresh filesystem and symbol state.
7973pub(crate) fn refresh_index(
7974    store: &mut AtlasStore,
7975    plan: &ScanRuntimePlan,
7976    symbol_options: &SymbolBuildOptions,
7977) -> Result<IndexRefreshReport, CliError> {
7978    let control = index_work_control(symbol_options);
7979    refresh_index_controlled(store, plan, symbol_options, &control)
7980}
7981
7982/// Refresh every derived projection under one cancellation boundary.
7983pub(crate) fn refresh_index_controlled(
7984    store: &mut AtlasStore,
7985    plan: &ScanRuntimePlan,
7986    symbol_options: &SymbolBuildOptions,
7987    control: &IndexWorkControl,
7988) -> Result<IndexRefreshReport, CliError> {
7989    let bounded_control = bounded_index_work_control(control);
7990    let control = &bounded_control;
7991    let reuse_unchanged_symbols = publication_contract_matches(store, plan)?;
7992    if reuse_unchanged_symbols
7993        && detect_index_freshness_controlled(store, plan, ScanLimits::default(), control)?
7994            .delta
7995            .is_none()
7996    {
7997        graph_projection::cleanup_abandoned_repository_graph_staging(store, &plan.root, control)?;
7998        return Ok(empty_index_refresh_report(plan.text_options));
7999    }
8000    control.check(IndexWorkStage::Publication)?;
8001    store.probe_index_publication_writer()?;
8002    let batch = stage_full_index_publication(
8003        store,
8004        plan,
8005        symbol_options,
8006        reuse_unchanged_symbols,
8007        false,
8008        control,
8009    )?;
8010    revalidate_staged_publication_inputs_controlled(
8011        plan,
8012        batch.nodes.expected_nodes(),
8013        None,
8014        control,
8015    )?;
8016    if staged_full_refresh_is_unchanged(store, &batch)? {
8017        return Ok(empty_index_refresh_report(plan.text_options));
8018    }
8019    let outcome = publish_index_batch(store, batch, control)?;
8020    Ok(IndexRefreshReport {
8021        text_index: outcome.text_index,
8022        structural_summaries: outcome.structural_summaries,
8023        symbols: outcome.symbols,
8024    })
8025}
8026
8027/// Return whether one fully staged watcher refresh matches complete durable state.
8028fn staged_full_refresh_is_unchanged(
8029    store: &AtlasStore,
8030    batch: &IndexPublicationBatch,
8031) -> Result<bool, CliError> {
8032    let NodePublicationBatch::Full { nodes: expected } = &batch.nodes else {
8033        return Ok(false);
8034    };
8035    if batch.purpose_import.is_some() {
8036        return Ok(false);
8037    }
8038    let Some(publication) = store.index_publication()? else {
8039        return Ok(false);
8040    };
8041    if publication.state != IndexPublicationState::Complete
8042        || publication.generation != batch.base_generation
8043        || publication.contract_fingerprint.as_deref() != Some(batch.contract_fingerprint.as_str())
8044    {
8045        return Ok(false);
8046    }
8047    let current = store
8048        .load_nodes()?
8049        .into_iter()
8050        .map(|indexed| indexed.node)
8051        .collect::<Vec<_>>();
8052    Ok(current.len() == expected.len()
8053        && current
8054            .iter()
8055            .zip(expected)
8056            .all(|(current, expected)| same_indexed_source(current, expected)))
8057}
8058
8059/// Build the stable report for a verified watcher no-op.
8060fn empty_index_refresh_report(text_options: TextIndexOptions) -> IndexRefreshReport {
8061    IndexRefreshReport {
8062        text_index: empty_text_index_report(text_options),
8063        structural_summaries: StructuralSummaryReport::default(),
8064        symbols: empty_symbol_build_report(),
8065    }
8066}
8067
8068/// Refresh filesystem and symbol state for a debounced event batch.
8069pub(crate) fn refresh_index_for_changes(
8070    store: &mut AtlasStore,
8071    plan: &ScanRuntimePlan,
8072    changes: &WatchChangeSet,
8073    symbol_options: &SymbolBuildOptions,
8074) -> Result<IndexRefreshReport, CliError> {
8075    let control = index_work_control(symbol_options);
8076    refresh_index_for_changes_controlled(store, plan, changes, symbol_options, &control)
8077}
8078
8079/// Refresh one watcher batch under one cancellation and publication boundary.
8080pub(crate) fn refresh_index_for_changes_controlled(
8081    store: &mut AtlasStore,
8082    plan: &ScanRuntimePlan,
8083    changes: &WatchChangeSet,
8084    symbol_options: &SymbolBuildOptions,
8085    control: &IndexWorkControl,
8086) -> Result<IndexRefreshReport, CliError> {
8087    let bounded_control = bounded_index_work_control(control);
8088    let control = &bounded_control;
8089    control.check(IndexWorkStage::RepositoryTraversal)?;
8090    if changes.requires_full_scan || !publication_contract_matches(store, plan)? {
8091        return refresh_index_controlled(store, plan, symbol_options, control);
8092    }
8093    let changed_event_path_count = changes.paths.union(&changes.document_paths).count();
8094    if changed_event_path_count > MAX_INCREMENTAL_CHANGED_PATHS {
8095        return Err(IndexWorkFailure::resource_limit(
8096            IndexWorkStage::RepositoryTraversal,
8097            IndexWorkResource::Entries,
8098            MAX_INCREMENTAL_CHANGED_PATHS as u64,
8099            changed_event_path_count as u64,
8100        )
8101        .into());
8102    }
8103    let root = &plan.root;
8104    let base_generation = publication_base_generation(store)?;
8105    let baseline_nodes = store
8106        .load_nodes()?
8107        .into_iter()
8108        .map(|indexed| indexed.node)
8109        .collect::<Vec<_>>();
8110    let baseline_by_path = baseline_nodes
8111        .iter()
8112        .map(|node| (node.path.clone(), node))
8113        .collect::<HashMap<_, _>>();
8114    let mut nodes = Vec::new();
8115    let mut absent_paths = Vec::new();
8116    let mut source_bytes = 0_u64;
8117    let scan_policy = RootScanPolicy::discover(root, &plan.scan_options, control)
8118        .map_err(|source| source_inspection_error(root, source))?;
8119    let mut direct_document_paths = changes
8120        .paths
8121        .union(&changes.document_paths)
8122        .map(|path| normalized_deleted_path(root, path))
8123        .collect::<Result<Vec<_>, _>>()?
8124        .into_iter()
8125        .flatten()
8126        .collect::<BTreeSet<_>>();
8127    for path in sorted_watch_paths(&changes.paths) {
8128        control.check(IndexWorkStage::RepositoryTraversal)?;
8129        match path.try_exists() {
8130            Ok(true) => {
8131                let remaining_source_bytes =
8132                    MAX_INCREMENTAL_SOURCE_BYTES.saturating_sub(source_bytes);
8133                if let Some(node) = scan_path_with_policy_controlled(
8134                    &scan_policy,
8135                    &path,
8136                    ScanLimits::new(1, remaining_source_bytes, 1),
8137                    control,
8138                )
8139                .map_err(|source| source_inspection_error(root, source))?
8140                {
8141                    source_bytes = source_bytes
8142                        .checked_add(node.size_bytes.unwrap_or_default())
8143                        .ok_or_else(|| {
8144                            IndexWorkFailure::resource_limit(
8145                                IndexWorkStage::SourceMetadata,
8146                                IndexWorkResource::SourceBytes,
8147                                MAX_INCREMENTAL_SOURCE_BYTES,
8148                                u64::MAX,
8149                            )
8150                        })?;
8151                    if source_bytes > MAX_INCREMENTAL_SOURCE_BYTES {
8152                        return Err(IndexWorkFailure::resource_limit(
8153                            IndexWorkStage::SourceMetadata,
8154                            IndexWorkResource::SourceBytes,
8155                            MAX_INCREMENTAL_SOURCE_BYTES,
8156                            source_bytes,
8157                        )
8158                        .into());
8159                    }
8160                    nodes.push(node);
8161                } else if let Some(path_key) = normalized_deleted_path(root, &path)? {
8162                    absent_paths.push(path_key);
8163                }
8164            }
8165            Ok(false) => {
8166                if let Some(path_key) = normalized_deleted_path(root, &path)? {
8167                    absent_paths.push(path_key);
8168                }
8169            }
8170            Err(source) => {
8171                return Err(CliError::VerificationIncomplete(Box::new(
8172                    IndexVerificationIncomplete {
8173                        project_root: lossless_project_root_display(root),
8174                        worktree: None,
8175                        status: IndexReadStatus::VerificationIncomplete,
8176                        reason: IndexVerificationReason::SourceInspectionFailed,
8177                        scope: IndexRefreshScope::Full,
8178                        message: format!("failed to inspect '{}': {source}", path.display()),
8179                    },
8180                )));
8181            }
8182        }
8183    }
8184    absent_paths.sort();
8185    absent_paths.dedup();
8186    let candidate_paths = nodes
8187        .iter()
8188        .map(|node| node.path.clone())
8189        .chain(absent_paths.iter().cloned())
8190        .collect::<HashSet<_>>();
8191    let mut sorted_candidate_paths = candidate_paths.into_iter().collect::<Vec<_>>();
8192    sorted_candidate_paths.sort();
8193    let existing_nodes = sorted_candidate_paths
8194        .iter()
8195        .filter_map(|path| {
8196            baseline_by_path
8197                .get(path)
8198                .map(|node| (path.clone(), (*node).clone()))
8199        })
8200        .collect::<HashMap<_, _>>();
8201    if absent_paths.iter().any(|path| {
8202        existing_nodes
8203            .get(path)
8204            .is_some_and(|node| node.kind == NodeKind::Folder)
8205    }) {
8206        return refresh_index_controlled(store, plan, symbol_options, control);
8207    }
8208    graph_projection::cleanup_abandoned_repository_graph_staging(store, root, control)?;
8209    direct_document_paths.extend(
8210        nodes
8211            .iter()
8212            .map(|node| node.path.clone())
8213            .chain(absent_paths.iter().cloned()),
8214    );
8215    nodes.retain(|node| {
8216        existing_nodes
8217            .get(&node.path)
8218            .is_none_or(|indexed| !same_indexed_source(node, indexed))
8219    });
8220    absent_paths.retain(|path| existing_nodes.contains_key(path));
8221    let changed_paths = nodes
8222        .iter()
8223        .map(|node| node.path.clone())
8224        .chain(absent_paths.iter().cloned())
8225        .collect::<HashSet<_>>();
8226    direct_document_paths
8227        .retain(|path| changed_paths.contains(path) || !baseline_by_path.contains_key(path));
8228    if nodes.is_empty() && absent_paths.is_empty() && direct_document_paths.is_empty() {
8229        revalidate_staged_publication_inputs_controlled(plan, &baseline_nodes, None, control)?;
8230        return Ok(empty_index_refresh_report(plan.text_options));
8231    }
8232    control.check(IndexWorkStage::Publication)?;
8233    store.probe_index_publication_writer()?;
8234    drop(existing_nodes);
8235    drop(baseline_by_path);
8236    let previous_hashes = indexed_file_hashes_for_paths(store, &changed_paths)?;
8237    let mut text_paths = changed_paths.iter().cloned().collect::<Vec<_>>();
8238    text_paths.sort();
8239    let text =
8240        stage_text_index_for_changed_paths_controlled(root, &nodes, plan.text_options, control)?;
8241    let content_classifications = stage_file_content_classifications(&nodes, &text.rows);
8242    let protected_purpose_paths = protected_purpose_paths(&nodes, None);
8243    let target_paths = nodes
8244        .iter()
8245        .filter(|node| node.kind == NodeKind::File)
8246        .map(|node| node.path.clone())
8247        .collect::<HashSet<_>>();
8248    let expected_nodes = expected_nodes_after_incremental(baseline_nodes, &nodes, &absent_paths);
8249    let contract_fingerprint = plan.publication_contract_fingerprint();
8250    let retained_before_symbols = staged_publication_identity_bytes(root, &contract_fingerprint)
8251        .saturating_add(staged_string_bytes(&text_paths))
8252        .saturating_add(staged_string_bytes(&absent_paths))
8253        .saturating_add(staged_node_bytes(&expected_nodes))
8254        .saturating_add(staged_node_bytes(&nodes))
8255        .saturating_add(staged_text_bytes(&text))
8256        .saturating_add(staged_file_content_classification_bytes(
8257            &content_classifications,
8258        ));
8259    let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
8260    let symbols = stage_symbols_for_nodes_with_limits(
8261        store,
8262        root,
8263        #[cfg(feature = "optional-parser-supervisor")]
8264        &plan.optional_parser_selection,
8265        &nodes,
8266        symbol_options,
8267        Some(&previous_hashes),
8268        Some(&target_paths),
8269        &protected_purpose_paths,
8270        control,
8271        symbol_limits,
8272    )?;
8273    let graph = graph_projection::stage_incremental_repository_graph(
8274        store,
8275        root,
8276        base_generation,
8277        &expected_nodes,
8278        &direct_document_paths.into_iter().collect::<Vec<_>>(),
8279        &scan_policy,
8280        &symbols,
8281        control,
8282    )?;
8283    let structural_summaries = stage_structural_summaries_for_nodes_controlled(
8284        store,
8285        &nodes,
8286        &text.rows,
8287        Some(&symbols),
8288        &protected_purpose_paths,
8289        symbol_options.effective_workers(),
8290        control,
8291    )?;
8292    enforce_publication_staging_budget(
8293        retained_before_symbols
8294            .saturating_add(symbols.retained_bytes)
8295            .saturating_add(graph.retained_bytes())
8296            .saturating_add(structural_summaries.retained_bytes),
8297    )?;
8298    let batch = IndexPublicationBatch {
8299        base_generation,
8300        contract_fingerprint,
8301        root: root.clone(),
8302        nodes: NodePublicationBatch::Incremental {
8303            nodes,
8304            absent_paths,
8305            expected_nodes,
8306        },
8307        purpose_import: None,
8308        text_paths,
8309        text,
8310        content_classifications,
8311        symbols,
8312        graph,
8313        structural_summaries,
8314    };
8315    revalidate_staged_publication_inputs_controlled(
8316        plan,
8317        batch.nodes.expected_nodes(),
8318        None,
8319        control,
8320    )?;
8321    let outcome = publish_index_batch(store, batch, control)?;
8322    Ok(IndexRefreshReport {
8323        text_index: outcome.text_index,
8324        structural_summaries: outcome.structural_summaries,
8325        symbols: outcome.symbols,
8326    })
8327}
8328
8329/// Seed built-in purposes for reserved `ProjectAtlas` metadata nodes when needed.
8330pub(crate) fn seed_builtin_projectatlas_purposes(
8331    store: &AtlasStore,
8332    nodes: &[Node],
8333) -> Result<(), CliError> {
8334    let indexed_paths = nodes
8335        .iter()
8336        .map(|node| node.path.as_str())
8337        .collect::<HashSet<_>>();
8338    for (path, purpose) in BUILTIN_PROJECTATLAS_PURPOSES {
8339        if !indexed_paths.contains(path) {
8340            continue;
8341        }
8342        let Some(indexed) = store.load_node_by_path(path)? else {
8343            continue;
8344        };
8345        if !matches!(
8346            indexed.purpose.status,
8347            PurposeStatus::Approved | PurposeStatus::Stale
8348        ) {
8349            store.set_purpose(path, purpose, PurposeSource::Imported)?;
8350        }
8351    }
8352    Ok(())
8353}
8354
8355/// Refresh structural summaries while observing the operation work boundary.
8356#[cfg(test)]
8357pub(crate) fn refresh_structural_summaries_for_nodes(
8358    store: &mut AtlasStore,
8359    nodes: &[Node],
8360    text_rows: &[TextIndexRow],
8361) -> Result<StructuralSummaryReport, CliError> {
8362    let control = standalone_index_work_control();
8363    refresh_structural_summaries_for_nodes_controlled(store, nodes, text_rows, &control)
8364}
8365
8366/// Refresh structural summaries while observing the operation work boundary.
8367#[cfg(test)]
8368fn refresh_structural_summaries_for_nodes_controlled(
8369    store: &mut AtlasStore,
8370    nodes: &[Node],
8371    text_rows: &[TextIndexRow],
8372    control: &IndexWorkControl,
8373) -> Result<StructuralSummaryReport, CliError> {
8374    let staged = stage_structural_summaries_for_nodes_controlled(
8375        store,
8376        nodes,
8377        text_rows,
8378        None,
8379        &HashSet::new(),
8380        2,
8381        control,
8382    )?;
8383    apply_structural_summary_stage(store, &staged, control)?;
8384    Ok(staged.report)
8385}
8386
8387/// Derive structural summary mutations without acquiring the `SQLite` writer.
8388fn stage_structural_summaries_for_nodes_controlled(
8389    store: &AtlasStore,
8390    nodes: &[Node],
8391    text_rows: &[TextIndexRow],
8392    symbols: Option<&SymbolBuildStage>,
8393    protected_purpose_paths: &HashSet<String>,
8394    max_workers: usize,
8395    control: &IndexWorkControl,
8396) -> Result<StructuralSummaryStage, CliError> {
8397    control.check(IndexWorkStage::TextIndex)?;
8398    let candidates = nodes
8399        .iter()
8400        .filter(|node| node.kind == NodeKind::File)
8401        .filter(|node| is_structural_summary_candidate(&node.path, node.language.as_deref()))
8402        .collect::<Vec<_>>();
8403    if candidates.is_empty() {
8404        return Ok(StructuralSummaryStage {
8405            report: StructuralSummaryReport::default(),
8406            changes: Vec::new(),
8407            retained_bytes: 0,
8408        });
8409    }
8410    let paths = candidates
8411        .iter()
8412        .map(|node| node.path.clone())
8413        .collect::<Vec<_>>();
8414    let indexed_nodes = store
8415        .load_nodes_by_paths(&paths)?
8416        .into_iter()
8417        .map(|indexed| (indexed.node.path.clone(), indexed))
8418        .collect::<HashMap<_, _>>();
8419    let symbol_counts = store.symbol_counts_for_paths(&paths)?;
8420    let text_by_path = text_rows
8421        .iter()
8422        .filter_map(|row| row.text.as_ref().map(|text| (text.path.as_str(), text)))
8423        .collect::<HashMap<_, _>>();
8424    let reason_by_path = text_rows
8425        .iter()
8426        .map(|row| (row.path.as_str(), row.reason))
8427        .collect::<HashMap<_, _>>();
8428    let mut staged_symbol_counts = HashMap::new();
8429    let mut staged_symbol_summaries = HashMap::new();
8430    let mut staged_structural_summaries = HashMap::new();
8431    if let Some(symbols) = symbols {
8432        for change in &symbols.changes {
8433            match change {
8434                SymbolProjectionChange::Parsed(parsed) => {
8435                    staged_symbol_counts.insert(parsed.path.as_str(), parsed.graph.symbols.len());
8436                    staged_symbol_summaries.insert(parsed.path.as_str(), parsed.summary.as_str());
8437                    if parsed.summary_is_structural {
8438                        staged_structural_summaries
8439                            .insert(parsed.path.as_str(), parsed.purpose_suggestion.is_some());
8440                    }
8441                }
8442                SymbolProjectionChange::Clear { path, .. } => {
8443                    staged_symbol_counts.insert(path.as_str(), 0);
8444                }
8445            }
8446        }
8447    }
8448    let mut report = StructuralSummaryReport {
8449        candidates: paths.len(),
8450        ..StructuralSummaryReport::default()
8451    };
8452    let worker_count = worker_count_for_work(candidates.len(), max_workers);
8453    let pool = ThreadPoolBuilder::new()
8454        .num_threads(worker_count)
8455        .build()
8456        .map_err(|source| {
8457            CliError::InvalidInput(format!("structural summary worker pool failed: {source}"))
8458        })?;
8459    let derivations = pool.install(|| {
8460        candidates
8461            .par_iter()
8462            .map(|node| -> Result<StructuralSummaryDerivation, CliError> {
8463                control.check(IndexWorkStage::TextIndex)?;
8464                let existing = indexed_nodes.get(&node.path);
8465                let max_bytes = source_input_byte_limit(
8466                    &node.path,
8467                    node.language.as_deref(),
8468                    node.size_bytes,
8469                    MAX_SYMBOL_FILE_BYTES,
8470                    IndexWorkStage::TextIndex,
8471                )?;
8472                if reason_by_path.get(node.path.as_str()) == Some(&TextIndexSkipReason::TooLarge)
8473                    || node
8474                        .size_bytes
8475                        .is_some_and(|size_bytes| size_bytes > max_bytes)
8476                {
8477                    return Ok(StructuralSummaryDerivation {
8478                        change: Some(StructuralSummaryChange::Clear {
8479                            path: node.path.clone(),
8480                        }),
8481                        cleared: 1,
8482                        too_large: 1,
8483                        retained_bytes: node.path.len() as u64,
8484                        ..StructuralSummaryDerivation::default()
8485                    });
8486                }
8487                let Some(text) = text_by_path.get(node.path.as_str()) else {
8488                    return Ok(StructuralSummaryDerivation {
8489                        change: Some(StructuralSummaryChange::Clear {
8490                            path: node.path.clone(),
8491                        }),
8492                        cleared: 1,
8493                        binary_or_non_utf8: usize::from(
8494                            reason_by_path.get(node.path.as_str())
8495                                == Some(&TextIndexSkipReason::BinaryOrNonUtf8),
8496                        ),
8497                        retained_bytes: node.path.len() as u64,
8498                        ..StructuralSummaryDerivation::default()
8499                    });
8500                };
8501                if let Some(purpose_suggested) = staged_structural_summaries.get(node.path.as_str())
8502                {
8503                    return Ok(StructuralSummaryDerivation {
8504                        summarized: 1,
8505                        purpose_suggestions: usize::from(*purpose_suggested),
8506                        ..StructuralSummaryDerivation::default()
8507                    });
8508                }
8509                let symbol_count = staged_symbol_counts
8510                    .get(node.path.as_str())
8511                    .copied()
8512                    .or_else(|| symbol_counts.get(node.path.as_str()).copied())
8513                    .unwrap_or_default();
8514                let effective_summary = staged_symbol_summaries
8515                    .get(node.path.as_str())
8516                    .copied()
8517                    .or_else(|| existing.and_then(|indexed| indexed.summary.as_deref()));
8518                if symbol_count > 0
8519                    && effective_summary.is_some_and(|summary| {
8520                        !summary.trim().is_empty() && !is_scanner_fallback_summary(summary)
8521                    })
8522                {
8523                    return Ok(StructuralSummaryDerivation::default());
8524                }
8525                let Some(summary) = structural_summary_for_path(
8526                    &node.path,
8527                    node.language.as_deref(),
8528                    &text.content,
8529                ) else {
8530                    return Ok(StructuralSummaryDerivation {
8531                        change: Some(StructuralSummaryChange::Clear {
8532                            path: node.path.clone(),
8533                        }),
8534                        cleared: 1,
8535                        retained_bytes: node.path.len() as u64,
8536                        ..StructuralSummaryDerivation::default()
8537                    });
8538                };
8539                let purpose_needs_suggestion = !protected_purpose_paths.contains(&node.path)
8540                    && existing.is_none_or(|indexed| {
8541                        matches!(
8542                            indexed.purpose.status,
8543                            PurposeStatus::Missing | PurposeStatus::Suggested
8544                        )
8545                    });
8546                let purpose_suggestion =
8547                    purpose_needs_suggestion.then(|| suggest_file_purpose(&node.path, &summary));
8548                let purpose_suggestions = usize::from(purpose_suggestion.is_some());
8549                let retained_bytes = (node.path.len() as u64)
8550                    .saturating_add(summary.len() as u64)
8551                    .saturating_add(
8552                        purpose_suggestion
8553                            .as_ref()
8554                            .map_or(0, |suggestion| suggestion.len() as u64),
8555                    );
8556                control.check(IndexWorkStage::TextIndex)?;
8557                Ok(StructuralSummaryDerivation {
8558                    change: Some(StructuralSummaryChange::Set {
8559                        path: node.path.clone(),
8560                        summary,
8561                        purpose_suggestion,
8562                    }),
8563                    summarized: 1,
8564                    purpose_suggestions,
8565                    retained_bytes,
8566                    ..StructuralSummaryDerivation::default()
8567                })
8568            })
8569            .collect::<Result<Vec<_>, CliError>>()
8570    })?;
8571    let mut changes = Vec::new();
8572    let mut retained_bytes = 0_u64;
8573    for derivation in derivations {
8574        report.summarized += derivation.summarized;
8575        report.cleared += derivation.cleared;
8576        report.too_large += derivation.too_large;
8577        report.binary_or_non_utf8 += derivation.binary_or_non_utf8;
8578        report.purpose_suggestions += derivation.purpose_suggestions;
8579        retained_bytes = retained_bytes.saturating_add(derivation.retained_bytes);
8580        if let Some(change) = derivation.change {
8581            changes.push(change);
8582        }
8583    }
8584    Ok(StructuralSummaryStage {
8585        report,
8586        changes,
8587        retained_bytes,
8588    })
8589}
8590
8591/// Apply prepared structural summaries inside the parent publication transaction.
8592fn apply_structural_summary_stage(
8593    store: &mut AtlasStore,
8594    staged: &StructuralSummaryStage,
8595    control: &IndexWorkControl,
8596) -> Result<(), CliError> {
8597    for change in &staged.changes {
8598        control.check(IndexWorkStage::Publication)?;
8599        match change {
8600            StructuralSummaryChange::Set {
8601                path,
8602                summary,
8603                purpose_suggestion,
8604            } => {
8605                store.set_node_summary(path, summary)?;
8606                if let Some(suggestion) = purpose_suggestion.as_deref() {
8607                    store.set_suggested_purpose(path, suggestion)?;
8608                }
8609            }
8610            StructuralSummaryChange::Clear { path } => store.clear_node_summary(path)?,
8611        }
8612    }
8613    control.check(IndexWorkStage::Publication)?;
8614    Ok(())
8615}
8616
8617/// Refresh the persisted text index for every scanned file node.
8618#[cfg(test)]
8619pub(crate) fn refresh_text_index_for_nodes(
8620    store: &mut AtlasStore,
8621    root: &Path,
8622    nodes: &[Node],
8623    options: TextIndexOptions,
8624) -> Result<TextIndexReport, CliError> {
8625    let control = standalone_index_work_control();
8626    Ok(
8627        refresh_text_index_for_nodes_with_rows_controlled(store, root, nodes, options, &control)?
8628            .report,
8629    )
8630}
8631
8632/// Refresh all text rows under one cancellation and staging-byte boundary.
8633#[cfg(test)]
8634pub(crate) fn refresh_text_index_for_nodes_with_rows(
8635    store: &mut AtlasStore,
8636    root: &Path,
8637    nodes: &[Node],
8638    options: TextIndexOptions,
8639) -> Result<TextIndexRefresh, CliError> {
8640    let control = standalone_index_work_control();
8641    refresh_text_index_for_nodes_with_rows_controlled(store, root, nodes, options, &control)
8642}
8643
8644/// Refresh all text rows under one cancellation and staging-byte boundary.
8645#[cfg(test)]
8646fn refresh_text_index_for_nodes_with_rows_controlled(
8647    store: &mut AtlasStore,
8648    root: &Path,
8649    nodes: &[Node],
8650    options: TextIndexOptions,
8651    control: &IndexWorkControl,
8652) -> Result<TextIndexRefresh, CliError> {
8653    let file_paths = nodes
8654        .iter()
8655        .filter(|node| node.kind == NodeKind::File)
8656        .map(|node| node.path.clone())
8657        .collect::<Vec<_>>();
8658    refresh_text_index_for_changed_paths_with_rows_controlled(
8659        store,
8660        root,
8661        &file_paths,
8662        nodes,
8663        options,
8664        control,
8665    )
8666}
8667
8668/// Refresh selected text rows under one cancellation and staging-byte boundary.
8669#[cfg(test)]
8670fn refresh_text_index_for_changed_paths_with_rows_controlled(
8671    store: &mut AtlasStore,
8672    root: &Path,
8673    considered_paths: &[String],
8674    nodes: &[Node],
8675    options: TextIndexOptions,
8676    control: &IndexWorkControl,
8677) -> Result<TextIndexRefresh, CliError> {
8678    let staged = stage_text_index_for_changed_paths_controlled(root, nodes, options, control)?;
8679    apply_text_index_stage(store, considered_paths, &staged, control)?;
8680    Ok(staged)
8681}
8682
8683/// Build selected persisted-text rows without acquiring the `SQLite` writer.
8684fn stage_text_index_for_changed_paths_controlled(
8685    root: &Path,
8686    nodes: &[Node],
8687    options: TextIndexOptions,
8688    control: &IndexWorkControl,
8689) -> Result<TextIndexRefresh, CliError> {
8690    control.check(IndexWorkStage::TextIndex)?;
8691    let text_rows = indexed_file_texts_for_nodes_controlled(root, nodes, options, control)?;
8692    let indexed = text_rows.iter().filter(|row| row.text.is_some()).count();
8693    let indexed_bytes = text_rows
8694        .iter()
8695        .filter_map(|row| row.text.as_ref())
8696        .map(|text| text.byte_count)
8697        .fold(0usize, usize::saturating_add);
8698    let file_candidates = nodes
8699        .iter()
8700        .filter(|node| node.kind == NodeKind::File)
8701        .count();
8702    let binary_or_non_utf8 = text_rows
8703        .iter()
8704        .filter(|row| row.reason == TextIndexSkipReason::BinaryOrNonUtf8)
8705        .count();
8706    let too_large = text_rows
8707        .iter()
8708        .filter(|row| row.reason == TextIndexSkipReason::TooLarge)
8709        .count();
8710    let report = TextIndexReport {
8711        candidates: file_candidates,
8712        indexed,
8713        binary_or_non_utf8,
8714        too_large,
8715        skipped: file_candidates.saturating_sub(indexed),
8716        max_bytes: options.max_bytes,
8717        bytes: indexed_bytes,
8718    };
8719    control.check(IndexWorkStage::TextIndex)?;
8720    Ok(TextIndexRefresh {
8721        report,
8722        rows: text_rows,
8723    })
8724}
8725
8726/// Apply prepared persisted-text rows inside the parent publication transaction.
8727fn apply_text_index_stage(
8728    store: &mut AtlasStore,
8729    considered_paths: &[String],
8730    staged: &TextIndexRefresh,
8731    control: &IndexWorkControl,
8732) -> Result<(), CliError> {
8733    let text_by_path = staged
8734        .rows
8735        .iter()
8736        .filter_map(|row| row.text.as_ref().map(|text| (text.path.as_str(), text)))
8737        .collect::<HashMap<_, _>>();
8738    for paths in considered_paths.chunks(PUBLICATION_TEXT_BATCH_SIZE) {
8739        control.check(IndexWorkStage::Publication)?;
8740        store.replace_file_texts_for_paths(
8741            paths,
8742            paths
8743                .iter()
8744                .filter_map(|path| text_by_path.get(path.as_str()).copied()),
8745        )?;
8746    }
8747    control.check(IndexWorkStage::Publication)?;
8748    Ok(())
8749}
8750
8751/// Build indexed text rows for UTF-8 scanned files with size caps.
8752#[cfg(test)]
8753pub(crate) fn indexed_file_texts_for_nodes(
8754    root: &Path,
8755    nodes: &[Node],
8756    options: TextIndexOptions,
8757) -> Result<Vec<TextIndexRow>, CliError> {
8758    let control = standalone_index_work_control();
8759    indexed_file_texts_for_nodes_controlled(root, nodes, options, &control)
8760}
8761
8762/// Build bounded UTF-8 text rows while observing cancellation between files.
8763fn indexed_file_texts_for_nodes_controlled(
8764    root: &Path,
8765    nodes: &[Node],
8766    options: TextIndexOptions,
8767    control: &IndexWorkControl,
8768) -> Result<Vec<TextIndexRow>, CliError> {
8769    indexed_file_texts_for_nodes_with_limit(root, nodes, options, MAX_STAGED_TEXT_BYTES, control)
8770}
8771
8772/// Build UTF-8 text rows under an explicit aggregate staging-byte limit.
8773fn indexed_file_texts_for_nodes_with_limit(
8774    root: &Path,
8775    nodes: &[Node],
8776    options: TextIndexOptions,
8777    max_staged_bytes: u64,
8778    control: &IndexWorkControl,
8779) -> Result<Vec<TextIndexRow>, CliError> {
8780    let mut rows = Vec::new();
8781    let mut staged_bytes = 0_u64;
8782    for node in nodes.iter().filter(|node| node.kind == NodeKind::File) {
8783        control.check(IndexWorkStage::TextIndex)?;
8784        let max_bytes = source_input_byte_limit(
8785            &node.path,
8786            node.language.as_deref(),
8787            node.size_bytes,
8788            options.max_bytes,
8789            IndexWorkStage::TextIndex,
8790        )?;
8791        if node
8792            .size_bytes
8793            .is_some_and(|size_bytes| size_bytes > max_bytes)
8794        {
8795            rows.push(TextIndexRow {
8796                path: node.path.clone(),
8797                text: None,
8798                reason: TextIndexSkipReason::TooLarge,
8799            });
8800            continue;
8801        }
8802        let remaining_staged_bytes = max_staged_bytes.saturating_sub(staged_bytes);
8803        let is_document = document_format_for_path(&node.path, node.language.as_deref()).is_some();
8804        if !is_document
8805            && node
8806                .size_bytes
8807                .is_some_and(|size_bytes| size_bytes > remaining_staged_bytes)
8808        {
8809            return Err(IndexWorkFailure::resource_limit(
8810                IndexWorkStage::TextIndex,
8811                IndexWorkResource::TextBytes,
8812                max_staged_bytes,
8813                staged_bytes.saturating_add(node.size_bytes.unwrap_or_default()),
8814            )
8815            .into());
8816        }
8817        let native_path = root.join(repo_path_to_native(&node.path));
8818        let read_limit = if is_document {
8819            max_bytes
8820        } else {
8821            max_bytes.min(remaining_staged_bytes)
8822        };
8823        let aggregate_limit_is_narrower = !is_document && remaining_staged_bytes <= max_bytes;
8824        let bytes = match read_source_bytes_controlled(
8825            &native_path,
8826            read_limit,
8827            IndexWorkStage::TextIndex,
8828            control,
8829        ) {
8830            Ok(bytes) => bytes,
8831            Err(SourceReadFailure::Io(source)) => {
8832                return Err(CliError::Io {
8833                    path: native_path,
8834                    source,
8835                });
8836            }
8837            Err(SourceReadFailure::IndexWork(failure)) => return Err(failure.into()),
8838            Err(SourceReadFailure::LimitExceeded { observed }) if aggregate_limit_is_narrower => {
8839                return Err(IndexWorkFailure::resource_limit(
8840                    IndexWorkStage::TextIndex,
8841                    IndexWorkResource::TextBytes,
8842                    max_staged_bytes,
8843                    staged_bytes.saturating_add(observed),
8844                )
8845                .into());
8846            }
8847            Err(SourceReadFailure::LimitExceeded { .. }) => {
8848                return Err(source_changed_during_derivation(root, &node.path));
8849            }
8850        };
8851        control.check(IndexWorkStage::TextIndex)?;
8852        let current_hash = blake3::hash(&bytes).to_hex().to_string();
8853        if node.content_hash.as_deref() != Some(current_hash.as_str()) {
8854            return Err(source_changed_during_derivation(root, &node.path));
8855        }
8856        let content = if is_document {
8857            extract_document_text_controlled(&bytes, &node.path, node.language.as_deref(), control)
8858                .map_err(|error| document_navigation_error(&node.path, error))?
8859                .text
8860        } else {
8861            let Ok(content) = String::from_utf8(bytes) else {
8862                rows.push(TextIndexRow {
8863                    path: node.path.clone(),
8864                    text: None,
8865                    reason: TextIndexSkipReason::BinaryOrNonUtf8,
8866                });
8867                continue;
8868            };
8869            content
8870        };
8871        let next_staged_bytes = staged_bytes.saturating_add(content.len() as u64);
8872        if next_staged_bytes > max_staged_bytes {
8873            return Err(IndexWorkFailure::resource_limit(
8874                IndexWorkStage::TextIndex,
8875                IndexWorkResource::TextBytes,
8876                max_staged_bytes,
8877                next_staged_bytes,
8878            )
8879            .into());
8880        }
8881        staged_bytes = next_staged_bytes;
8882        rows.push(TextIndexRow {
8883            path: node.path.clone(),
8884            reason: TextIndexSkipReason::Indexed,
8885            text: Some(IndexedFileText {
8886                path: node.path.clone(),
8887                content_hash: node.content_hash.clone(),
8888                byte_count: content.len(),
8889                line_count: content.lines().count(),
8890                content,
8891            }),
8892        });
8893    }
8894    Ok(rows)
8895}
8896
8897/// Load indexed file hashes for incremental refresh comparison.
8898pub(crate) fn indexed_file_hashes(store: &AtlasStore) -> Result<HashMap<String, String>, CliError> {
8899    Ok(store
8900        .load_nodes()?
8901        .into_iter()
8902        .filter(|node| node.node.kind == NodeKind::File)
8903        .filter_map(|node| node.node.content_hash.map(|hash| (node.node.path, hash)))
8904        .collect::<HashMap<_, _>>())
8905}
8906
8907/// Load indexed file hashes for selected repository paths.
8908pub(crate) fn indexed_file_hashes_for_paths(
8909    store: &AtlasStore,
8910    paths: &HashSet<String>,
8911) -> Result<HashMap<String, String>, CliError> {
8912    let mut sorted_paths = paths.iter().cloned().collect::<Vec<_>>();
8913    sorted_paths.sort();
8914    Ok(store
8915        .load_nodes_by_paths(&sorted_paths)?
8916        .into_iter()
8917        .filter(|node| node.node.kind == NodeKind::File)
8918        .filter_map(|node| node.node.content_hash.map(|hash| (node.node.path, hash)))
8919        .collect::<HashMap<_, _>>())
8920}
8921
8922/// Return event paths in deterministic order.
8923pub(crate) fn sorted_watch_paths(paths: &HashSet<PathBuf>) -> Vec<PathBuf> {
8924    let mut paths = paths.iter().cloned().collect::<Vec<_>>();
8925    paths.sort();
8926    paths
8927}
8928
8929/// Normalize a deleted path if it belongs to the watched repository.
8930pub(crate) fn normalized_deleted_path(
8931    root: &Path,
8932    path: &Path,
8933) -> Result<Option<String>, CliError> {
8934    match normalize_repo_path(root, path) {
8935        Ok(path) => Ok(valid_watch_relative_path(path)),
8936        Err(projectatlas_core::CoreError::PathOutsideRoot { .. }) => {
8937            Ok(native_display_relative_path(root, path).and_then(valid_watch_relative_path))
8938        }
8939        Err(source) => Err(CliError::InvalidInput(source.to_string())),
8940    }
8941}
8942
8943/// Inspect and optionally remove legacy `.purpose` files.
8944pub(crate) fn strip_legacy_purpose(
8945    root: &Path,
8946    config_path: Option<&Path>,
8947    apply: bool,
8948    dry_run: bool,
8949    strip_source_headers: bool,
8950) -> Result<LegacyPurposeReport, CliError> {
8951    let root = root.canonicalize().map_err(|source| CliError::Io {
8952        path: root.to_path_buf(),
8953        source,
8954    })?;
8955    let scan_options = scan_options_for_root(config_path, &root)?;
8956    let nodes = scan_repo(&root, &scan_options)?;
8957    let effective_dry_run = dry_run || !apply;
8958    let purpose_files = indexed_purpose_files(&root, &nodes);
8959    let mut removed = 0;
8960    if !effective_dry_run {
8961        for path in &purpose_files {
8962            let native = root.join(repo_path_to_native(path));
8963            fs::remove_file(&native).map_err(|source| CliError::Io {
8964                path: native,
8965                source,
8966            })?;
8967            removed += 1;
8968        }
8969    }
8970    let source_header_candidates = if strip_source_headers {
8971        purpose_header_candidates(&root, &nodes)?
8972    } else {
8973        Vec::new()
8974    };
8975    Ok(LegacyPurposeReport {
8976        applied: !effective_dry_run,
8977        purpose_files_found: purpose_files.len(),
8978        purpose_files_removed: removed,
8979        source_header_candidates,
8980        purpose_files,
8981    })
8982}
8983
8984/// Collect `.purpose` files only from folders included in the normal index.
8985pub(crate) fn indexed_purpose_files(root: &Path, nodes: &[Node]) -> Vec<String> {
8986    let mut purpose_files = Vec::new();
8987    for node in nodes.iter().filter(|node| node.kind == NodeKind::Folder) {
8988        let relative = if node.path == "." {
8989            ".purpose".to_string()
8990        } else {
8991            format!("{}/.purpose", node.path)
8992        };
8993        let native = root.join(repo_path_to_native(&relative));
8994        if native.exists() {
8995            purpose_files.push(relative);
8996        }
8997    }
8998    purpose_files.sort();
8999    purpose_files
9000}
9001
9002/// Return source files that appear to start with legacy Purpose headers.
9003pub(crate) fn purpose_header_candidates(
9004    root: &Path,
9005    nodes: &[Node],
9006) -> Result<Vec<String>, CliError> {
9007    let mut candidates = Vec::new();
9008    for node in nodes
9009        .iter()
9010        .filter(|node| node.kind == NodeKind::File)
9011        .filter(|node| is_symbol_candidate(&node.path, node.language.as_deref()))
9012    {
9013        let path = root.join(repo_path_to_native(&node.path));
9014        let content = fs::read_to_string(&path).map_err(|source| CliError::Io { path, source })?;
9015        if content
9016            .lines()
9017            .take(3)
9018            .any(|line| line.trim_start().contains("Purpose:"))
9019        {
9020            candidates.push(node.path.clone());
9021        }
9022    }
9023    Ok(candidates)
9024}
9025
9026#[cfg(test)]
9027mod tests {
9028    use super::*;
9029    use projectatlas_core::graph::{
9030        DocumentTargetUnresolvedReason, EntitySelector, ExtendedRelationKind, GraphRelationKind,
9031        RelationResolution, RepositoryNodePath,
9032    };
9033    use projectatlas_db::{DbError, RepositoryGraphRelationQuery, WorktreeAlias};
9034    use std::error::Error;
9035    use std::fmt::Debug;
9036    use std::process::Command as StdCommand;
9037
9038    fn run_git_fixture(command: &mut StdCommand) -> Result<(), Box<dyn Error>> {
9039        let output = command.output()?;
9040        if !output.status.success() {
9041            return Err(io::Error::other(format!(
9042                "Git fixture command failed: {}{}",
9043                String::from_utf8_lossy(&output.stdout),
9044                String::from_utf8_lossy(&output.stderr)
9045            ))
9046            .into());
9047        }
9048        Ok(())
9049    }
9050
9051    /// Downgrade a current fixture to the released schema-19 worktree shape.
9052    fn drop_native_worktree_identity_schema(
9053        connection: &rusqlite::Connection,
9054    ) -> rusqlite::Result<()> {
9055        connection.execute_batch(
9056            "DROP INDEX IF EXISTS idx_worktree_registrations_active_native_administrative_directory;
9057             DROP INDEX IF EXISTS idx_worktree_registrations_active_native_root;
9058             ALTER TABLE worktree_registrations DROP COLUMN git_common_directory_identity;
9059             ALTER TABLE worktree_registrations DROP COLUMN git_administrative_directory_identity;
9060             ALTER TABLE worktree_registrations DROP COLUMN last_root_identity;",
9061        )
9062    }
9063
9064    #[test]
9065    fn synchronization_control_identity_rejects_replacement_without_caller_identity()
9066    -> Result<(), Box<dyn Error>> {
9067        let temp = tempfile::tempdir()?;
9068        let root = temp.path().join("control");
9069        let state = root.join(".projectatlas");
9070        let database = state.join("projectatlas.db");
9071        fs::create_dir_all(&state)?;
9072        let original = AtlasStore::open_for_project(&database, &root)?;
9073        let captured = require_synchronization_control_identity(&original, None)?;
9074        require_eq(
9075            &require_synchronization_control_identity(&original, Some(captured))?,
9076            &captured,
9077            "stable control identity",
9078        )?;
9079        drop(original);
9080
9081        fs::rename(&state, root.join(".projectatlas-captured-control"))?;
9082        fs::create_dir(&state)?;
9083        let replacement = AtlasStore::open_for_project(&database, &root)?;
9084        let rejected = require_synchronization_control_identity(&replacement, Some(captured));
9085        require_eq(
9086            &rejected
9087                .as_ref()
9088                .is_err_and(|error| error.to_string().contains("control atlas identity changed")),
9089            &true,
9090            "replacement control identity rejection",
9091        )
9092    }
9093
9094    #[cfg(windows)]
9095    #[test]
9096    fn default_mcp_project_root_recovers_custom_predecessor_candidate() -> Result<(), Box<dyn Error>>
9097    {
9098        let temp = tempfile::tempdir()?;
9099        let root = temp.path().join("legacy-project");
9100        let database = temp.path().join("custom-projectatlas.db");
9101        fs::create_dir(&root)?;
9102        drop(AtlasStore::open_for_project(&database, &root)?);
9103        {
9104            let connection = rusqlite::Connection::open(&database)?;
9105            drop_native_worktree_identity_schema(&connection)?;
9106            connection.execute_batch(
9107                "DROP TABLE project_root_identity;
9108                 DROP TABLE IF EXISTS graph_identity_rejections;
9109                 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
9110            )?;
9111        }
9112
9113        let resolved = default_mcp_project_root(&database, None)?;
9114        require_eq(
9115            &resolved,
9116            &canonical_source_project_root(&root)?,
9117            "custom predecessor root recovery",
9118        )?;
9119        drop(AtlasStore::open_for_project(&database, &root)?);
9120        Ok(())
9121    }
9122
9123    #[test]
9124    fn init_rejects_current_wrong_root_before_project_writes() -> Result<(), Box<dyn Error>> {
9125        let temp = tempfile::tempdir()?;
9126        let selected_root = temp.path().join("selected-root");
9127        let bound_root = temp.path().join("bound-root");
9128        let database = temp.path().join("external-projectatlas.db");
9129        let config_path = selected_root.join("external-config/config.toml");
9130        fs::create_dir_all(&selected_root)?;
9131        fs::create_dir_all(&bound_root)?;
9132        let persisted_identity = {
9133            let store = AtlasStore::open_for_project(&database, &bound_root)?;
9134            store.project_root_identity()?
9135        };
9136        drop(AtlasStore::open_read_only_for_project(
9137            &database,
9138            &bound_root,
9139        )?);
9140        let _ = read_project_root_identity_read_only(&database)?;
9141        let database_before = fs::read(&database)?;
9142        let sidecars_before = ["wal", "shm", "journal"]
9143            .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
9144        let selected_project_dir = selected_root.join(".projectatlas");
9145        let config_parent = config_path
9146            .parent()
9147            .ok_or_else(|| io::Error::other("selected config has no parent"))?;
9148
9149        let result = run_init_bootstrap(
9150            &selected_root,
9151            &database,
9152            Some(&config_path),
9153            &InitBootstrapOptions {
9154                no_scan: true,
9155                force_rescan: false,
9156                text_index_max_bytes: None,
9157            },
9158        );
9159        require_eq(
9160            &matches!(result, Err(CliError::ProjectMismatch(_))),
9161            &true,
9162            "current wrong-root init did not return a typed project mismatch",
9163        )?;
9164        require_eq(
9165            &(!selected_project_dir.exists() && !config_parent.exists() && !config_path.exists()),
9166            &true,
9167            "current wrong-root init created selected project or config state",
9168        )?;
9169        require_eq(
9170            &(fs::read(&database)? == database_before
9171                && ["wal", "shm", "journal"]
9172                    .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok())
9173                    == sidecars_before),
9174            &true,
9175            "current wrong-root init changed database or sidecar state",
9176        )?;
9177        let reopened = AtlasStore::open_read_only_for_project(&database, &bound_root)?;
9178        require_eq(
9179            &reopened.project_root_identity()?,
9180            &persisted_identity,
9181            "current binding after wrong-root init",
9182        )?;
9183        Ok(())
9184    }
9185
9186    #[test]
9187    fn init_repairs_current_missing_root_identity_from_bound_metadata() -> Result<(), Box<dyn Error>>
9188    {
9189        let temp = tempfile::tempdir()?;
9190        let root = temp.path().join("incomplete-binding-root");
9191        let database = temp.path().join("incomplete-binding.db");
9192        fs::create_dir(&root)?;
9193        let project_instance_id = {
9194            let store = AtlasStore::open_for_project(&database, &root)?;
9195            store
9196                .project_instance_id()?
9197                .ok_or_else(|| io::Error::other("incomplete binding project identity is missing"))?
9198        };
9199        let connection = rusqlite::Connection::open(&database)?;
9200        connection.execute("DELETE FROM project_root_identity", [])?;
9201        drop(connection);
9202
9203        let ordinary_error = AtlasStore::open_for_project(&database, &root)
9204            .err()
9205            .ok_or_else(|| io::Error::other("ordinary open repaired incomplete binding"))?;
9206        require_eq(
9207            &matches!(ordinary_error, DbError::ProjectRootIdentityMissing),
9208            &true,
9209            "ordinary open returned the wrong incomplete-binding error",
9210        )?;
9211
9212        let wrong_root = temp.path().join("incomplete-binding-wrong-root");
9213        fs::create_dir(&wrong_root)?;
9214        let database_before = fs::read(&database)?;
9215        let sidecars_before = ["wal", "shm", "journal"]
9216            .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
9217        let wrong_result = run_init_bootstrap(
9218            &wrong_root,
9219            &database,
9220            None,
9221            &InitBootstrapOptions {
9222                no_scan: true,
9223                force_rescan: false,
9224                text_index_max_bytes: None,
9225            },
9226        );
9227        require_eq(
9228            &matches!(wrong_result, Err(CliError::ProjectMismatch(_))),
9229            &true,
9230            "incomplete-binding init accepted a different root",
9231        )?;
9232        require_eq(
9233            &fs::read(&database)?,
9234            &database_before,
9235            "wrong-root incomplete-binding init changed database bytes",
9236        )?;
9237        require_eq(
9238            &["wal", "shm", "journal"]
9239                .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok()),
9240            &sidecars_before,
9241            "wrong-root incomplete-binding init changed SQLite sidecars",
9242        )?;
9243
9244        let report = run_init_bootstrap(
9245            &root,
9246            &database,
9247            None,
9248            &InitBootstrapOptions {
9249                no_scan: true,
9250                force_rescan: false,
9251                text_index_max_bytes: None,
9252            },
9253        )?;
9254        require_eq(
9255            &report.ok,
9256            &true,
9257            "explicit init did not repair the incomplete binding",
9258        )?;
9259
9260        let reopened = AtlasStore::open_for_project(&database, &root)?;
9261        require_eq(
9262            &reopened.project_instance_id()?,
9263            &Some(project_instance_id),
9264            "explicit init changed the project identity while repairing the root",
9265        )?;
9266        require_eq(
9267            &reopened.project_root_identity()?,
9268            &Some(projectatlas_core::CanonicalProjectRoot::from_path(&root)?),
9269            "explicit init did not restore the native root identity",
9270        )?;
9271        Ok(())
9272    }
9273
9274    #[cfg(windows)]
9275    #[test]
9276    fn init_rejects_predecessor_wrong_root_before_project_writes() -> Result<(), Box<dyn Error>> {
9277        let temp = tempfile::tempdir()?;
9278        let selected_root = temp.path().join("selected-predecessor-root");
9279        let bound_root = temp.path().join("bound-predecessor-root");
9280        let database = temp.path().join("external-predecessor.db");
9281        let config_path = selected_root.join("external-config/config.toml");
9282        fs::create_dir_all(&selected_root)?;
9283        fs::create_dir_all(&bound_root)?;
9284        drop(AtlasStore::open_for_project(&database, &bound_root)?);
9285        {
9286            let connection = rusqlite::Connection::open(&database)?;
9287            drop_native_worktree_identity_schema(&connection)?;
9288            connection.execute_batch(
9289                "DROP TABLE project_root_identity;
9290                 DROP TABLE IF EXISTS graph_identity_rejections;
9291                 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
9292            )?;
9293        }
9294        read_legacy_project_root_candidate_read_only(&database)?;
9295        let database_before = fs::read(&database)?;
9296        let sidecars_before = ["wal", "shm", "journal"]
9297            .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
9298        let selected_project_dir = selected_root.join(".projectatlas");
9299        let config_parent = config_path
9300            .parent()
9301            .ok_or_else(|| io::Error::other("selected predecessor config has no parent"))?;
9302
9303        let result = run_init_bootstrap(
9304            &selected_root,
9305            &database,
9306            Some(&config_path),
9307            &InitBootstrapOptions {
9308                no_scan: true,
9309                force_rescan: false,
9310                text_index_max_bytes: None,
9311            },
9312        );
9313        require_eq(
9314            &matches!(result, Err(CliError::ProjectMismatch(_))),
9315            &true,
9316            "predecessor wrong-root init did not return a typed project mismatch",
9317        )?;
9318        require_eq(
9319            &(!selected_project_dir.exists() && !config_parent.exists() && !config_path.exists()),
9320            &true,
9321            "predecessor wrong-root init created selected project or config state",
9322        )?;
9323        require_eq(
9324            &(fs::read(&database)? == database_before
9325                && ["wal", "shm", "journal"]
9326                    .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok())
9327                    == sidecars_before),
9328            &true,
9329            "predecessor wrong-root init changed database or sidecar state",
9330        )?;
9331        Ok(())
9332    }
9333
9334    #[cfg(unix)]
9335    #[test]
9336    fn runtime_rejects_non_authoritative_predecessor_before_config_discovery()
9337    -> Result<(), Box<dyn Error>> {
9338        fn is_ambiguous_predecessor(error: &CliError) -> bool {
9339            matches!(error, CliError::Db(DbError::ProjectRootIdentityMissing))
9340        }
9341
9342        use std::ffi::OsString;
9343        use std::os::unix::ffi::OsStringExt;
9344
9345        let temp = tempfile::tempdir()?;
9346        let raw_root = temp
9347            .path()
9348            .join(OsString::from_vec(b"runtime-repo\\name".to_vec()));
9349        let replacement_root = temp.path().join("runtime-repo/name");
9350        let database = temp.path().join("runtime-custom-predecessor.db");
9351        fs::create_dir_all(&raw_root)?;
9352        drop(AtlasStore::open_for_project(&database, &raw_root)?);
9353        {
9354            let connection = rusqlite::Connection::open(&database)?;
9355            drop_native_worktree_identity_schema(&connection)?;
9356            connection.execute_batch(
9357                "DROP TABLE project_root_identity;
9358                 DROP TABLE IF EXISTS graph_identity_rejections;
9359                 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
9360            )?;
9361            connection.execute(
9362                "INSERT INTO metadata(key, value) VALUES('project_root', ?1)
9363                 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
9364                [replacement_root.to_string_lossy().into_owned()],
9365            )?;
9366        }
9367        let replacement_config = replacement_root.join(".projectatlas/config.toml");
9368        fs::create_dir_all(
9369            replacement_config
9370                .parent()
9371                .ok_or_else(|| io::Error::other("replacement config has no parent"))?,
9372        )?;
9373        fs::write(
9374            &replacement_config,
9375            format!(
9376                "[project]\nroot = {}\n",
9377                serde_json::to_string(&replacement_root.to_string_lossy())?
9378            ),
9379        )?;
9380        let replacement_config_before = fs::read(&replacement_config)?;
9381        require_condition(
9382            matches!(
9383                read_legacy_project_root_candidate_read_only(&database),
9384                Err(DbError::ProjectRootIdentityMissing)
9385            ),
9386            "non-authoritative predecessor candidate was exposed",
9387        )?;
9388        let database_before = fs::read(&database)?;
9389        let sidecars_before = ["wal", "shm", "journal"]
9390            .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
9391
9392        require_condition(
9393            default_mcp_project_root(&database, None)
9394                .is_err_and(|error| is_ambiguous_predecessor(&error)),
9395            "default MCP discovery admitted an ambiguous predecessor",
9396        )?;
9397        require_condition(
9398            default_cli_project_root(&database, None, false)
9399                .is_err_and(|error| is_ambiguous_predecessor(&error)),
9400            "default CLI discovery admitted an ambiguous predecessor",
9401        )?;
9402        require_condition(
9403            resolved_mcp_config_path(&database, None)
9404                .is_err_and(|error| is_ambiguous_predecessor(&error)),
9405            "MCP config discovery admitted an ambiguous predecessor",
9406        )?;
9407        require_condition(
9408            resolved_mcp_config_path(&database, Some(&replacement_config))
9409                .is_err_and(|error| is_ambiguous_predecessor(&error)),
9410            "explicit MCP config bypassed ambiguous predecessor admission",
9411        )?;
9412        require_condition(
9413            crate::build_harness_mcp_config_report(
9414                crate::HarnessConfig::McpJson,
9415                "ambiguous-predecessor",
9416                &database,
9417                None,
9418                false,
9419            )
9420            .is_err_and(|error| is_ambiguous_predecessor(&error)),
9421            "generated MCP config admitted an ambiguous predecessor",
9422        )?;
9423        require_condition(
9424            build_settings_report(&database, None, OutputFormat::Json)
9425                .is_err_and(|error| is_ambiguous_predecessor(&error)),
9426            "settings discovery admitted an ambiguous predecessor",
9427        )?;
9428        require_condition(
9429            fs::read(&replacement_config)? == replacement_config_before,
9430            "ambiguous predecessor discovery changed the replacement config",
9431        )?;
9432        require_condition(
9433            fs::read(&database)? == database_before
9434                && ["wal", "shm", "journal"]
9435                    .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok())
9436                    == sidecars_before,
9437            "ambiguous predecessor discovery changed database or sidecars",
9438        )?;
9439        Ok(())
9440    }
9441
9442    #[test]
9443    fn synchronized_repository_read_holds_catalog_writer_exclusion() -> Result<(), Box<dyn Error>> {
9444        let temp = tempfile::tempdir()?;
9445        let root = temp.path().join("control");
9446        let database = root.join(".projectatlas/projectatlas.db");
9447        fs::create_dir_all(
9448            database
9449                .parent()
9450                .ok_or_else(|| io::Error::other("control database has no parent"))?,
9451        )?;
9452        let control = AtlasStore::open_for_project(&database, &root)?;
9453        let control_project = control
9454            .project_instance_id()?
9455            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
9456        let writer_blocked = std::cell::Cell::new(false);
9457
9458        let overview = synchronize_registered_worktree_usage_with_catalog_validation(
9459            &database,
9460            &root,
9461            Some(control_project),
9462            |_, _| Ok(()),
9463            || Ok(()),
9464            |reader| {
9465                let contender = rusqlite::Connection::open(&database).map_err(DbError::from)?;
9466                contender
9467                    .busy_timeout(Duration::ZERO)
9468                    .map_err(DbError::from)?;
9469                match contender.execute_batch("BEGIN IMMEDIATE") {
9470                    Err(rusqlite::Error::SqliteFailure(code, _))
9471                        if matches!(
9472                            code.code,
9473                            rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
9474                        ) =>
9475                    {
9476                        writer_blocked.set(true);
9477                    }
9478                    Ok(()) => contender.execute_batch("ROLLBACK").map_err(DbError::from)?,
9479                    Err(error) => return Err(DbError::from(error)),
9480                }
9481                reader.repository_token_overview()
9482            },
9483        )?;
9484        require_eq(
9485            &writer_blocked.get(),
9486            &true,
9487            "repository aggregate read writer exclusion",
9488        )?;
9489        require_eq(&overview.calls, &0, "empty synchronized repository report")
9490    }
9491
9492    #[test]
9493    fn synchronization_rejects_a_registration_committed_after_catalog_capture()
9494    -> Result<(), Box<dyn Error>> {
9495        let temp = tempfile::tempdir()?;
9496        let root = temp.path().join("control");
9497        let database = root.join(".projectatlas/projectatlas.db");
9498        let common = temp.path().join("common.git");
9499        let admin = common.join("worktrees/added");
9500        let target = temp.path().join("added");
9501        for path in [&root, &admin, &target] {
9502            fs::create_dir_all(path)?;
9503        }
9504        fs::create_dir_all(
9505            database
9506                .parent()
9507                .ok_or_else(|| io::Error::other("control database has no parent"))?,
9508        )?;
9509        let control = AtlasStore::open_for_project(&database, &root)?;
9510        let control_project = control
9511            .project_instance_id()?
9512            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
9513        let alias = WorktreeAlias::parse("added")?;
9514
9515        let result = synchronize_registered_worktree_usage_with_catalog_validation(
9516            &database,
9517            &root,
9518            Some(control_project),
9519            |_, _| Ok(()),
9520            || {
9521                control.register_worktree(
9522                    &alias,
9523                    &common,
9524                    &admin,
9525                    &"01".repeat(32),
9526                    &target,
9527                    None,
9528                    1,
9529                )?;
9530                Ok(())
9531            },
9532            |_| Ok(()),
9533        );
9534        require_eq(
9535            &result.as_ref().is_err_and(|error| {
9536                error
9537                    .to_string()
9538                    .contains("catalog changed during aggregate synchronization")
9539            }),
9540            &true,
9541            "concurrent registration catalog rejection",
9542        )?;
9543        require_eq(
9544            &control.worktree_registration(&alias)?.registration_id,
9545            &1,
9546            "concurrently committed registration",
9547        )
9548    }
9549
9550    #[test]
9551    fn unbound_worktree_synchronization_revalidates_git_lifecycle_before_binding()
9552    -> Result<(), Box<dyn Error>> {
9553        let temp = tempfile::tempdir()?;
9554        let primary = temp.path().join("primary");
9555        let linked = temp.path().join("linked");
9556        fs::create_dir(&primary)?;
9557        run_git_fixture(StdCommand::new("git").current_dir(&primary).arg("init"))?;
9558        for (key, value) in [
9559            ("user.name", "ProjectAtlas Test"),
9560            ("user.email", "projectatlas@example.invalid"),
9561            ("commit.gpgsign", "false"),
9562            ("core.autocrlf", "false"),
9563        ] {
9564            run_git_fixture(
9565                StdCommand::new("git")
9566                    .current_dir(&primary)
9567                    .args(["config", key, value]),
9568            )?;
9569        }
9570        fs::write(primary.join("lib.rs"), "pub fn primary() {}\n")?;
9571        run_git_fixture(
9572            StdCommand::new("git")
9573                .current_dir(&primary)
9574                .args(["add", "."]),
9575        )?;
9576        run_git_fixture(
9577            StdCommand::new("git")
9578                .current_dir(&primary)
9579                .args(["commit", "-m", "fixture"]),
9580        )?;
9581        run_git_fixture(
9582            StdCommand::new("git")
9583                .current_dir(&primary)
9584                .args(["worktree", "add", "-b", "captured"])
9585                .arg(&linked),
9586        )?;
9587
9588        let RepositoryStructure::Git(repository) =
9589            projectatlas_fs::worktree::discover_repository_structure(&primary)?
9590        else {
9591            return Err(io::Error::other("Git fixture was not discovered").into());
9592        };
9593        let root = linked.canonicalize()?;
9594        let entry = repository
9595            .worktrees
9596            .iter()
9597            .find(|entry| match &entry.state {
9598                GitWorktreeState::Active {
9599                    root: candidate, ..
9600                } => candidate == &root,
9601                GitWorktreeState::Missing { .. } | GitWorktreeState::Invalid { .. } => false,
9602            })
9603            .ok_or_else(|| io::Error::other("linked fixture was not discovered"))?;
9604        let administrative_directory = entry.administrative_directory.clone();
9605        let administrative_identity = git_administrative_identity(&administrative_directory)?;
9606        let alias = WorktreeAlias::parse("captured")?;
9607        let control_database = primary.join(".projectatlas/projectatlas.db");
9608        fs::create_dir_all(primary.join(".projectatlas"))?;
9609        let control = AtlasStore::open_for_project(&control_database, &primary)?;
9610        let registration = control.register_worktree(
9611            &alias,
9612            &repository.common_directory,
9613            &administrative_directory,
9614            &administrative_identity,
9615            &root,
9616            None,
9617            1,
9618        )?;
9619        require_registered_worktree_lifecycle(&registration, &root)?;
9620
9621        let target_database = root.join(".projectatlas/projectatlas.db");
9622        fs::create_dir_all(root.join(".projectatlas"))?;
9623        let target = AtlasStore::open_for_project(&target_database, &root)?;
9624        target.record_usage(&usage_from_text(
9625            "captured",
9626            "atlas_overview",
9627            None,
9628            None,
9629            "pub fn captured() {}",
9630            "repository overview",
9631        ))?;
9632        let snapshot = target.export_worktree_usage_snapshot()?;
9633        drop(target);
9634
9635        let preserved_target = root.join(".projectatlas-snapshot-race");
9636        let replacement_project = std::cell::Cell::new(None);
9637        let replaced_database = synchronize_registered_worktree_usage_with_catalog_validation(
9638            &control_database,
9639            &primary,
9640            control.project_instance_id()?,
9641            |captured, captured_root| {
9642                require_eq(
9643                    &captured.registration_id,
9644                    &registration.registration_id,
9645                    "snapshot-race registration",
9646                )
9647                .map_err(|error| CliError::InvalidInput(error.to_string()))?;
9648                if captured_root != root.as_path() {
9649                    return Err(CliError::InvalidInput(
9650                        "snapshot-race root changed".to_string(),
9651                    ));
9652                }
9653                fs::rename(root.join(".projectatlas"), &preserved_target)?;
9654                fs::create_dir(root.join(".projectatlas"))?;
9655                let replacement = AtlasStore::open_for_project(&target_database, &root)?;
9656                replacement_project.set(replacement.project_instance_id()?);
9657                Ok(())
9658            },
9659            || Ok(()),
9660            |_| Ok(()),
9661        );
9662        require_eq(
9663            &replaced_database.as_ref().is_err_and(|error| {
9664                error
9665                    .to_string()
9666                    .contains("atlas changed after its usage snapshot was captured")
9667            }),
9668            &true,
9669            "same-lifecycle database replacement rejection",
9670        )?;
9671        require_eq(
9672            &(replacement_project.get().is_some()
9673                && replacement_project.get() != Some(snapshot.project_instance_id())),
9674            &true,
9675            "snapshot-race replacement identity",
9676        )?;
9677        let stored = control.worktree_registration(&alias)?;
9678        require_eq(
9679            &(stored.project_instance_id.is_none()
9680                && stored.accepted_telemetry_revision == 0
9681                && control.registered_worktree_token_overview(&alias)?.calls == 0),
9682            &true,
9683            "database replacement rollback state",
9684        )?;
9685        fs::remove_dir_all(root.join(".projectatlas"))?;
9686        fs::rename(&preserved_target, root.join(".projectatlas"))?;
9687
9688        run_git_fixture(
9689            StdCommand::new("git")
9690                .current_dir(&primary)
9691                .args(["worktree", "remove", "--force"])
9692                .arg(&linked),
9693        )?;
9694        run_git_fixture(
9695            StdCommand::new("git")
9696                .current_dir(&primary)
9697                .args(["worktree", "add", "-b", "replacement"])
9698                .arg(&linked),
9699        )?;
9700        fs::create_dir_all(root.join(".projectatlas"))?;
9701        let replacement = AtlasStore::open_for_project(&target_database, &root)?;
9702        let replacement_project = replacement
9703            .project_instance_id()?
9704            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
9705        require_eq(
9706            &(replacement_project != snapshot.project_instance_id()),
9707            &true,
9708            "replacement worktree atlas identity",
9709        )?;
9710        drop(replacement);
9711
9712        let rejected = require_registered_worktree_lifecycle(&registration, &root);
9713        require_eq(
9714            &rejected.as_ref().is_err_and(|error| {
9715                error
9716                    .to_string()
9717                    .contains("administrative lifecycle changed")
9718            }),
9719            &true,
9720            "replacement Git lifecycle rejection",
9721        )?;
9722        let stored = control.worktree_registration(&alias)?;
9723        require_eq(
9724            &stored.project_instance_id,
9725            &None,
9726            "failed revalidation project binding",
9727        )?;
9728        require_eq(
9729            &stored.accepted_telemetry_revision,
9730            &0,
9731            "failed revalidation telemetry revision",
9732        )?;
9733        require_eq(
9734            &control.registered_worktree_token_overview(&alias)?.calls,
9735            &0,
9736            "failed revalidation token totals",
9737        )?;
9738
9739        control.retire_worktree(registration.registration_id, &alias, 2)?;
9740        let RepositoryStructure::Git(replacement_repository) =
9741            projectatlas_fs::worktree::discover_repository_structure(&primary)?
9742        else {
9743            return Err(io::Error::other("replacement Git fixture was not discovered").into());
9744        };
9745        let replacement_entry = replacement_repository
9746            .worktrees
9747            .iter()
9748            .find(|entry| match &entry.state {
9749                GitWorktreeState::Active {
9750                    root: candidate, ..
9751                } => candidate == &root,
9752                GitWorktreeState::Missing { .. } | GitWorktreeState::Invalid { .. } => false,
9753            })
9754            .ok_or_else(|| io::Error::other("replacement worktree was not discovered"))?;
9755        let replacement_alias = WorktreeAlias::parse("replacement")?;
9756        let replacement_registration = control.register_worktree(
9757            &replacement_alias,
9758            &replacement_repository.common_directory,
9759            &replacement_entry.administrative_directory,
9760            &git_administrative_identity(&replacement_entry.administrative_directory)?,
9761            &root,
9762            None,
9763            3,
9764        )?;
9765        let preserved_target = root.join(".projectatlas-concurrent-binding");
9766        fs::rename(root.join(".projectatlas"), &preserved_target)?;
9767        let concurrent_binding = synchronize_registered_worktree_usage_with_catalog_validation(
9768            &control_database,
9769            &primary,
9770            control.project_instance_id()?,
9771            |_, _| Ok(()),
9772            || {
9773                fs::rename(&preserved_target, root.join(".projectatlas"))?;
9774                control.bind_worktree_project(
9775                    replacement_registration.registration_id,
9776                    &replacement_alias,
9777                    &root,
9778                    replacement_project,
9779                )?;
9780                Ok(())
9781            },
9782            |_| Ok(()),
9783        );
9784        require_eq(
9785            &concurrent_binding.as_ref().is_err_and(|error| {
9786                error
9787                    .to_string()
9788                    .contains("catalog changed during aggregate synchronization")
9789            }),
9790            &true,
9791            "concurrent unbound-to-bound catalog rejection",
9792        )?;
9793        require_eq(
9794            &control
9795                .worktree_registration(&replacement_alias)?
9796                .project_instance_id,
9797            &Some(replacement_project),
9798            "concurrently committed project binding",
9799        )
9800    }
9801
9802    #[test]
9803    fn worker_pools_respect_work_cardinality_and_runtime_ceiling() {
9804        for (work_items, max_workers, expected) in [
9805            (0, 16, 0),
9806            (1, 16, 1),
9807            (8, 16, 8),
9808            (64, 16, 16),
9809            (64, usize::MAX, INDEX_WORKER_SAFE_CEILING),
9810            (8, 0, 1),
9811        ] {
9812            assert_eq!(
9813                worker_count_for_work(work_items, max_workers),
9814                expected,
9815                "work_items={work_items}, max_workers={max_workers}"
9816            );
9817        }
9818    }
9819
9820    #[test]
9821    fn staged_classifications_prefer_registry_then_bounded_text_evidence() {
9822        let file = |path: &str, language: Option<&str>| Node {
9823            path: path.to_string(),
9824            kind: NodeKind::File,
9825            parent_path: None,
9826            extension: None,
9827            language: language.map(str::to_string),
9828            size_bytes: Some(1),
9829            mtime_ns: Some(1),
9830            content_hash: Some("hash".to_string()),
9831        };
9832        let nodes = vec![
9833            file("docs/guide.md", Some("markdown")),
9834            file("notes", None),
9835            file("blob", None),
9836        ];
9837        let rows = vec![
9838            TextIndexRow {
9839                path: "docs/guide.md".to_string(),
9840                text: None,
9841                reason: TextIndexSkipReason::BinaryOrNonUtf8,
9842            },
9843            TextIndexRow {
9844                path: "notes".to_string(),
9845                text: Some(IndexedFileText {
9846                    path: "notes".to_string(),
9847                    content_hash: Some("hash".to_string()),
9848                    byte_count: 1,
9849                    line_count: 1,
9850                    content: "x".to_string(),
9851                }),
9852                reason: TextIndexSkipReason::Indexed,
9853            },
9854            TextIndexRow {
9855                path: "blob".to_string(),
9856                text: None,
9857                reason: TextIndexSkipReason::BinaryOrNonUtf8,
9858            },
9859        ];
9860
9861        assert_eq!(
9862            stage_file_content_classifications(&nodes, &rows),
9863            vec![
9864                FileContentClassification {
9865                    path: "docs/guide.md".to_string(),
9866                    classification:
9867                        projectatlas_core::language::ContentClassification::Documentation,
9868                },
9869                FileContentClassification {
9870                    path: "notes".to_string(),
9871                    classification: projectatlas_core::language::ContentClassification::OtherText,
9872                },
9873                FileContentClassification {
9874                    path: "blob".to_string(),
9875                    classification: projectatlas_core::language::ContentClassification::Opaque,
9876                },
9877            ]
9878        );
9879    }
9880
9881    #[test]
9882    fn classified_navigation_capability_is_closed_and_directional() {
9883        let report = classified_navigation_capabilities();
9884        assert_eq!(
9885            report.classifications,
9886            [
9887                ContentClassification::Source,
9888                ContentClassification::Documentation,
9889                ContentClassification::ConfigurationData,
9890                ContentClassification::OtherText,
9891                ContentClassification::Opaque,
9892            ]
9893        );
9894        assert_eq!(
9895            report.selections,
9896            [
9897                ContentSelection::Source,
9898                ContentSelection::Documentation,
9899                ContentSelection::Both,
9900            ]
9901        );
9902        assert_eq!(report.document_relation, "extended:documents");
9903        assert_eq!(report.inbound_document_view, "documented_by");
9904    }
9905
9906    #[test]
9907    fn settings_publication_identity_rejects_mixed_snapshots() {
9908        let fingerprint = "a".repeat(64);
9909        let diagnostic = DatabasePublicationReport {
9910            state: IndexPublicationState::Complete,
9911            contract_fingerprint: Some(fingerprint.clone()),
9912            contract_fingerprint_state: DatabasePublicationContractState::Valid,
9913            generation: IndexGeneration::new(4),
9914        };
9915        let matching = IndexPublication {
9916            state: IndexPublicationState::Complete,
9917            contract_fingerprint: Some(fingerprint),
9918            generation: IndexGeneration::new(4),
9919        };
9920        assert!(settings_publication_matches(
9921            Some(&diagnostic),
9922            Some(&matching)
9923        ));
9924
9925        let next_generation = IndexPublication {
9926            generation: IndexGeneration::new(5),
9927            ..matching.clone()
9928        };
9929        assert!(!settings_publication_matches(
9930            Some(&diagnostic),
9931            Some(&next_generation)
9932        ));
9933
9934        let invalid = DatabasePublicationReport {
9935            contract_fingerprint: None,
9936            contract_fingerprint_state: DatabasePublicationContractState::Invalid,
9937            ..diagnostic
9938        };
9939        assert!(!settings_publication_matches(
9940            Some(&invalid),
9941            Some(&matching)
9942        ));
9943    }
9944
9945    #[cfg(unix)]
9946    #[test]
9947    fn settings_report_uses_native_db_identity_when_display_is_unavailable()
9948    -> Result<(), Box<dyn Error>> {
9949        use std::ffi::OsString;
9950        use std::os::unix::ffi::OsStringExt;
9951
9952        let temp = tempfile::tempdir()?;
9953        let raw_name = OsString::from_vec(vec![b'r', b'o', b'o', b't', 0x80]);
9954        let root = temp.path().join(&raw_name);
9955        let database = root.join(".projectatlas/projectatlas.db");
9956        fs::create_dir_all(
9957            database
9958                .parent()
9959                .ok_or_else(|| io::Error::other("raw-root database has no parent"))?,
9960        )?;
9961        drop(AtlasStore::open_for_project(&database, &root)?);
9962
9963        let report = build_settings_report(&database, None, OutputFormat::Json)?;
9964        require_eq(
9965            &report.root_detection_source,
9966            &"db".to_string(),
9967            "native database root detection source",
9968        )?;
9969        require_eq(
9970            &report.repo_root,
9971            &None,
9972            "unavailable native database root display",
9973        )?;
9974        let serialized = serde_json::to_string(&report)?;
9975        require_eq(
9976            &serialized.contains('\u{fffd}'),
9977            &false,
9978            "serialized settings fabricated a replacement root",
9979        )?;
9980        Ok(())
9981    }
9982
9983    #[cfg(windows)]
9984    #[test]
9985    fn lossless_native_path_display_preserves_absolute_volume_guid_paths()
9986    -> Result<(), Box<dyn Error>> {
9987        let volume = r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\repo\file";
9988        let volume_display = lossless_native_path_display(Path::new(volume))
9989            .ok_or_else(|| io::Error::other("volume-GUID path was not UTF-8"))?;
9990        require_eq(
9991            &volume_display,
9992            &volume.to_string(),
9993            "lossless volume-GUID display",
9994        )?;
9995        require_eq(
9996            &Path::new(&volume_display).is_absolute(),
9997            &true,
9998            "volume-GUID display remained absolute",
9999        )?;
10000
10001        let drive = lossless_native_path_display(Path::new(r"\\?\C:\repo\file"))
10002            .ok_or_else(|| io::Error::other("extended drive path was not UTF-8"))?;
10003        require_eq(
10004            &drive,
10005            &"C:/repo/file".to_string(),
10006            "normalized extended drive display",
10007        )?;
10008        require_eq(
10009            &Path::new(&drive).is_absolute(),
10010            &true,
10011            "extended drive display remained absolute",
10012        )?;
10013
10014        let unc = lossless_native_path_display(Path::new(r"\\?\UNC\server\share\repo\file"))
10015            .ok_or_else(|| io::Error::other("extended UNC path was not UTF-8"))?;
10016        require_eq(
10017            &unc,
10018            &"//server/share/repo/file".to_string(),
10019            "normalized extended UNC display",
10020        )?;
10021        require_eq(
10022            &Path::new(&unc).is_absolute(),
10023            &true,
10024            "extended UNC display remained absolute",
10025        )?;
10026        Ok(())
10027    }
10028
10029    #[cfg(windows)]
10030    #[test]
10031    fn settings_report_preserves_verbatim_native_root_projection() -> Result<(), Box<dyn Error>> {
10032        let temp = tempfile::tempdir()?;
10033        let base = temp
10034            .path()
10035            .to_str()
10036            .ok_or("temporary directory was not UTF-8")?;
10037        let long_component = "a".repeat(220);
10038        let root = PathBuf::from(format!(r"\\?\{base}\{long_component}"));
10039        fs::create_dir(&root)?;
10040        let database = root.join(".projectatlas/projectatlas.db");
10041        fs::create_dir_all(
10042            database
10043                .parent()
10044                .ok_or("verbatim settings database has no parent")?,
10045        )?;
10046        drop(AtlasStore::open_for_project(&database, &root)?);
10047
10048        let config = temp.path().join("verbatim-settings-config.toml");
10049        fs::write(
10050            &config,
10051            format!(
10052                "[project]\nroot = {}\n",
10053                serde_json::to_string(
10054                    &root
10055                        .to_str()
10056                        .ok_or("verbatim root was not UTF-8")?
10057                        .to_owned()
10058                )?
10059            ),
10060        )?;
10061        let expected = CanonicalProjectRoot::from_path(&root)?.display_string()?;
10062        if !expected.starts_with(r"\\?\") {
10063            return Err("verbatim root lost its extended prefix".into());
10064        }
10065
10066        let report = build_settings_report(&database, Some(&config), OutputFormat::Json)?;
10067        require_eq(
10068            &report.repo_root,
10069            &Some(expected.clone()),
10070            "JSON settings verbatim root",
10071        )?;
10072        let json = serde_json::to_value(&report)?;
10073        require_eq(
10074            &json.get("repo_root"),
10075            &Some(&Value::String(expected.clone())),
10076            "JSON settings verbatim root field",
10077        )?;
10078        let toon = crate::render_settings_report(&report);
10079        let toon_value: Value = toon_format::decode_default(&toon)?;
10080        require_eq(
10081            &toon_value.pointer("/settings/repo_root"),
10082            &Some(&Value::String(expected)),
10083            "TOON settings verbatim root field",
10084        )?;
10085        Ok(())
10086    }
10087
10088    #[cfg(windows)]
10089    #[test]
10090    fn settings_report_accepts_case_only_root_rename() -> Result<(), Box<dyn Error>> {
10091        let temp = tempfile::tempdir()?;
10092        let original = temp.path().join("CaseOnlyRoot");
10093        let staging = temp.path().join("CaseOnlyRootStaging");
10094        let renamed = temp.path().join("caseonlyroot");
10095        fs::create_dir(&original)?;
10096        let database = original.join(".projectatlas/projectatlas.db");
10097        fs::create_dir_all(
10098            database
10099                .parent()
10100                .ok_or_else(|| io::Error::other("case-only database has no parent"))?,
10101        )?;
10102        drop(AtlasStore::open_for_project(&database, &original)?);
10103
10104        fs::rename(&original, &staging)?;
10105        fs::rename(&staging, &renamed)?;
10106        let renamed_database = renamed.join(".projectatlas/projectatlas.db");
10107        let config = temp.path().join("case-only-config.toml");
10108        fs::write(
10109            &config,
10110            format!(
10111                "[project]\nroot = {}\n",
10112                serde_json::to_string(&renamed.to_string_lossy())?
10113            ),
10114        )?;
10115
10116        let report = build_settings_report(&renamed_database, Some(&config), OutputFormat::Json)?;
10117        require_eq(
10118            &report.root_verified,
10119            &true,
10120            "settings accepted case-only root rename",
10121        )?;
10122        require_eq(
10123            &report.root_mismatches.is_empty(),
10124            &true,
10125            "case-only root rename mismatch diagnostics",
10126        )?;
10127        let json = serde_json::to_value(&report)?;
10128        require_eq(
10129            &json.get("root_verified"),
10130            &Some(&Value::Bool(true)),
10131            "JSON settings root verification",
10132        )?;
10133        let toon = crate::render_settings_report(&report);
10134        let toon_value: Value = toon_format::decode_default(&toon)?;
10135        require_eq(
10136            &toon_value.pointer("/settings/root_verified"),
10137            &Some(&Value::Bool(true)),
10138            "TOON settings root verification",
10139        )?;
10140        Ok(())
10141    }
10142
10143    #[cfg(windows)]
10144    #[test]
10145    fn settings_report_rejects_case_sensitive_sibling() -> Result<(), Box<dyn Error>> {
10146        let temp = tempfile::tempdir()?;
10147        let parent = temp.path().join("case-sensitive-parent");
10148        fs::create_dir(&parent)?;
10149        let enabled = StdCommand::new("fsutil")
10150            .args(["file", "SetCaseSensitiveInfo"])
10151            .arg(&parent)
10152            .arg("enable")
10153            .status()
10154            .is_ok_and(|status| status.success());
10155        if !enabled {
10156            return Ok(());
10157        }
10158
10159        let stored_root = parent.join("Repo");
10160        let selected_root = parent.join("repo");
10161        fs::create_dir(&stored_root)?;
10162        fs::create_dir(&selected_root)?;
10163        let database = stored_root.join(".projectatlas/projectatlas.db");
10164        fs::create_dir_all(
10165            database
10166                .parent()
10167                .ok_or_else(|| io::Error::other("case-sensitive database has no parent"))?,
10168        )?;
10169        drop(AtlasStore::open_for_project(&database, &stored_root)?);
10170        let config = temp.path().join("case-sensitive-config.toml");
10171        fs::write(
10172            &config,
10173            format!(
10174                "[project]\nroot = {}\n",
10175                serde_json::to_string(&selected_root.to_string_lossy())?
10176            ),
10177        )?;
10178
10179        let report = build_settings_report(&database, Some(&config), OutputFormat::Json)?;
10180        require_eq(
10181            &report.root_verified,
10182            &false,
10183            "settings rejected case-sensitive sibling",
10184        )?;
10185        require_eq(
10186            &report.root_mismatches.is_empty(),
10187            &false,
10188            "case-sensitive sibling mismatch diagnostics",
10189        )?;
10190        let json = serde_json::to_value(&report)?;
10191        require_eq(
10192            &json.get("root_verified"),
10193            &Some(&Value::Bool(false)),
10194            "JSON case-sensitive sibling verification",
10195        )?;
10196        let toon = crate::render_settings_report(&report);
10197        let toon_value: Value = toon_format::decode_default(&toon)?;
10198        require_eq(
10199            &toon_value.pointer("/settings/root_verified"),
10200            &Some(&Value::Bool(false)),
10201            "TOON case-sensitive sibling verification",
10202        )?;
10203        Ok(())
10204    }
10205
10206    #[cfg(windows)]
10207    #[test]
10208    fn freshness_and_symbol_build_revalidate_case_only_root_without_sibling_mutation()
10209    -> Result<(), Box<dyn Error>> {
10210        let temp = tempfile::tempdir()?;
10211        let original = temp.path().join("CaseOnlyRuntimeRoot");
10212        let staging = temp.path().join("CaseOnlyRuntimeRootStaging");
10213        let renamed = temp.path().join("caseonlyruntimeroot");
10214        fs::create_dir(&original)?;
10215        fs::write(original.join("lib.rs"), "pub fn runtime_root() {}\n")?;
10216        let database = original.join(".projectatlas/projectatlas.db");
10217        fs::create_dir_all(
10218            database
10219                .parent()
10220                .ok_or_else(|| io::Error::other("case-only runtime database has no parent"))?,
10221        )?;
10222        let original_plan = ScanRuntimePlan::for_path(None, &original, None)?;
10223        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
10224        let mut original_store = open_atlas_store_for_project(&database, &original)?;
10225        run_scan_pipeline(&mut original_store, &original_plan, &symbol_options)?;
10226        let initial_project = original_store
10227            .project_instance_id()?
10228            .ok_or_else(|| io::Error::other("case-only runtime project identity is missing"))?;
10229        drop(original_store);
10230
10231        fs::rename(&original, &staging)?;
10232        fs::rename(&staging, &renamed)?;
10233        let renamed_database = renamed.join(".projectatlas/projectatlas.db");
10234        let fresh = open_fresh_atlas_store_for_project(&renamed_database, &renamed, None)?;
10235        require_eq(
10236            &fresh.project_instance_id()?,
10237            &Some(initial_project),
10238            "fresh read after case-only root rename",
10239        )?;
10240        drop(fresh);
10241
10242        let renamed_plan = ScanRuntimePlan::for_path(None, &renamed, None)?;
10243        let mut renamed_store = open_atlas_store_for_project(&renamed_database, &renamed)?;
10244        run_symbol_build_pipeline(&mut renamed_store, &renamed_plan, &symbol_options, None)?;
10245        require_eq(
10246            &renamed_store.project_instance_id()?,
10247            &Some(initial_project),
10248            "symbol build after case-only root rename",
10249        )?;
10250        drop(renamed_store);
10251
10252        let case_sensitive_parent = temp.path().join("runtime-case-sensitive-parent");
10253        fs::create_dir(&case_sensitive_parent)?;
10254        let enabled = StdCommand::new("fsutil")
10255            .args(["file", "SetCaseSensitiveInfo"])
10256            .arg(&case_sensitive_parent)
10257            .arg("enable")
10258            .status()
10259            .is_ok_and(|status| status.success());
10260        if !enabled {
10261            return Ok(());
10262        }
10263        let stored_root = case_sensitive_parent.join("Repo");
10264        let selected_root = case_sensitive_parent.join("repo");
10265        fs::create_dir(&stored_root)?;
10266        fs::create_dir(&selected_root)?;
10267        fs::write(stored_root.join("lib.rs"), "pub fn stored_root() {}\n")?;
10268        let sibling_database = stored_root.join(".projectatlas/projectatlas.db");
10269        fs::create_dir_all(
10270            sibling_database
10271                .parent()
10272                .ok_or_else(|| io::Error::other("case-sensitive runtime database has no parent"))?,
10273        )?;
10274        let sibling_plan = ScanRuntimePlan::for_path(None, &stored_root, None)?;
10275        let mut sibling_store = open_atlas_store_for_project(&sibling_database, &stored_root)?;
10276        run_scan_pipeline(&mut sibling_store, &sibling_plan, &symbol_options)?;
10277        drop(sibling_store);
10278
10279        let sidecar_bytes = |path: &Path| -> Result<Vec<Option<Vec<u8>>>, Box<dyn Error>> {
10280            [
10281                path.to_path_buf(),
10282                db_sidecar_path(path, "wal"),
10283                db_sidecar_path(path, "shm"),
10284                db_sidecar_path(path, "journal"),
10285            ]
10286            .into_iter()
10287            .map(|candidate| match fs::read(candidate) {
10288                Ok(bytes) => Ok(Some(bytes)),
10289                Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
10290                Err(error) => Err(error.into()),
10291            })
10292            .collect()
10293        };
10294        let durable_state = |path: &Path| -> Result<
10295            (
10296                Option<CanonicalProjectRoot>,
10297                Option<ProjectInstanceId>,
10298                Option<IndexPublication>,
10299            ),
10300            Box<dyn Error>,
10301        > {
10302            let store = AtlasStore::open_read_only(path)?;
10303            Ok((
10304                store.project_root_identity()?,
10305                store.project_instance_id()?,
10306                store.index_publication()?,
10307            ))
10308        };
10309
10310        drop(AtlasStore::open_read_only(&sibling_database)?);
10311        let before_sidecars = sidecar_bytes(&sibling_database)?;
10312        let before_state = durable_state(&sibling_database)?;
10313        let Err(CliError::ProjectMismatch(_)) =
10314            open_fresh_atlas_store_for_project(&sibling_database, &selected_root, None)
10315        else {
10316            return Err(io::Error::other("case-sensitive sibling was admitted").into());
10317        };
10318        let after_sidecars = sidecar_bytes(&sibling_database)?;
10319        let after_state = durable_state(&sibling_database)?;
10320        require_eq(
10321            &after_sidecars,
10322            &before_sidecars,
10323            "case-sensitive sibling sidecar state",
10324        )?;
10325        require_eq(
10326            &after_state,
10327            &before_state,
10328            "case-sensitive sibling durable state",
10329        )?;
10330        Ok(())
10331    }
10332
10333    #[test]
10334    fn supplied_language_controls_symbol_candidate_owner() {
10335        for path in ["Cargo.toml", "src/App.vue", "scripts/Get-Atlas.ps1"] {
10336            assert!(!is_symbol_candidate(path, Some("toon")), "{path}");
10337        }
10338        assert!(is_symbol_candidate("data/report.toon", Some("rust")));
10339        assert!(is_symbol_candidate(
10340            "data/report.toon",
10341            Some("cargo-manifest")
10342        ));
10343        assert!(is_symbol_candidate("data/report.toon", Some("vue")));
10344        assert!(is_symbol_candidate("data/report.toon", Some("powershell")));
10345        assert!(!is_symbol_candidate("data/report.toon", Some("toon")));
10346    }
10347
10348    #[test]
10349    fn optional_catalog_symbol_work_requires_effective_admission() {
10350        assert!(is_symbol_candidate("scripts/report.awk", Some("awk")));
10351        assert!(!is_symbol_candidate_for_admission(
10352            "scripts/report.awk",
10353            Some("awk"),
10354            false,
10355        ));
10356        assert!(is_symbol_candidate_for_admission(
10357            "scripts/report.awk",
10358            Some("awk"),
10359            true,
10360        ));
10361        assert!(is_symbol_candidate_for_admission(
10362            "src/lib.rs",
10363            Some("rust"),
10364            false,
10365        ));
10366    }
10367
10368    #[test]
10369    fn missing_language_preserves_specialized_symbol_candidate_inference() {
10370        for path in [
10371            "Cargo.toml",
10372            "Cargo.lock",
10373            "src/App.vue",
10374            "src/App.VUE",
10375            "scripts/Get-Atlas.ps1",
10376            "scripts/Atlas.psm1",
10377            "scripts/Atlas.psd1",
10378        ] {
10379            assert!(is_symbol_candidate(path, None), "{path}");
10380        }
10381        assert!(!is_symbol_candidate("data/report.toon", None));
10382    }
10383
10384    #[test]
10385    fn rejected_database_location_does_not_create_or_change_database() -> Result<(), Box<dyn Error>>
10386    {
10387        let temp = tempfile::tempdir()?;
10388        let root = temp.path().join("repository");
10389        fs::create_dir_all(&root)?;
10390
10391        for uncertain in [false, true] {
10392            let database_parent = temp
10393                .path()
10394                .join(if uncertain {
10395                    "uncertain"
10396                } else {
10397                    "unsupported"
10398                })
10399                .join("nested");
10400            let database = database_parent.join("projectatlas.db");
10401            let rejected_path = database.clone();
10402            let result = open_atlas_store_for_project_with_location_validator(
10403                &database,
10404                &root,
10405                move |_path| {
10406                    if uncertain {
10407                        Err(projectatlas_db::DbError::DatabaseFilesystemUncertain {
10408                            path: rejected_path,
10409                            mount_point: None,
10410                            filesystem_type: None,
10411                            reason: "injected filesystem uncertainty".to_string(),
10412                        })
10413                    } else {
10414                        Err(projectatlas_db::DbError::DatabaseFilesystemUnsupported {
10415                            path: rejected_path,
10416                            mount_point: None,
10417                            filesystem_type: Some("nfs".to_string()),
10418                        })
10419                    }
10420                },
10421            );
10422            let rejected = matches!(
10423                result,
10424                Err(CliError::Db(
10425                    projectatlas_db::DbError::DatabaseFilesystemUnsupported { .. }
10426                        | projectatlas_db::DbError::DatabaseFilesystemUncertain { .. }
10427                ))
10428            );
10429            require_eq(&rejected, &true, "typed location rejection")?;
10430            require_eq(
10431                &database_parent.exists(),
10432                &false,
10433                "rejected database parent absence",
10434            )?;
10435            require_eq(&database.exists(), &false, "rejected database absence")?;
10436
10437            let existing_database = temp.path().join(if uncertain {
10438                "uncertain-existing.db"
10439            } else {
10440                "unsupported-existing.db"
10441            });
10442            let original_bytes = b"existing database bytes stay untouched";
10443            fs::write(&existing_database, original_bytes)?;
10444            let rejected_path = existing_database.clone();
10445            let existing_result = open_atlas_store_for_project_with_location_validator(
10446                &existing_database,
10447                &root,
10448                move |_path| {
10449                    if uncertain {
10450                        Err(projectatlas_db::DbError::DatabaseFilesystemUncertain {
10451                            path: rejected_path,
10452                            mount_point: None,
10453                            filesystem_type: None,
10454                            reason: "injected filesystem uncertainty".to_string(),
10455                        })
10456                    } else {
10457                        Err(projectatlas_db::DbError::DatabaseFilesystemUnsupported {
10458                            path: rejected_path,
10459                            mount_point: None,
10460                            filesystem_type: Some("nfs".to_string()),
10461                        })
10462                    }
10463                },
10464            );
10465            require_eq(
10466                &matches!(
10467                    existing_result,
10468                    Err(CliError::Db(
10469                        projectatlas_db::DbError::DatabaseFilesystemUnsupported { .. }
10470                            | projectatlas_db::DbError::DatabaseFilesystemUncertain { .. }
10471                    ))
10472                ),
10473                &true,
10474                "typed existing location rejection",
10475            )?;
10476            require_eq(
10477                &fs::read(&existing_database)?,
10478                &original_bytes.to_vec(),
10479                "rejected existing database bytes",
10480            )?;
10481        }
10482        Ok(())
10483    }
10484
10485    #[test]
10486    fn operation_deadline_starts_with_default_or_shorter_explicit_timeout() {
10487        for (timeout_seconds, expected) in [
10488            (None, DEFAULT_INDEX_WORK_TIMEOUT),
10489            (Some(1), Duration::from_secs(1)),
10490            (
10491                Some(DEFAULT_INDEX_WORK_TIMEOUT.as_secs() + 1),
10492                DEFAULT_INDEX_WORK_TIMEOUT,
10493            ),
10494        ] {
10495            let options = SymbolBuildOptions::new(1_024, Some(1), timeout_seconds);
10496            let control = index_work_control(&options);
10497            assert_eq!(
10498                control
10499                    .deadline()
10500                    .map(|deadline| deadline.duration_since(control.started_at())),
10501                Some(expected)
10502            );
10503        }
10504    }
10505
10506    #[test]
10507    fn normal_read_refresh_delta_enforces_path_and_byte_budgets() -> Result<(), Box<dyn Error>> {
10508        let temp = tempfile::tempdir()?;
10509        let node = |path: String, size_bytes: u64| Node {
10510            path,
10511            kind: NodeKind::File,
10512            parent_path: None,
10513            extension: Some(".rs".to_string()),
10514            language: Some("rust".to_string()),
10515            size_bytes: Some(size_bytes),
10516            mtime_ns: Some(1),
10517            content_hash: Some("current-content".to_string()),
10518        };
10519        let path_bounded_nodes = (0..=NORMAL_READ_REFRESH_MAX_PATHS)
10520            .map(|index| node(format!("src/file_{index}.rs"), 1))
10521            .collect::<Vec<_>>();
10522        let path_delta = source_node_delta(temp.path(), &path_bounded_nodes, &[])
10523            .ok_or_else(|| io::Error::other("path-bounded freshness delta was missing"))?;
10524        require_eq(
10525            &path_delta.report.scope,
10526            &IndexRefreshScope::Full,
10527            "path-bounded refresh scope",
10528        )?;
10529        require_eq(
10530            &path_delta.report.changed,
10531            &(NORMAL_READ_REFRESH_MAX_PATHS + 1),
10532            "path-bounded change count",
10533        )?;
10534        require_eq(
10535            &path_delta.report.sample_paths.len(),
10536            &INDEX_FRESHNESS_SAMPLE_LIMIT,
10537            "bounded freshness sample",
10538        )?;
10539
10540        let byte_bounded_nodes = vec![node(
10541            "src/large.rs".to_string(),
10542            NORMAL_READ_REFRESH_MAX_BYTES + 1,
10543        )];
10544        let byte_delta = source_node_delta(temp.path(), &byte_bounded_nodes, &[])
10545            .ok_or_else(|| io::Error::other("byte-bounded freshness delta was missing"))?;
10546        require_eq(
10547            &byte_delta.report.scope,
10548            &IndexRefreshScope::Full,
10549            "byte-bounded refresh scope",
10550        )?;
10551        Ok(())
10552    }
10553
10554    #[test]
10555    fn controlled_freshness_plan_uses_the_callers_cancellation() -> Result<(), Box<dyn Error>> {
10556        let temp = tempfile::tempdir()?;
10557        let root = temp.path().join("repo");
10558        fs::create_dir(&root)?;
10559        fs::write(root.join("lib.rs"), "pub fn indexed() {}\n")?;
10560        let db_path = root.join(".projectatlas").join("projectatlas.db");
10561        let plan = ScanRuntimePlan::for_path(None, &root, None)?;
10562        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
10563        let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
10564        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
10565        drop(store);
10566
10567        let invalid_config = root.join("invalid-config.toml");
10568        fs::write(&invalid_config, "[invalid")?;
10569        let control = IndexWorkControl::new(IndexCancellation::new(), None);
10570        control.cancel();
10571        let result = open_fresh_atlas_store_for_project_controlled(
10572            &db_path,
10573            &root,
10574            Some(&invalid_config),
10575            &control,
10576        );
10577        if !matches!(
10578            result,
10579            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
10580                stage: IndexWorkStage::Publication,
10581            }))
10582        ) {
10583            return Err(io::Error::other(
10584                "controlled freshness parsed policy outside the caller cancellation boundary",
10585            )
10586            .into());
10587        }
10588        Ok(())
10589    }
10590
10591    #[test]
10592    fn automatic_read_refresh_returns_typed_state_when_writer_is_unavailable()
10593    -> Result<(), Box<dyn Error>> {
10594        let temp = tempfile::tempdir()?;
10595        let root = temp.path().join("repo");
10596        fs::create_dir(&root)?;
10597        let source_path = root.join("lib.rs");
10598        let indexed_source = "pub fn indexed() {}\n";
10599        let current_source = "pub fn current() {}\n";
10600        fs::write(&source_path, indexed_source)?;
10601        let db_path = root.join(".projectatlas").join("projectatlas.db");
10602        let plan = ScanRuntimePlan::for_path(None, &root, None)?;
10603        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
10604        let mut initial_store = open_atlas_store_for_project(&db_path, &plan.root)?;
10605        run_scan_pipeline(&mut initial_store, &plan, &symbol_options)?;
10606        let initial_generation = initial_store
10607            .index_publication()?
10608            .ok_or_else(|| io::Error::other("initial publication missing"))?
10609            .generation;
10610        drop(initial_store);
10611
10612        fs::write(&source_path, current_source)?;
10613        let mut blocking_writer = open_atlas_store_for_project(&db_path, &plan.root)?;
10614        let publication =
10615            blocking_writer.begin_index_publication(&plan.publication_contract_fingerprint())?;
10616        let refresh = open_fresh_atlas_store_for_project(&db_path, &plan.root, None);
10617        let Err(CliError::RefreshRequired(report)) = refresh else {
10618            return Err(io::Error::other(
10619                "contended automatic refresh did not return typed refresh_required",
10620            )
10621            .into());
10622        };
10623        require_eq(
10624            &report.scope,
10625            &IndexRefreshScope::Incremental,
10626            "contended automatic refresh scope",
10627        )?;
10628        require_eq(
10629            &report.reason,
10630            &IndexRefreshReason::SourceChanged,
10631            "contended automatic refresh reason",
10632        )?;
10633
10634        let last_valid = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
10635        require_eq(
10636            &last_valid
10637                .index_publication()?
10638                .ok_or_else(|| io::Error::other("last-valid publication missing"))?
10639                .generation,
10640            &initial_generation,
10641            "last-valid generation during contention",
10642        )?;
10643        require_eq(
10644            &last_valid
10645                .load_file_text("lib.rs")?
10646                .ok_or_else(|| io::Error::other("last-valid source text missing"))?
10647                .content,
10648            &indexed_source.to_string(),
10649            "last-valid source during contention",
10650        )?;
10651        drop(last_valid);
10652        drop(publication);
10653
10654        let repaired = open_fresh_atlas_store_for_project(&db_path, &plan.root, None)?;
10655        require_eq(
10656            &repaired
10657                .index_publication()?
10658                .ok_or_else(|| io::Error::other("repaired publication missing"))?
10659                .generation,
10660            &initial_generation
10661                .checked_next()
10662                .ok_or_else(|| io::Error::other("test generation overflow"))?,
10663            "repaired generation after contention",
10664        )?;
10665        require_eq(
10666            &repaired
10667                .load_file_text("lib.rs")?
10668                .ok_or_else(|| io::Error::other("repaired source text missing"))?
10669                .content,
10670            &current_source.to_string(),
10671            "current source after contention",
10672        )?;
10673        Ok(())
10674    }
10675
10676    #[test]
10677    fn fallback_retains_actual_source_provenance() -> Result<(), Box<dyn Error>> {
10678        for (language, content, parser) in [
10679            ("rust", "fn recovered() {}", ParserKind::TreeSitter),
10680            ("rust", "def recovered(): pass", ParserKind::Fallback),
10681            (
10682                "php",
10683                "<?php function recovered() {}",
10684                ParserKind::TreeSitter,
10685            ),
10686            ("php", "<?php\ndef recovered(): pass", ParserKind::Fallback),
10687        ] {
10688            let job = SymbolParseJob {
10689                path: format!("src/recovered.{language}"),
10690                native_path: PathBuf::new(),
10691                expected_content_hash: String::new(),
10692                language: Some(language.to_string()),
10693                fallback_summary: None,
10694                purpose_needs_suggestion: false,
10695            };
10696            let SymbolParseOutcome::Parsed(parsed) = parse_admitted_symbol_job(
10697                &job,
10698                content,
10699                None,
10700                &SymbolBuildOptions::new(1_024, Some(1), None),
10701                &standalone_index_work_control(),
10702            ) else {
10703                return Err(io::Error::other("source provenance fixture did not parse").into());
10704            };
10705            require_eq(&parsed.graph.parser, &parser, "observed fact parser")?;
10706            require_eq(&parsed.source_parser, &parser, "observed source parser")?;
10707            require_eq(
10708                &parsed
10709                    .graph
10710                    .symbols
10711                    .iter()
10712                    .any(|symbol| symbol.name == "recovered"),
10713                &true,
10714                "recovered declaration",
10715            )?;
10716        }
10717        Ok(())
10718    }
10719
10720    #[test]
10721    fn partial_php_facts_keep_tree_sitter_source_provenance() -> Result<(), Box<dyn Error>> {
10722        let content = "<?php function run(): void { $callable(); helper(); }";
10723        let job = SymbolParseJob {
10724            path: "src/dynamic.php".to_string(),
10725            native_path: PathBuf::new(),
10726            expected_content_hash: String::new(),
10727            language: Some("php".to_string()),
10728            fallback_summary: None,
10729            purpose_needs_suggestion: false,
10730        };
10731        let SymbolParseOutcome::Parsed(parsed) = parse_admitted_symbol_job(
10732            &job,
10733            content,
10734            None,
10735            &SymbolBuildOptions::new(1_024, Some(1), None),
10736            &standalone_index_work_control(),
10737        ) else {
10738            return Err(io::Error::other("partial PHP fixture did not parse").into());
10739        };
10740        require_eq(
10741            &parsed.graph.parser,
10742            &ParserKind::Fallback,
10743            "partial PHP fact parser",
10744        )?;
10745        require_eq(
10746            &parsed.source_parser,
10747            &ParserKind::TreeSitter,
10748            "partial PHP source parser",
10749        )?;
10750        require_eq(
10751            &parsed
10752                .graph
10753                .symbols
10754                .iter()
10755                .any(|symbol| symbol.name == "run"),
10756            &true,
10757            "partial PHP recovered symbol",
10758        )?;
10759        require_eq(
10760            &parsed.graph.relations.iter().any(|relation| {
10761                relation.kind == RelationKind::Calls && relation.target_name == "helper"
10762            }),
10763            &true,
10764            "partial PHP known call",
10765        )?;
10766        require_eq(
10767            &parsed.graph.relations.iter().all(|relation| {
10768                relation.kind != RelationKind::Calls || relation.target_name != "$callable"
10769            }),
10770            &true,
10771            "partial PHP dynamic call abstention",
10772        )?;
10773        Ok(())
10774    }
10775
10776    #[test]
10777    fn derived_source_readers_reject_bytes_outside_staged_hash() -> Result<(), Box<dyn Error>> {
10778        let temp = tempfile::tempdir()?;
10779        let path = temp.path().join("lib.rs");
10780        let staged_source = "fn first() {}\n";
10781        let changed_source = "fn other() {}\n";
10782        fs::write(&path, staged_source)?;
10783        let expected_content_hash = blake3::hash(staged_source.as_bytes()).to_hex().to_string();
10784        let node = Node {
10785            path: "lib.rs".to_string(),
10786            kind: NodeKind::File,
10787            parent_path: None,
10788            extension: Some(".rs".to_string()),
10789            language: Some("rust".to_string()),
10790            size_bytes: Some(staged_source.len() as u64),
10791            mtime_ns: Some(1),
10792            content_hash: Some(expected_content_hash.clone()),
10793        };
10794        fs::write(&path, changed_source)?;
10795
10796        let text_result = indexed_file_texts_for_nodes(
10797            temp.path(),
10798            std::slice::from_ref(&node),
10799            TextIndexOptions::new(1_024),
10800        );
10801        let Err(CliError::RefreshRequired(details)) = text_result else {
10802            return Err(io::Error::other("text derivation accepted changed source bytes").into());
10803        };
10804        require_eq(
10805            &details.reason,
10806            &IndexRefreshReason::SourceChanged,
10807            "text source-change reason",
10808        )?;
10809        require_eq(
10810            &details.sample_paths,
10811            &vec!["lib.rs".to_string()],
10812            "text source-change paths",
10813        )?;
10814
10815        let symbol_outcome = parse_symbol_job(
10816            &SymbolParseJob {
10817                path: node.path,
10818                native_path: path,
10819                expected_content_hash,
10820                language: node.language,
10821                fallback_summary: None,
10822                purpose_needs_suggestion: false,
10823            },
10824            &SymbolBuildOptions::new(1_024, Some(1), None),
10825            Instant::now(),
10826        );
10827        if !matches!(
10828            symbol_outcome,
10829            SymbolParseOutcome::SourceChanged { path } if path == "lib.rs"
10830        ) {
10831            return Err(io::Error::other("symbol derivation accepted changed source bytes").into());
10832        }
10833
10834        let bounded_path = temp.path().join("bounded.txt");
10835        fs::write(&bounded_path, "four")?;
10836        let bounded_node = Node {
10837            path: "bounded.txt".to_string(),
10838            kind: NodeKind::File,
10839            parent_path: None,
10840            extension: Some(".txt".to_string()),
10841            language: Some("text".to_string()),
10842            size_bytes: Some(4),
10843            mtime_ns: Some(1),
10844            content_hash: Some(blake3::hash(b"four").to_hex().to_string()),
10845        };
10846        let bounded_control = standalone_index_work_control();
10847        let bounded_result = indexed_file_texts_for_nodes_with_limit(
10848            temp.path(),
10849            &[bounded_node],
10850            TextIndexOptions::new(1_024),
10851            3,
10852            &bounded_control,
10853        );
10854        if !matches!(
10855            bounded_result,
10856            Err(CliError::IndexWork(
10857                IndexWorkFailure::ResourceLimitExceeded {
10858                    stage: IndexWorkStage::TextIndex,
10859                    resource: IndexWorkResource::TextBytes,
10860                    limit: 3,
10861                    observed: 4,
10862                }
10863            ))
10864        ) {
10865            return Err(io::Error::other("text staging accepted bytes beyond its limit").into());
10866        }
10867
10868        let bounded_symbol = parse_symbol_job(
10869            &SymbolParseJob {
10870                path: "bounded.txt".to_string(),
10871                native_path: bounded_path,
10872                expected_content_hash: blake3::hash(b"four").to_hex().to_string(),
10873                language: Some("text".to_string()),
10874                fallback_summary: None,
10875                purpose_needs_suggestion: false,
10876            },
10877            &SymbolBuildOptions::new(3, Some(1), None),
10878            Instant::now(),
10879        );
10880        if !matches!(
10881            bounded_symbol,
10882            SymbolParseOutcome::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
10883                stage: IndexWorkStage::SymbolParsing,
10884                resource: IndexWorkResource::SourceBytes,
10885                limit: 3,
10886                observed: 4,
10887            })
10888        ) {
10889            return Err(io::Error::other("symbol read accepted bytes beyond its limit").into());
10890        }
10891        Ok(())
10892    }
10893
10894    #[test]
10895    fn parser_workers_reuse_structural_summaries_without_touching_approved_purpose()
10896    -> Result<(), Box<dyn Error>> {
10897        let temp = tempfile::tempdir()?;
10898        let path = temp.path().join("package.json");
10899        let content =
10900            r#"{"name":"demo","scripts":{"test":"vitest"},"dependencies":{"react":"1.0.0"}}"#;
10901        fs::write(&path, content)?;
10902        let node = Node {
10903            path: "package.json".to_string(),
10904            kind: NodeKind::File,
10905            parent_path: None,
10906            extension: Some(".json".to_string()),
10907            language: Some("json".to_string()),
10908            size_bytes: Some(content.len() as u64),
10909            mtime_ns: Some(1),
10910            content_hash: Some(blake3::hash(content.as_bytes()).to_hex().to_string()),
10911        };
10912        let mut store = AtlasStore::in_memory()?;
10913        store.replace_scan(std::slice::from_ref(&node))?;
10914        store.set_purpose(
10915            "package.json",
10916            "Own the JavaScript package manifest.",
10917            PurposeSource::Agent,
10918        )?;
10919        let text = refresh_text_index_for_nodes_with_rows(
10920            &mut store,
10921            temp.path(),
10922            std::slice::from_ref(&node),
10923            TextIndexOptions::new(1_024),
10924        )?;
10925        let SymbolParseOutcome::Parsed(parsed) = parse_symbol_job(
10926            &SymbolParseJob {
10927                path: node.path.clone(),
10928                native_path: path,
10929                expected_content_hash: node
10930                    .content_hash
10931                    .clone()
10932                    .ok_or_else(|| io::Error::other("fixture hash missing"))?,
10933                language: node.language.clone(),
10934                fallback_summary: None,
10935                purpose_needs_suggestion: false,
10936            },
10937            &SymbolBuildOptions::new(1_024, Some(1), None),
10938            Instant::now(),
10939        ) else {
10940            return Err(io::Error::other("package manifest did not parse").into());
10941        };
10942        require_eq(
10943            &parsed.summary_is_structural,
10944            &true,
10945            "parser-owned structural summary",
10946        )?;
10947        require_eq(
10948            &parsed.summary.as_str(),
10949            &"package manifest for demo with scripts test and 1 dependencies.",
10950            "structural content summary",
10951        )?;
10952        require_eq(
10953            &parsed.purpose_suggestion.is_none(),
10954            &true,
10955            "approved-purpose suggestion suppression",
10956        )?;
10957        let retained_bytes = symbol_parse_output_bytes(&parsed);
10958        let mut symbols = SymbolBuildStage {
10959            report: empty_symbol_build_report(),
10960            changes: vec![SymbolProjectionChange::Parsed(parsed)],
10961            retained_bytes,
10962            identity_admission: graph_projection::GraphIdentityAdmission::default(),
10963        };
10964        let protected_purpose_paths = HashSet::from(["package.json".to_string()]);
10965        let control = standalone_index_work_control();
10966        let structural = stage_structural_summaries_for_nodes_controlled(
10967            &store,
10968            std::slice::from_ref(&node),
10969            &text.rows,
10970            Some(&symbols),
10971            &protected_purpose_paths,
10972            1,
10973            &control,
10974        )?;
10975        require_eq(
10976            &structural.report.summarized,
10977            &1,
10978            "structural summary report",
10979        )?;
10980        require_eq(
10981            &structural.report.purpose_suggestions,
10982            &0,
10983            "structural purpose-suggestion report",
10984        )?;
10985        require_eq(
10986            &structural.changes.is_empty(),
10987            &true,
10988            "duplicate structural mutations",
10989        )?;
10990
10991        apply_symbol_build_stage(&mut store, &mut symbols, &control)?;
10992        apply_structural_summary_stage(&mut store, &structural, &control)?;
10993        let indexed = store
10994            .load_node_by_path("package.json")?
10995            .ok_or_else(|| io::Error::other("indexed package manifest missing"))?;
10996        require_eq(
10997            &indexed.summary.as_deref(),
10998            &Some("package manifest for demo with scripts test and 1 dependencies."),
10999            "persisted structural content summary",
11000        )?;
11001        require_eq(
11002            &indexed.purpose.purpose.as_deref(),
11003            &Some("Own the JavaScript package manifest."),
11004            "approved purpose text",
11005        )?;
11006        require_eq(
11007            &indexed.purpose.status,
11008            &PurposeStatus::Approved,
11009            "approved purpose status",
11010        )?;
11011        Ok(())
11012    }
11013
11014    #[test]
11015    fn symbol_build_clamps_file_bytes_and_bounds_all_published_output() -> Result<(), Box<dyn Error>>
11016    {
11017        let temp = tempfile::tempdir()?;
11018        fs::write(
11019            temp.path().join("lib.rs"),
11020            "pub fn first() { second(); }\nfn second() {}\n",
11021        )?;
11022        let nodes = scan_repo(temp.path(), &ScanOptions::default())?;
11023        let mut store = AtlasStore::in_memory()?;
11024        store.replace_scan(&nodes)?;
11025        let options = SymbolBuildOptions::new(u64::MAX, Some(INDEX_WORKER_SAFE_CEILING), None);
11026        require_eq(
11027            &options.max_bytes,
11028            &MAX_SYMBOL_FILE_BYTES,
11029            "effective CLI/MCP symbol file limit",
11030        )?;
11031        let control = standalone_index_work_control();
11032        for resource in [
11033            IndexWorkResource::SymbolRows,
11034            IndexWorkResource::RelationRows,
11035            IndexWorkResource::OutputBytes,
11036        ] {
11037            if !matches!(
11038                checked_symbol_publication_usage(1, 1, 1, resource),
11039                Err(CliError::IndexWork(
11040                    IndexWorkFailure::ResourceLimitExceeded { observed: 2, .. }
11041                ))
11042            ) {
11043                return Err(io::Error::other(format!(
11044                    "symbol publication did not accumulate {resource}"
11045                ))
11046                .into());
11047            }
11048        }
11049        let cases = [
11050            (
11051                SymbolPublicationLimits {
11052                    symbol_rows: 0,
11053                    relation_rows: u64::MAX,
11054                    output_bytes: u64::MAX,
11055                },
11056                IndexWorkResource::SymbolRows,
11057            ),
11058            (
11059                SymbolPublicationLimits {
11060                    symbol_rows: u64::MAX,
11061                    relation_rows: 0,
11062                    output_bytes: u64::MAX,
11063                },
11064                IndexWorkResource::RelationRows,
11065            ),
11066            (
11067                SymbolPublicationLimits {
11068                    symbol_rows: u64::MAX,
11069                    relation_rows: u64::MAX,
11070                    output_bytes: 0,
11071                },
11072                IndexWorkResource::OutputBytes,
11073            ),
11074        ];
11075        for (limits, expected_resource) in cases {
11076            let result = build_symbols_for_paths_with_limits(
11077                &mut store,
11078                temp.path(),
11079                &options,
11080                None,
11081                None,
11082                &control,
11083                limits,
11084            );
11085            if !matches!(
11086                result,
11087                Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
11088                    stage: IndexWorkStage::SymbolParsing,
11089                    resource,
11090                    ..
11091                })) if resource == expected_resource
11092            ) {
11093                return Err(io::Error::other(format!(
11094                    "symbol publication did not enforce {expected_resource}"
11095                ))
11096                .into());
11097            }
11098            require_eq(
11099                &store.symbol_count_for_path("lib.rs")?,
11100                &0,
11101                "no over-limit symbol output persisted",
11102            )?;
11103        }
11104
11105        let mut oversized_node = nodes
11106            .iter()
11107            .find(|node| node.path == "lib.rs")
11108            .cloned()
11109            .ok_or_else(|| io::Error::other("oversized symbol fixture node missing"))?;
11110        oversized_node.size_bytes = Some(4);
11111        let clear_bytes = oversized_node.path.len() as u64
11112            + oversized_node.language.as_ref().map_or(0, String::len) as u64;
11113        let clear_options = SymbolBuildOptions::new(3, Some(1), None);
11114        let clear_limits = SymbolPublicationLimits {
11115            symbol_rows: u64::MAX,
11116            relation_rows: u64::MAX,
11117            output_bytes: clear_bytes.saturating_sub(1),
11118        };
11119        let clear_result = stage_symbols_for_nodes_with_limits(
11120            &store,
11121            temp.path(),
11122            #[cfg(feature = "optional-parser-supervisor")]
11123            &OptionalParserPackProjectSelection::Inactive,
11124            std::slice::from_ref(&oversized_node),
11125            &clear_options,
11126            None,
11127            None,
11128            &HashSet::new(),
11129            &control,
11130            clear_limits,
11131        );
11132        if !matches!(
11133            clear_result,
11134            Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
11135                stage: IndexWorkStage::SymbolParsing,
11136                resource: IndexWorkResource::OutputBytes,
11137                limit,
11138                observed,
11139            })) if limit == clear_bytes.saturating_sub(1) && observed == clear_bytes
11140        ) {
11141            return Err(
11142                io::Error::other("symbol clear output bypassed its retained-byte limit").into(),
11143            );
11144        }
11145        let clear_stage = stage_symbols_for_nodes_with_limits(
11146            &store,
11147            temp.path(),
11148            #[cfg(feature = "optional-parser-supervisor")]
11149            &OptionalParserPackProjectSelection::Inactive,
11150            std::slice::from_ref(&oversized_node),
11151            &clear_options,
11152            None,
11153            None,
11154            &HashSet::new(),
11155            &control,
11156            SymbolPublicationLimits {
11157                output_bytes: clear_bytes,
11158                ..SymbolPublicationLimits::STANDARD
11159            },
11160        )?;
11161        require_eq(
11162            &clear_stage.retained_bytes,
11163            &clear_bytes,
11164            "retained symbol clear bytes",
11165        )?;
11166        if !matches!(
11167            clear_stage.changes.as_slice(),
11168            [SymbolProjectionChange::Clear { path, language }]
11169                if path == "lib.rs" && language.as_deref() == Some("rust")
11170        ) {
11171            return Err(
11172                io::Error::other("oversized symbol output did not retain one clear").into(),
11173            );
11174        }
11175
11176        let report = build_symbols_for_paths_with_limits(
11177            &mut store,
11178            temp.path(),
11179            &options,
11180            None,
11181            None,
11182            &control,
11183            SymbolPublicationLimits::STANDARD,
11184        )?;
11185        require_eq(&report.parsed, &1, "compatible bounded symbol build")?;
11186        require_eq(&report.max_workers, &1, "single-job symbol worker count")?;
11187        if report.symbols == 0 || report.relations == 0 {
11188            return Err(io::Error::other("bounded symbol build omitted parser output").into());
11189        }
11190        Ok(())
11191    }
11192
11193    #[test]
11194    fn markdown_parse_budget_counts_enclosing_heading_identity() -> Result<(), Box<dyn Error>> {
11195        let temp = tempfile::tempdir()?;
11196        let path = temp.path().join("guide.md");
11197        let content = "# Guide\n\n[target](target.md)\n";
11198        fs::write(&path, content)?;
11199        let SymbolParseOutcome::Parsed(mut parsed) = parse_symbol_job(
11200            &SymbolParseJob {
11201                path: "guide.md".to_string(),
11202                native_path: path,
11203                expected_content_hash: blake3::hash(content.as_bytes()).to_hex().to_string(),
11204                language: Some("markdown".to_string()),
11205                fallback_summary: None,
11206                purpose_needs_suggestion: false,
11207            },
11208            &SymbolBuildOptions::new(1_024, Some(1), None),
11209            Instant::now(),
11210        ) else {
11211            return Err(io::Error::other("Markdown fixture did not parse").into());
11212        };
11213        let enclosing_heading_bytes = parsed
11214            .markdown_facts
11215            .as_ref()
11216            .ok_or_else(|| io::Error::other("Markdown facts missing"))?
11217            .link_candidates
11218            .iter()
11219            .map(|candidate| candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64)
11220            .sum::<u64>();
11221        let retained_with_heading_owners = symbol_parse_output_bytes(&parsed);
11222        for candidate in &mut parsed
11223            .markdown_facts
11224            .as_mut()
11225            .ok_or_else(|| io::Error::other("Markdown facts missing"))?
11226            .link_candidates
11227        {
11228            candidate.enclosing_heading = None;
11229        }
11230        require_eq(
11231            &retained_with_heading_owners.saturating_sub(symbol_parse_output_bytes(&parsed)),
11232            &enclosing_heading_bytes,
11233            "parser output enclosing-heading bytes",
11234        )?;
11235        Ok(())
11236    }
11237
11238    #[test]
11239    fn purpose_import_skips_non_utf8_source_headers_but_keeps_authored_inputs_strict()
11240    -> Result<(), Box<dyn Error>> {
11241        let temp = tempfile::tempdir()?;
11242        let config_path = init_config_path(temp.path(), None);
11243        init_project_with_config(temp.path(), Some(&config_path))?;
11244        let config = load_atlas_config(Some(&config_path))?;
11245        fs::write(
11246            temp.path().join("binary.txt"),
11247            b"// Purpose: Must not be imported from binary source.\n\xff",
11248        )?;
11249        let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
11250        let nodes = scan_repo(&plan.root, &plan.scan_options)?;
11251        if !nodes.iter().any(|node| node.path == "binary.txt") {
11252            return Err(io::Error::other("binary source fixture was not scanned").into());
11253        }
11254        let snapshot =
11255            plan.purpose_import_snapshot_controlled(&nodes, &standalone_index_work_control())?;
11256        if snapshot
11257            .records
11258            .iter()
11259            .any(|record| record.path == "binary.txt")
11260        {
11261            return Err(io::Error::other("non-UTF-8 source header imported a purpose").into());
11262        }
11263
11264        fs::write(&config.map_path, [0xff])?;
11265        let strict =
11266            plan.purpose_import_snapshot_controlled(&nodes, &standalone_index_work_control());
11267        if !matches!(
11268            strict,
11269            Err(CliError::InvalidInput(message))
11270                if message.contains("purpose input is not valid UTF-8")
11271        ) {
11272            return Err(
11273                io::Error::other("non-UTF-8 authored purpose input was not rejected").into(),
11274            );
11275        }
11276        Ok(())
11277    }
11278
11279    #[test]
11280    fn purpose_import_inputs_observe_cancellation_limits_and_rollback() -> Result<(), Box<dyn Error>>
11281    {
11282        struct CancelAfterFirstRead {
11283            bytes: io::Cursor<Vec<u8>>,
11284            cancellation: IndexCancellation,
11285            reads: usize,
11286        }
11287
11288        impl Read for CancelAfterFirstRead {
11289            fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
11290                let read = self.bytes.read(buffer)?;
11291                self.reads += 1;
11292                if self.reads == 1 {
11293                    self.cancellation.cancel();
11294                }
11295                Ok(read)
11296            }
11297        }
11298
11299        let temp = tempfile::tempdir()?;
11300        let config_path = init_config_path(temp.path(), None);
11301        init_project_with_config(temp.path(), Some(&config_path))?;
11302        let fixture_config = load_atlas_config(Some(&config_path))?;
11303        fs::write(temp.path().join("lib.rs"), "fn imported() {}\n")?;
11304        fs::write(
11305            &fixture_config.map_path,
11306            "folders[1]:\n  .,Controlled imported repository purpose\n",
11307        )?;
11308        let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
11309        let nodes = scan_repo(&plan.root, &plan.scan_options)?;
11310        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
11311        let mut store = AtlasStore::in_memory()?;
11312        store.set_project_root(&plan.root)?;
11313        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
11314        let publication_before = store
11315            .index_publication()?
11316            .ok_or_else(|| io::Error::other("controlled import publication missing"))?;
11317        let root_before = store
11318            .load_node_by_path(".")?
11319            .ok_or_else(|| io::Error::other("controlled import root missing"))?;
11320        let text_before = store
11321            .load_file_text("lib.rs")?
11322            .ok_or_else(|| io::Error::other("controlled import text missing"))?;
11323        let symbol_count_before = store.symbol_count()?;
11324        let relation_count_before = store.symbol_relation_count()?;
11325
11326        let config_bytes = fs::metadata(&config_path)?.len();
11327        let map_bytes = fs::metadata(&fixture_config.map_path)?.len();
11328        let nonsource_bytes = fs::metadata(&fixture_config.nonsource_files_path)?.len();
11329        let source_bytes = fs::metadata(temp.path().join("lib.rs"))?.len();
11330        let staged_input_bytes = config_bytes
11331            .saturating_add(map_bytes)
11332            .saturating_add(nonsource_bytes)
11333            .saturating_add(source_bytes);
11334        let complete_operation_bytes = config_bytes
11335            .saturating_add(staged_input_bytes)
11336            .saturating_add(config_bytes)
11337            .saturating_add(staged_input_bytes);
11338        let largest_complete_input = config_bytes.max(map_bytes).max(nonsource_bytes);
11339        let aggregate_limits = PurposeImportLimits {
11340            total_bytes: complete_operation_bytes,
11341            complete_file_bytes: largest_complete_input,
11342            header_bytes: source_bytes,
11343            records: 100,
11344        };
11345        let aggregate_control = standalone_index_work_control();
11346        let aggregate_plan = ScanRuntimePlan::for_path_controlled_with_limits(
11347            None,
11348            temp.path(),
11349            None,
11350            &aggregate_control,
11351            aggregate_limits,
11352        )?;
11353        let aggregate_nodes = scan_repo(&aggregate_plan.root, &aggregate_plan.scan_options)?;
11354        let aggregate_snapshot = aggregate_plan.purpose_import_snapshot_controlled_with_limits(
11355            &aggregate_nodes,
11356            &aggregate_control,
11357            aggregate_limits,
11358        )?;
11359        revalidate_index_publication_inputs_controlled_with_limits(
11360            &store,
11361            &aggregate_plan,
11362            Some(&aggregate_snapshot.fingerprint),
11363            &aggregate_control,
11364            aggregate_limits,
11365        )?;
11366
11367        let cumulative_limit = complete_operation_bytes.saturating_sub(1);
11368        let cumulative_limits = PurposeImportLimits {
11369            total_bytes: cumulative_limit,
11370            ..aggregate_limits
11371        };
11372        let cumulative_control = standalone_index_work_control();
11373        let cumulative_plan = ScanRuntimePlan::for_path_controlled_with_limits(
11374            None,
11375            temp.path(),
11376            None,
11377            &cumulative_control,
11378            cumulative_limits,
11379        )?;
11380        let cumulative_nodes = scan_repo(&cumulative_plan.root, &cumulative_plan.scan_options)?;
11381        let cumulative_snapshot = cumulative_plan.purpose_import_snapshot_controlled_with_limits(
11382            &cumulative_nodes,
11383            &cumulative_control,
11384            cumulative_limits,
11385        )?;
11386        let cumulative_result = revalidate_index_publication_inputs_controlled_with_limits(
11387            &store,
11388            &cumulative_plan,
11389            Some(&cumulative_snapshot.fingerprint),
11390            &cumulative_control.with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT),
11391            cumulative_limits,
11392        );
11393        if !matches!(
11394            cumulative_result,
11395            Err(CliError::IndexWork(
11396                IndexWorkFailure::ResourceLimitExceeded {
11397                    stage: IndexWorkStage::Publication,
11398                    resource: IndexWorkResource::PurposeBytes,
11399                    limit,
11400                    observed,
11401                }
11402            )) if limit == cumulative_limit && observed > limit
11403        ) {
11404            return Err(io::Error::other(
11405                "plan, staging, and revalidation readers did not share one purpose-byte budget",
11406            )
11407            .into());
11408        }
11409
11410        let control = standalone_index_work_control();
11411        let staged_snapshot = plan.purpose_import_snapshot_controlled(&nodes, &control)?;
11412        let small_limits = PurposeImportLimits {
11413            total_bytes: 64,
11414            complete_file_bytes: 64,
11415            header_bytes: 64,
11416            records: 100,
11417        };
11418        let initial_limit_control = standalone_index_work_control();
11419        let initial_limited = plan.purpose_import_snapshot_controlled_with_limits(
11420            &nodes,
11421            &initial_limit_control,
11422            small_limits,
11423        );
11424        if !matches!(
11425            initial_limited,
11426            Err(CliError::IndexWork(
11427                IndexWorkFailure::ResourceLimitExceeded {
11428                    stage: IndexWorkStage::Publication,
11429                    resource: IndexWorkResource::PurposeBytes,
11430                    limit: 64,
11431                    observed: 65,
11432                }
11433            ))
11434        ) {
11435            return Err(io::Error::other(
11436                "initial purpose snapshot exceeded limits without a typed failure",
11437            )
11438            .into());
11439        }
11440        let initial_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
11441        initial_cancel.cancel();
11442        if !matches!(
11443            plan.purpose_import_snapshot_controlled(&nodes, &initial_cancel),
11444            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
11445                stage: IndexWorkStage::Publication,
11446            }))
11447        ) {
11448            return Err(io::Error::other(
11449                "initial purpose snapshot ignored operation cancellation",
11450            )
11451            .into());
11452        }
11453
11454        let contract_fingerprint = plan.publication_contract_fingerprint();
11455        let mut publication = store.begin_index_publication(&contract_fingerprint)?;
11456        publication.set_project_root(&plan.root)?;
11457        publication.replace_scan(&nodes)?;
11458        publication.set_purpose(".", "Uncommitted purpose input", PurposeSource::Imported)?;
11459        let publication_limit_control = standalone_index_work_control();
11460        let limited = revalidate_index_publication_inputs_controlled_with_limits(
11461            &publication,
11462            &plan,
11463            Some(&staged_snapshot.fingerprint),
11464            &publication_limit_control,
11465            small_limits,
11466        );
11467        if !matches!(
11468            limited,
11469            Err(CliError::IndexWork(
11470                IndexWorkFailure::ResourceLimitExceeded {
11471                    stage: IndexWorkStage::Publication,
11472                    resource: IndexWorkResource::PurposeBytes,
11473                    limit: 64,
11474                    observed: 65,
11475                }
11476            ))
11477        ) {
11478            return Err(io::Error::other(
11479                "publication purpose inputs exceeded limits without a typed failure",
11480            )
11481            .into());
11482        }
11483        drop(publication);
11484        require_eq(
11485            &store.index_publication()?,
11486            &Some(publication_before.clone()),
11487            "publication after bounded purpose-input rollback",
11488        )?;
11489        require_eq(
11490            &store.load_node_by_path(".")?,
11491            &Some(root_before.clone()),
11492            "authored purpose after bounded input rollback",
11493        )?;
11494        require_eq(
11495            &store.load_file_text("lib.rs")?,
11496            &Some(text_before.clone()),
11497            "indexed text after bounded input rollback",
11498        )?;
11499        require_eq(
11500            &store.symbol_count()?,
11501            &symbol_count_before,
11502            "symbols after bounded input rollback",
11503        )?;
11504        require_eq(
11505            &store.symbol_relation_count()?,
11506            &relation_count_before,
11507            "relations after bounded input rollback",
11508        )?;
11509
11510        let mut canceled_publication = store.begin_index_publication(&contract_fingerprint)?;
11511        canceled_publication.set_project_root(&plan.root)?;
11512        canceled_publication.replace_scan(&nodes)?;
11513        canceled_publication.set_purpose(
11514            ".",
11515            "Canceled uncommitted purpose input",
11516            PurposeSource::Imported,
11517        )?;
11518        let late_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
11519        late_cancel.cancel();
11520        let canceled = revalidate_index_publication_inputs_controlled(
11521            &canceled_publication,
11522            &plan,
11523            Some(&staged_snapshot.fingerprint),
11524            &late_cancel,
11525        );
11526        if !matches!(
11527            canceled,
11528            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
11529                stage: IndexWorkStage::Publication,
11530            }))
11531        ) {
11532            return Err(io::Error::other(
11533                "late purpose-input revalidation ignored operation cancellation",
11534            )
11535            .into());
11536        }
11537        drop(canceled_publication);
11538        require_eq(
11539            &store.index_publication()?,
11540            &Some(publication_before),
11541            "publication after canceled purpose-input rollback",
11542        )?;
11543        require_eq(
11544            &store.load_node_by_path(".")?,
11545            &Some(root_before),
11546            "authored purpose after canceled input rollback",
11547        )?;
11548        require_eq(
11549            &store.load_file_text("lib.rs")?,
11550            &Some(text_before),
11551            "indexed text after canceled input rollback",
11552        )?;
11553        require_eq(
11554            &store.symbol_count()?,
11555            &symbol_count_before,
11556            "symbols after canceled input rollback",
11557        )?;
11558        require_eq(
11559            &store.symbol_relation_count()?,
11560            &relation_count_before,
11561            "relations after canceled input rollback",
11562        )?;
11563
11564        let record_limited = plan.purpose_import_snapshot_controlled_with_limits(
11565            &nodes,
11566            &control,
11567            PurposeImportLimits {
11568                records: 0,
11569                ..PurposeImportLimits::default()
11570            },
11571        );
11572        if !matches!(
11573            record_limited,
11574            Err(CliError::IndexWork(
11575                IndexWorkFailure::ResourceLimitExceeded {
11576                    stage: IndexWorkStage::Publication,
11577                    resource: IndexWorkResource::PurposeRecords,
11578                    limit: 0,
11579                    observed,
11580                }
11581            )) if observed > 0
11582        ) {
11583            return Err(io::Error::other("purpose record limit was not enforced").into());
11584        }
11585
11586        let cancellation = IndexCancellation::new();
11587        let cancel_control = IndexWorkControl::new(cancellation.clone(), None);
11588        let mut reader =
11589            PurposeInputReader::new(&plan, &cancel_control, PurposeImportLimits::default());
11590        let mut input = CancelAfterFirstRead {
11591            bytes: io::Cursor::new(vec![b'x'; CONTROLLED_SOURCE_READ_BUFFER_BYTES * 2]),
11592            cancellation,
11593            reads: 0,
11594        };
11595        let canceled = reader.read_bytes(
11596            Path::new("controlled-purpose-input"),
11597            &mut input,
11598            u64::try_from(CONTROLLED_SOURCE_READ_BUFFER_BYTES * 2).unwrap_or(u64::MAX),
11599            true,
11600        );
11601        if !matches!(
11602            canceled,
11603            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
11604                stage: IndexWorkStage::Publication,
11605            }))
11606        ) {
11607            return Err(io::Error::other(
11608                "purpose input did not observe cancellation between chunks",
11609            )
11610            .into());
11611        }
11612        Ok(())
11613    }
11614
11615    #[test]
11616    fn publication_revalidation_rejects_source_policy_and_import_drift()
11617    -> Result<(), Box<dyn Error>> {
11618        let temp = tempfile::tempdir()?;
11619        let config_dir = temp.path().join(".projectatlas");
11620        fs::create_dir_all(&config_dir)?;
11621        let source_path = temp.path().join("lib.rs");
11622        let staged_source = "fn first() {}\n";
11623        fs::write(&source_path, staged_source)?;
11624        let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
11625        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
11626        let db_path = config_dir.join("projectatlas.db");
11627        let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
11628        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
11629        let control = standalone_index_work_control();
11630        let generation_before_contention = store
11631            .index_publication()?
11632            .ok_or_else(|| io::Error::other("initial publication missing"))?
11633            .generation;
11634        let contract_fingerprint = plan.publication_contract_fingerprint();
11635        store.probe_index_publication_writer()?;
11636        let contended_batch =
11637            stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
11638        revalidate_staged_publication_inputs_controlled(
11639            &plan,
11640            contended_batch.nodes.expected_nodes(),
11641            None,
11642            &control,
11643        )?;
11644        let writer_blocker = rusqlite::Connection::open(&db_path)?;
11645        writer_blocker.execute_batch("BEGIN IMMEDIATE")?;
11646        let source_after_contention = "fn later() {}\n";
11647        let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
11648        let (publish_tx, publish_rx) = std::sync::mpsc::sync_channel(1);
11649        let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1);
11650        let publisher = std::thread::spawn(move || {
11651            if ready_tx.send(()).is_err() || publish_rx.recv().is_err() {
11652                return;
11653            }
11654            let publication_control = standalone_index_work_control();
11655            let result = publish_index_batch(&mut store, contended_batch, &publication_control);
11656            drop(result_tx.send((store, result)));
11657        });
11658        ready_rx
11659            .recv_timeout(Duration::from_secs(1))
11660            .map_err(|source| {
11661                io::Error::other(format!("publisher did not become ready: {source}"))
11662            })?;
11663        fs::write(&source_path, source_after_contention)?;
11664        publish_tx
11665            .send(())
11666            .map_err(|source| io::Error::other(format!("publisher stopped early: {source}")))?;
11667        let timely_result = result_rx.recv_timeout(Duration::from_millis(500));
11668        writer_blocker.execute_batch("ROLLBACK")?;
11669        let (returned_store, contention_result) = match timely_result {
11670            Ok(result) => result,
11671            Err(source) => {
11672                publisher
11673                    .join()
11674                    .map_err(|_panic| io::Error::other("publication thread panicked"))?;
11675                return Err(io::Error::other(format!(
11676                    "publication waited for a contending writer instead of failing fast: {source}"
11677                ))
11678                .into());
11679            }
11680        };
11681        publisher
11682            .join()
11683            .map_err(|_panic| io::Error::other("publication thread panicked"))?;
11684        store = returned_store;
11685        let Err(CliError::Db(contention)) = contention_result else {
11686            return Err(io::Error::other(
11687                "publication waited through contention and accepted stale staged source",
11688            )
11689            .into());
11690        };
11691        if !contention.is_write_unavailable() {
11692            return Err(io::Error::other(
11693                "publication contention did not return typed write-unavailable state",
11694            )
11695            .into());
11696        }
11697        require_eq(
11698            &store
11699                .index_publication()?
11700                .ok_or_else(|| io::Error::other("publication missing after contention"))?
11701                .generation,
11702            &generation_before_contention,
11703            "publication generation after contention",
11704        )?;
11705        require_eq(
11706            &store
11707                .load_file_text("lib.rs")?
11708                .ok_or_else(|| io::Error::other("indexed text missing after contention"))?
11709                .content,
11710            &staged_source.to_string(),
11711            "indexed text after contention",
11712        )?;
11713        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
11714        require_eq(
11715            &store
11716                .load_file_text("lib.rs")?
11717                .ok_or_else(|| io::Error::other("indexed text missing after retry"))?
11718                .content,
11719            &source_after_contention.to_string(),
11720            "restaged text after contention retry",
11721        )?;
11722        let initial_generation = store
11723            .index_publication()?
11724            .ok_or_else(|| io::Error::other("retried publication missing"))?
11725            .generation;
11726        require_eq(
11727            &initial_generation,
11728            &generation_before_contention
11729                .checked_next()
11730                .ok_or_else(|| io::Error::other("test generation overflowed"))?,
11731            "single generation advance after contention retry",
11732        )?;
11733        let mut competing_store = open_atlas_store_for_project(&db_path, &plan.root)?;
11734        let competing_publication = competing_store
11735            .begin_index_projection_refresh_from(&contract_fingerprint, initial_generation)?;
11736        competing_publication.set_node_summary("lib.rs", "winning projection")?;
11737        let generation_conflict_batch =
11738            stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
11739        competing_publication.complete()?;
11740        let winning_generation = initial_generation
11741            .checked_next()
11742            .ok_or_else(|| io::Error::other("test generation overflowed"))?;
11743        let conflict = publish_index_batch(&mut store, generation_conflict_batch, &control);
11744        if !matches!(
11745            conflict,
11746            Err(CliError::Db(
11747                projectatlas_db::DbError::PublicationBaseGenerationChanged {
11748                    expected,
11749                    found,
11750                }
11751            )) if expected == initial_generation && found == winning_generation
11752        ) {
11753            return Err(io::Error::other(
11754                "runtime publication did not reject a generation changed after preparation",
11755            )
11756            .into());
11757        }
11758        let winning_publication = store
11759            .index_publication()?
11760            .ok_or_else(|| io::Error::other("winning publication missing"))?;
11761        require_eq(
11762            &winning_publication.generation,
11763            &winning_generation,
11764            "winning publication generation",
11765        )?;
11766        require_eq(
11767            &winning_publication.state,
11768            &projectatlas_db::IndexPublicationState::Complete,
11769            "winning publication state",
11770        )?;
11771        require_eq(
11772            &store
11773                .load_node_by_path("lib.rs")?
11774                .and_then(|node| node.summary),
11775            &Some("winning projection".to_string()),
11776            "winning publication summary",
11777        )?;
11778        let staged_source_batch =
11779            stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
11780
11781        fs::write(&source_path, "fn other() {}\n")?;
11782        let source_result = revalidate_staged_publication_inputs_controlled(
11783            &plan,
11784            staged_source_batch.nodes.expected_nodes(),
11785            None,
11786            &control,
11787        );
11788        let Err(CliError::RefreshRequired(details)) = source_result else {
11789            return Err(io::Error::other("publication accepted changed source state").into());
11790        };
11791        require_eq(
11792            &details.reason,
11793            &IndexRefreshReason::SourceChanged,
11794            "publication source-change reason",
11795        )?;
11796        require_eq(
11797            &details.sample_paths,
11798            &vec!["lib.rs".to_string()],
11799            "publication source-change paths",
11800        )?;
11801
11802        fs::write(&source_path, staged_source)?;
11803        let staged_policy_batch =
11804            stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
11805        let config_path = config_dir.join("config.toml");
11806        fs::write(
11807            &config_path,
11808            r#"[project]
11809root = "."
11810map_path = ".projectatlas/projectatlas.toon"
11811nonsource_files_path = ".projectatlas/projectatlas-nonsource-files.toon"
11812
11813[scan]
11814source_extensions = [".rs"]
11815exclude_dir_names = [".git", ".projectatlas", "target"]
11816exclude_dir_suffixes = []
11817exclude_path_prefixes = []
11818non_source_path_prefixes = []
11819text_index_max_bytes = 7
11820
11821[purpose]
11822default_style = "line-comment"
11823line_comment_prefixes = ["//"]
11824
11825[purpose.styles_by_extension]
11826".rs" = "line-comment"
11827"#,
11828        )?;
11829        let policy_result = revalidate_staged_publication_inputs_controlled(
11830            &plan,
11831            staged_policy_batch.nodes.expected_nodes(),
11832            None,
11833            &control,
11834        );
11835        let Err(CliError::VerificationIncomplete(details)) = policy_result else {
11836            return Err(io::Error::other("publication accepted changed effective policy").into());
11837        };
11838        require_eq(
11839            &details.reason,
11840            &IndexVerificationReason::PublicationContractMismatch,
11841            "publication policy-change reason",
11842        )?;
11843
11844        let configured_plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
11845        let request_limited_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1))?;
11846        require_eq(
11847            &configured_plan.text_options.max_bytes,
11848            &7,
11849            "configured text-index limit",
11850        )?;
11851        require_eq(
11852            &request_limited_plan.text_options.max_bytes,
11853            &1,
11854            "request-scoped text-index limit",
11855        )?;
11856        require_eq(
11857            &configured_plan.publication_contract_fingerprint(),
11858            &request_limited_plan.publication_contract_fingerprint(),
11859            "request limit excluded from publication contract",
11860        )?;
11861        let reloaded_request_plan = request_limited_plan.reload()?;
11862        require_eq(
11863            &reloaded_request_plan.text_options.max_bytes,
11864            &1,
11865            "request limit retained across operation reload",
11866        )?;
11867        require_eq(
11868            &request_limited_plan.publication_contract_fingerprint(),
11869            &reloaded_request_plan.publication_contract_fingerprint(),
11870            "reloaded operation publication contract",
11871        )?;
11872
11873        let changed_config = fs::read_to_string(&config_path)?
11874            .replace("text_index_max_bytes = 7", "text_index_max_bytes = 8");
11875        fs::write(&config_path, changed_config)?;
11876        let configured_cap_changed_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1))?;
11877        if configured_plan.publication_contract_fingerprint()
11878            == configured_cap_changed_plan.publication_contract_fingerprint()
11879        {
11880            return Err(io::Error::other(
11881                "configured text-index limit did not change the publication contract",
11882            )
11883            .into());
11884        }
11885
11886        let import_repo = temp.path().join("import-repo");
11887        let import_atlas_dir = import_repo.join(".projectatlas");
11888        fs::create_dir_all(&import_atlas_dir)?;
11889        fs::write(import_repo.join("lib.rs"), "fn imported() {}\n")?;
11890        let import_map_path = import_atlas_dir.join("projectatlas.toon");
11891        fs::write(
11892            &import_map_path,
11893            "folders[1]:\n  .,Original imported repository purpose\n",
11894        )?;
11895        let external_config_path = temp.path().join("external-config.toml");
11896        fs::write(
11897            &external_config_path,
11898            r#"[project]
11899root = "import-repo"
11900map_path = ".projectatlas/projectatlas.toon"
11901nonsource_files_path = ".projectatlas/projectatlas-nonsource-files.toon"
11902"#,
11903        )?;
11904        let import_plan =
11905            ScanRuntimePlan::for_path(Some(&external_config_path), &import_repo, Some(1))?;
11906        let mut import_store = open_atlas_store_for_project(
11907            &import_atlas_dir.join("projectatlas.db"),
11908            &import_plan.root,
11909        )?;
11910        run_scan_pipeline(&mut import_store, &import_plan, &symbol_options)?;
11911        verify_index_freshness(&import_store, &import_repo, Some(&external_config_path))?;
11912        let normal_import_plan =
11913            ScanRuntimePlan::for_path(Some(&external_config_path), &import_repo, None)?;
11914        run_symbol_build_pipeline(
11915            &mut import_store,
11916            &normal_import_plan,
11917            &symbol_options,
11918            None,
11919        )?;
11920        verify_index_publication(&import_store, &normal_import_plan)?;
11921        let publication_before = import_store
11922            .index_publication()?
11923            .ok_or_else(|| io::Error::other("initial imported publication missing"))?;
11924        let root_before = import_store
11925            .load_node_by_path(".")?
11926            .ok_or_else(|| io::Error::other("imported root node missing"))?;
11927        if root_before.purpose.purpose.as_deref() != Some("Original imported repository purpose") {
11928            return Err(io::Error::other("legacy purpose fixture was not imported").into());
11929        }
11930
11931        let import_control = standalone_index_work_control();
11932        let staged_import_batch = stage_full_index_publication(
11933            &import_store,
11934            &import_plan,
11935            &symbol_options,
11936            true,
11937            true,
11938            &import_control,
11939        )?;
11940        let staged_purpose_import = staged_import_batch
11941            .purpose_import
11942            .as_ref()
11943            .ok_or_else(|| io::Error::other("staged purpose import missing"))?;
11944        if !staged_purpose_import
11945            .records
11946            .iter()
11947            .any(|record| record.summary == "Original imported repository purpose")
11948        {
11949            return Err(io::Error::other("legacy purpose fixture was not imported").into());
11950        }
11951        fs::write(
11952            &import_map_path,
11953            "folders[1]:\n  .,Changed imported repository purpose\n",
11954        )?;
11955        let import_result = revalidate_staged_publication_inputs_with_purpose_snapshot(
11956            &import_plan,
11957            staged_import_batch.nodes.expected_nodes(),
11958            Some(staged_purpose_import),
11959            &import_control,
11960        );
11961        let Err(CliError::VerificationIncomplete(details)) = import_result else {
11962            return Err(io::Error::other(
11963                "publication accepted changed legacy purpose import inputs",
11964            )
11965            .into());
11966        };
11967        require_eq(
11968            &details.reason,
11969            &IndexVerificationReason::PublicationContractMismatch,
11970            "publication import-change reason",
11971        )?;
11972        require_eq(
11973            &import_store.index_publication()?,
11974            &Some(publication_before),
11975            "publication after purpose-import rollback",
11976        )?;
11977        require_eq(
11978            &import_store.load_node_by_path(".")?,
11979            &Some(root_before),
11980            "authored purpose after purpose-import rollback",
11981        )?;
11982        Ok(())
11983    }
11984
11985    #[test]
11986    fn projection_contract_revisions_force_full_projection_refresh() -> Result<(), Box<dyn Error>> {
11987        const PRE_MODULE_CALLBACK_DIGEST: &str =
11988            "487625adf2f9ec76f98034d4ef5667e707960b6b8afd280b213021cb64a0f10f";
11989        let temp = tempfile::tempdir()?;
11990        let atlas_dir = temp.path().join(".projectatlas");
11991        fs::create_dir_all(&atlas_dir)?;
11992        fs::create_dir_all(temp.path().join("src"))?;
11993        fs::create_dir_all(temp.path().join("docs"))?;
11994        fs::write(
11995            temp.path().join("src/config.rs"),
11996            "pub fn load_timeout_millis() -> u64 { 250 }\n",
11997        )?;
11998        fs::write(
11999            temp.path().join("src/handler.rs"),
12000            "use crate::config;\npub fn health_response() { let _ = config::load_timeout_millis(); }\n",
12001        )?;
12002        fs::write(
12003            temp.path().join("src/router.rs"),
12004            "use crate::handler;\npub fn dispatch(path: &str) -> Option<()> { (path == \"/health\").then(handler::health_response) }\n",
12005        )?;
12006        fs::write(
12007            temp.path().join("docs/guide.md"),
12008            "# Configuration\n\nSee [the timeout source](../src/config.rs).\n",
12009        )?;
12010
12011        let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
12012        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12013        let db_path = atlas_dir.join("projectatlas.db");
12014        let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
12015        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
12016        let current_fingerprint = plan.publication_contract_fingerprint();
12017        let prior_projection_fingerprint = index_derivation_fingerprint_for_contract(
12018            &plan.scan_options,
12019            text_index_options(plan.config.as_ref(), None),
12020            #[cfg(feature = "optional-parser-supervisor")]
12021            &plan.optional_parser_selection,
12022            "2",
12023            &semantic_resolution_contract_digest(),
12024        );
12025        let prior_semantic_fingerprint = index_derivation_fingerprint_for_contract(
12026            &plan.scan_options,
12027            text_index_options(plan.config.as_ref(), None),
12028            #[cfg(feature = "optional-parser-supervisor")]
12029            &plan.optional_parser_selection,
12030            INDEX_DERIVATION_CONTRACT_VERSION,
12031            PRE_MODULE_CALLBACK_DIGEST,
12032        );
12033        if prior_projection_fingerprint == current_fingerprint
12034            || prior_semantic_fingerprint == current_fingerprint
12035        {
12036            return Err(io::Error::other(
12037                "projection or semantic contract revision did not change the derivation fingerprint",
12038            )
12039            .into());
12040        }
12041
12042        let current_generation = store
12043            .index_publication()?
12044            .ok_or_else(|| io::Error::other("current publication missing"))?
12045            .generation;
12046        store
12047            .begin_index_publication_from(&prior_projection_fingerprint, current_generation)?
12048            .complete()?;
12049        if publication_contract_matches(&store, &plan)? {
12050            return Err(io::Error::other(
12051                "prior semantic contract unexpectedly matched the current plan",
12052            )
12053            .into());
12054        }
12055        let Err(CliError::VerificationIncomplete(stale_contract)) =
12056            verify_index_publication(&store, &plan)
12057        else {
12058            return Err(io::Error::other(
12059                "stale document projection crossed the publication read boundary",
12060            )
12061            .into());
12062        };
12063        require_eq(
12064            &stale_contract.reason,
12065            &IndexVerificationReason::PublicationContractMismatch,
12066            "stale document projection read reason",
12067        )?;
12068
12069        let stale_generation = store
12070            .index_publication()?
12071            .ok_or_else(|| io::Error::other("stale publication missing"))?
12072            .generation;
12073        let control = standalone_index_work_control();
12074        refresh_index_controlled(&mut store, &plan, &symbol_options, &control)?;
12075        let refreshed = store
12076            .index_publication()?
12077            .ok_or_else(|| io::Error::other("refreshed publication missing"))?;
12078        if refreshed.generation <= stale_generation
12079            || refreshed.contract_fingerprint.as_deref() != Some(current_fingerprint.as_str())
12080        {
12081            return Err(io::Error::other(
12082                "semantic contract mismatch did not force a current full publication",
12083            )
12084            .into());
12085        }
12086        let document_relations = store.repository_graph_relations(
12087            RepositoryGraphRelationQuery::Family {
12088                relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12089            },
12090            10,
12091        )?;
12092        let document_source_is_file = document_relations.rows.iter().any(|relation| {
12093            store
12094                .repository_graph_entity(relation.source())
12095                .ok()
12096                .flatten()
12097                .is_some_and(|source| {
12098                    matches!(
12099                        source.selector(),
12100                        EntitySelector::File { path } if path.as_str() == "docs/guide.md"
12101                    )
12102                })
12103        });
12104        if !document_source_is_file {
12105            return Err(io::Error::other(
12106                "full contract refresh did not republish the document file as source",
12107            )
12108            .into());
12109        }
12110        let graphs = store.load_symbol_graphs_for_paths(&["src/router.rs".to_string()])?;
12111        if !graphs
12112            .iter()
12113            .flat_map(|graph| &graph.relations)
12114            .any(|relation| {
12115                relation.kind == projectatlas_core::symbols::RelationKind::Calls
12116                    && relation.target_name == "handler::health_response"
12117            })
12118        {
12119            return Err(io::Error::other(
12120                "semantic refresh did not publish the comparison-then callback edge",
12121            )
12122            .into());
12123        }
12124        Ok(())
12125    }
12126
12127    #[cfg(feature = "optional-parser-supervisor")]
12128    #[test]
12129    fn optional_parser_selection_changes_derivation_and_preserves_prior_generation_on_failure()
12130    -> Result<(), Box<dyn Error>> {
12131        let temp = tempfile::tempdir()?;
12132        let atlas_dir = temp.path().join(".projectatlas");
12133        fs::create_dir_all(&atlas_dir)?;
12134        let source_path = temp.path().join("main.awk");
12135        fs::write(&source_path, "BEGIN { print \"atlas\" }\n")?;
12136        let inactive_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12137        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12138        let db_path = atlas_dir.join("projectatlas.db");
12139        let mut store = open_atlas_store_for_project(&db_path, &inactive_plan.root)?;
12140        run_scan_pipeline(&mut store, &inactive_plan, &symbol_options)?;
12141        if inactive_plan.scan_options.admit_optional_languages {
12142            return Err(io::Error::other(
12143                "inactive optional pack admitted catalog languages into the scan policy",
12144            )
12145            .into());
12146        }
12147        let inactive_optional = store
12148            .load_node_by_path("main.awk")?
12149            .ok_or_else(|| io::Error::other("inactive optional source node missing"))?;
12150        if inactive_optional.node.language.is_some() {
12151            return Err(io::Error::other(
12152                "inactive optional extension received a catalog language assignment",
12153            )
12154            .into());
12155        }
12156        let before = store
12157            .index_publication()?
12158            .ok_or_else(|| io::Error::other("initial publication missing"))?;
12159
12160        let selection_path = temp.path().join(repo_path_to_native(
12161            OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH,
12162        ));
12163        fs::write(
12164            &selection_path,
12165            serde_json::to_vec(&json!({
12166                "schema_version": 1,
12167                "pack_id": "broad-parser",
12168                "selected": {
12169                    "projectatlas_version": OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION,
12170                    "artifact": "a".repeat(64),
12171                }
12172            }))?,
12173        )?;
12174        let selected_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12175        if !selected_plan.scan_options.admit_optional_languages {
12176            return Err(io::Error::other(
12177                "selected optional pack did not enable catalog language admission",
12178            )
12179            .into());
12180        }
12181        if inactive_plan.publication_contract_fingerprint()
12182            == selected_plan.publication_contract_fingerprint()
12183        {
12184            return Err(io::Error::other(
12185                "optional parser selection did not change the derivation contract",
12186            )
12187            .into());
12188        }
12189        if publication_contract_matches(&store, &selected_plan)? {
12190            return Err(io::Error::other(
12191                "selected optional artifact unexpectedly matched the inactive publication",
12192            )
12193            .into());
12194        }
12195        if !watch_path_requires_full_scan(temp.path(), &selection_path) {
12196            return Err(io::Error::other(
12197                "optional parser selection event did not require a full refresh",
12198            )
12199            .into());
12200        }
12201
12202        let mut changes = WatchChangeSet::default();
12203        changes.paths.insert(source_path);
12204        let result =
12205            refresh_index_for_changes(&mut store, &selected_plan, &changes, &symbol_options);
12206        if !matches!(result, Err(CliError::ParserPack(_))) {
12207            return Err(io::Error::other(
12208                "missing selected artifact did not fail before publication",
12209            )
12210            .into());
12211        }
12212        require_eq(
12213            &store
12214                .index_publication()?
12215                .ok_or_else(|| io::Error::other("publication disappeared after failure"))?
12216                .generation,
12217            &before.generation,
12218            "generation after selected optional artifact failure",
12219        )?;
12220
12221        fs::remove_file(selection_path)?;
12222        let disabled_plan = selected_plan.reload()?;
12223        if disabled_plan.scan_options.admit_optional_languages {
12224            return Err(io::Error::other(
12225                "disabled optional pack retained catalog language admission",
12226            )
12227            .into());
12228        }
12229        require_eq(
12230            &disabled_plan.publication_contract_fingerprint(),
12231            &inactive_plan.publication_contract_fingerprint(),
12232            "disabled optional parser derivation contract",
12233        )?;
12234
12235        let stale_graph = SymbolGraph {
12236            path: "main.awk".to_string(),
12237            language: Some("awk".to_string()),
12238            parser: ParserKind::Fallback,
12239            symbols: Vec::new(),
12240            relations: Vec::new(),
12241        };
12242        let stale_metadata = SourceParseMetadata {
12243            path: stale_graph.path.clone(),
12244            language: stale_graph.language.clone(),
12245            parser: ParserKind::TreeSitter,
12246            symbol_count: 0,
12247            relation_count: 0,
12248        };
12249        store.replace_symbol_graph_with_metadata(&stale_graph, &stale_metadata)?;
12250        let selected_publication = store.begin_index_publication_from(
12251            &selected_plan.publication_contract_fingerprint(),
12252            before.generation,
12253        )?;
12254        selected_publication.complete()?;
12255
12256        refresh_index(&mut store, &disabled_plan, &symbol_options)?;
12257        require_eq(
12258            &store.load_source_parse_metadata("main.awk")?,
12259            &None,
12260            "disabled optional parser metadata",
12261        )?;
12262        if !publication_contract_matches(&store, &disabled_plan)? {
12263            return Err(io::Error::other(
12264                "disabled optional parser refresh did not publish its derivation contract",
12265            )
12266            .into());
12267        }
12268        Ok(())
12269    }
12270
12271    #[test]
12272    fn symbol_projection_refresh_republishes_normalized_graph_at_one_generation()
12273    -> Result<(), Box<dyn Error>> {
12274        let temp = tempfile::tempdir()?;
12275        let atlas_dir = temp.path().join(".projectatlas");
12276        fs::create_dir_all(&atlas_dir)?;
12277        fs::write(
12278            temp.path().join("lib.rs"),
12279            "pub fn caller() { target(); }\npub fn target() {}\n",
12280        )?;
12281        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12282        let mut store =
12283            open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
12284        refresh_index(
12285            &mut store,
12286            &plan,
12287            &SymbolBuildOptions::new(1_024, Some(1), None),
12288        )?;
12289        let before = store
12290            .index_publication()?
12291            .ok_or_else(|| io::Error::other("initial publication missing"))?;
12292        let relation_query = RepositoryGraphRelationQuery::Family {
12293            relation: GraphRelationKind::Legacy(RelationKind::Calls),
12294        };
12295        require_eq(
12296            &store
12297                .repository_graph_relations(relation_query.clone(), 10)?
12298                .rows
12299                .len(),
12300            &1,
12301            "initial normalized call relation",
12302        )?;
12303
12304        run_symbol_build_pipeline(
12305            &mut store,
12306            &plan,
12307            &SymbolBuildOptions::new(1, Some(1), None),
12308            None,
12309        )?;
12310
12311        let after = store
12312            .index_publication()?
12313            .ok_or_else(|| io::Error::other("symbol publication missing"))?;
12314        require_eq(
12315            &after.generation,
12316            &before
12317                .generation
12318                .checked_next()
12319                .ok_or_else(|| io::Error::other("publication generation overflowed"))?,
12320            "symbol and graph publication generation",
12321        )?;
12322        require_eq(
12323            &store
12324                .repository_graph_relations(relation_query, 10)?
12325                .rows
12326                .len(),
12327            &0,
12328            "cleared symbol relation projection",
12329        )?;
12330        let project = store
12331            .project_instance_id()?
12332            .ok_or_else(|| io::Error::other("project identity missing"))?;
12333        let path = RepositoryNodePath::new(Path::new("lib.rs"))?;
12334        let entities = store.repository_graph_entities_by_path(project, &path, 10)?;
12335        require_eq(
12336            &entities
12337                .rows
12338                .iter()
12339                .all(|entity| entity.generation() == after.generation),
12340            &true,
12341            "symbol refresh normalized graph generation",
12342        )?;
12343        Ok(())
12344    }
12345
12346    #[test]
12347    fn unchanged_full_and_incremental_refreshes_do_not_advance_generation()
12348    -> Result<(), Box<dyn Error>> {
12349        let temp = tempfile::tempdir()?;
12350        let atlas_dir = temp.path().join(".projectatlas");
12351        fs::create_dir_all(&atlas_dir)?;
12352        let source_path = temp.path().join("lib.rs");
12353        fs::write(&source_path, "fn stable() {}\n")?;
12354        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12355        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12356        let mut store =
12357            open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
12358        refresh_index(&mut store, &plan, &symbol_options)?;
12359        let normal_read_plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
12360        verify_index_publication(&store, &normal_read_plan)?;
12361        let before = store
12362            .index_publication()?
12363            .ok_or_else(|| io::Error::other("initial publication missing"))?;
12364        let project = store
12365            .project_instance_id()?
12366            .ok_or_else(|| io::Error::other("project identity missing"))?;
12367        let abandoned_full_stage = atlas_dir.join("graph-stage-full-noop");
12368        fs::create_dir(&abandoned_full_stage)?;
12369        drop(AtlasStore::create_repository_graph_staging(
12370            &abandoned_full_stage.join("projectatlas.db"),
12371            &plan.root,
12372            project,
12373        )?);
12374        let full_report = refresh_index(&mut store, &plan, &symbol_options)?;
12375        let after_full = store
12376            .index_publication()?
12377            .ok_or_else(|| io::Error::other("publication missing after full no-op refresh"))?;
12378        require_eq(
12379            &after_full.generation,
12380            &before.generation,
12381            "full no-op publication generation",
12382        )?;
12383        require_eq(
12384            &full_report.text_index.candidates,
12385            &0,
12386            "full no-op text candidates",
12387        )?;
12388        require_eq(
12389            &full_report.structural_summaries.candidates,
12390            &0,
12391            "full no-op summary candidates",
12392        )?;
12393        require_eq(
12394            &full_report.symbols.candidates,
12395            &0,
12396            "full no-op symbol candidates",
12397        )?;
12398        require_eq(
12399            &full_report.symbols.max_workers,
12400            &0,
12401            "full no-op symbol workers",
12402        )?;
12403        require_eq(
12404            &abandoned_full_stage.exists(),
12405            &false,
12406            "full no-op abandoned graph stage",
12407        )?;
12408        let abandoned_incremental_stage = atlas_dir.join("graph-stage-incremental-noop");
12409        fs::create_dir(&abandoned_incremental_stage)?;
12410        drop(AtlasStore::create_repository_graph_staging(
12411            &abandoned_incremental_stage.join("projectatlas.db"),
12412            &plan.root,
12413            project,
12414        )?);
12415        let mut changes = WatchChangeSet::default();
12416        changes.paths.insert(source_path);
12417
12418        let report = refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
12419        let after = store
12420            .index_publication()?
12421            .ok_or_else(|| io::Error::other("publication missing after no-op refresh"))?;
12422
12423        require_eq(
12424            &after.generation,
12425            &before.generation,
12426            "no-op publication generation",
12427        )?;
12428        require_eq(&report.text_index.candidates, &0, "no-op text candidates")?;
12429        require_eq(
12430            &report.structural_summaries.candidates,
12431            &0,
12432            "no-op summary candidates",
12433        )?;
12434        require_eq(&report.symbols.candidates, &0, "no-op symbol candidates")?;
12435        require_eq(&report.symbols.max_workers, &0, "no-op symbol workers")?;
12436        require_eq(
12437            &abandoned_incremental_stage.exists(),
12438            &false,
12439            "incremental no-op abandoned graph stage",
12440        )?;
12441        Ok(())
12442    }
12443
12444    #[test]
12445    fn watcher_preserves_explicit_full_refresh_guidance() -> Result<(), Box<dyn Error>> {
12446        let temp = tempfile::tempdir()?;
12447        fs::write(temp.path().join("source.rs"), "pub fn source() {}\n")?;
12448        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12449        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12450        let database = temp.path().join(".projectatlas/projectatlas.db");
12451        let mut store = open_atlas_store_for_project(&database, &plan.root)?;
12452        refresh_index(&mut store, &plan, &symbol_options)?;
12453        let before = store.index_publication()?;
12454
12455        let error =
12456            run_watch_with_polling_fallback(&mut store, &plan, 0, 1, &symbol_options, |_| {
12457                Err(CliError::RefreshRequired(Box::new(
12458                    index_policy_refresh_required(&plan.root),
12459                )))
12460            })
12461            .err()
12462            .ok_or_else(|| {
12463                io::Error::other("full-refresh guidance was hidden by polling fallback")
12464            })?;
12465        let CliError::RefreshRequired(report) = error else {
12466            return Err(io::Error::other(format!(
12467                "unexpected watcher error after full-refresh guidance: {error:?}"
12468            ))
12469            .into());
12470        };
12471        require_eq(
12472            &report.scope,
12473            &IndexRefreshScope::Full,
12474            "watcher full-refresh scope",
12475        )?;
12476        require_eq(
12477            &store.index_publication()?,
12478            &before,
12479            "watcher generation after full-refresh guidance",
12480        )?;
12481        Ok(())
12482    }
12483
12484    #[test]
12485    fn canceled_watcher_batch_preserves_last_valid_and_retries_one_generation()
12486    -> Result<(), Box<dyn Error>> {
12487        let temp = tempfile::tempdir()?;
12488        let atlas_dir = temp.path().join(".projectatlas");
12489        fs::create_dir_all(&atlas_dir)?;
12490        let reserved_purpose_path = atlas_dir.join("projectatlas-nonsource-files.toon");
12491        fs::write(&reserved_purpose_path, "nonsource_files[]:\n")?;
12492        let changed_path = temp.path().join("changed.rs");
12493        let deleted_path = temp.path().join("deleted.rs");
12494        let deleted_dir = temp.path().join("deleted");
12495        let deleted_descendant = deleted_dir.join("descendant.rs");
12496        fs::create_dir(&deleted_dir)?;
12497        fs::write(&changed_path, "pub fn before() {}\n")?;
12498        fs::write(&deleted_path, "pub fn removed() {}\n")?;
12499        fs::write(&deleted_descendant, "pub fn descendant() {}\n")?;
12500        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12501        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12502        let db_path = atlas_dir.join("projectatlas.db");
12503        let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
12504        refresh_index(&mut store, &plan, &symbol_options)?;
12505        let before = store
12506            .index_publication()?
12507            .ok_or_else(|| io::Error::other("initial publication missing"))?;
12508        let before_node = store
12509            .load_node_by_path("changed.rs")?
12510            .ok_or_else(|| io::Error::other("initial changed node missing"))?;
12511        let reviewed_reserved_purpose = "Describe reviewed non-source atlas responsibilities.";
12512        store.set_purpose(
12513            ".projectatlas/projectatlas-nonsource-files.toon",
12514            reviewed_reserved_purpose,
12515            PurposeSource::Agent,
12516        )?;
12517        let old_reader = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
12518        require_eq(
12519            &old_reader
12520                .index_publication()?
12521                .as_ref()
12522                .map(|state| state.generation),
12523            &Some(before.generation),
12524            "old reader generation before staged publication",
12525        )?;
12526        let old_text = old_reader
12527            .load_file_text("changed.rs")?
12528            .ok_or_else(|| io::Error::other("old reader text missing"))?;
12529
12530        fs::write(&changed_path, "pub fn after() {}\n")?;
12531        fs::remove_file(&deleted_path)?;
12532        fs::remove_dir_all(&deleted_dir)?;
12533        fs::write(&reserved_purpose_path, "nonsource_files[]:\n\n")?;
12534        let preparation_control = standalone_index_work_control();
12535        let staged_batch = stage_full_index_publication(
12536            &store,
12537            &plan,
12538            &symbol_options,
12539            true,
12540            false,
12541            &preparation_control,
12542        )?;
12543        revalidate_staged_publication_inputs_controlled(
12544            &plan,
12545            staged_batch.nodes.expected_nodes(),
12546            None,
12547            &preparation_control,
12548        )?;
12549        let IndexPublicationBatch {
12550            base_generation,
12551            contract_fingerprint,
12552            root,
12553            nodes,
12554            purpose_import: _,
12555            text_paths,
12556            text,
12557            content_classifications,
12558            symbols: _,
12559            graph: _,
12560            structural_summaries: _,
12561        } = staged_batch;
12562        let mut staged =
12563            store.begin_index_publication_from(&contract_fingerprint, base_generation)?;
12564        staged.set_project_root(&root)?;
12565        let NodePublicationBatch::Full { nodes } = nodes else {
12566            return Err(io::Error::other("full staging returned an incremental batch").into());
12567        };
12568        staged.begin_scan_replacement()?;
12569        for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
12570            staged.upsert_scan_node_batch(batch)?;
12571        }
12572        apply_file_content_classification_stage(
12573            &mut staged,
12574            &content_classifications,
12575            &preparation_control,
12576        )?;
12577        staged.finish_scan_replacement()?;
12578        let late_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
12579        late_cancel.cancel();
12580        let late_result = apply_text_index_stage(&mut staged, &text_paths, &text, &late_cancel);
12581        if !matches!(
12582            late_result,
12583            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
12584                stage: IndexWorkStage::Publication,
12585            }))
12586        ) {
12587            return Err(io::Error::other("late cancellation did not stop publication").into());
12588        }
12589        drop(staged);
12590        require_eq(
12591            &store
12592                .index_publication()?
12593                .as_ref()
12594                .map(|state| state.generation),
12595            &Some(before.generation),
12596            "generation after canceled publication",
12597        )?;
12598        require_eq(
12599            &store.load_node_by_path("changed.rs")?,
12600            &Some(before_node),
12601            "last-valid node after canceled publication",
12602        )?;
12603        let mut changes = WatchChangeSet::default();
12604        changes.paths.insert(changed_path);
12605        changes.paths.insert(deleted_path);
12606        changes.paths.insert(deleted_dir);
12607        changes.paths.insert(deleted_descendant);
12608        changes.paths.insert(reserved_purpose_path);
12609        let fallback_report =
12610            run_watch_with_polling_fallback(&mut store, &plan, 0, 1, &symbol_options, |store| {
12611                let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
12612                canceled.cancel();
12613                let Err(error) = refresh_index_for_changes_controlled(
12614                    store,
12615                    &plan,
12616                    &changes,
12617                    &symbol_options,
12618                    &canceled,
12619                ) else {
12620                    return Err(CliError::InvalidInput(
12621                        "canceled watcher batch unexpectedly succeeded".to_string(),
12622                    ));
12623                };
12624                let generation = store.index_publication()?.map(|state| state.generation);
12625                if generation != Some(before.generation) {
12626                    return Err(CliError::InvalidInput(
12627                        "canceled watcher batch advanced publication before fallback".to_string(),
12628                    ));
12629                }
12630                Err(error)
12631            })?;
12632        require_eq(
12633            &fallback_report.mode.as_str(),
12634            &WATCH_MODE_POLLING,
12635            "canceled notify batch fallback mode",
12636        )?;
12637        if fallback_report
12638            .fallback_reason
12639            .as_deref()
12640            .is_none_or(|reason| !reason.contains("canceled"))
12641        {
12642            return Err(io::Error::other(
12643                "polling fallback did not retain the notify failure reason",
12644            )
12645            .into());
12646        }
12647
12648        let after = store
12649            .index_publication()?
12650            .ok_or_else(|| io::Error::other("incremental publication missing"))?;
12651        require_eq(
12652            &after.generation,
12653            &before
12654                .generation
12655                .checked_next()
12656                .ok_or_else(|| io::Error::other("test generation overflowed"))?,
12657            "one incremental publication generation",
12658        )?;
12659        require_eq(
12660            &store.load_node_by_path("deleted.rs")?.is_none(),
12661            &true,
12662            "deleted path is absent",
12663        )?;
12664        require_eq(
12665            &store
12666                .load_symbols(Some("deleted.rs"), Some("removed"), 10)?
12667                .is_empty(),
12668            &true,
12669            "deleted symbols are invalidated",
12670        )?;
12671        require_eq(
12672            &store.load_node_by_path("deleted/descendant.rs")?.is_none(),
12673            &true,
12674            "deleted descendant path is absent",
12675        )?;
12676        require_eq(
12677            &store
12678                .load_symbols(Some("deleted/descendant.rs"), Some("descendant"), 10)?
12679                .is_empty(),
12680            &true,
12681            "deleted descendant symbols are invalidated",
12682        )?;
12683        require_eq(
12684            &store
12685                .load_symbols(Some("changed.rs"), Some("after"), 10)?
12686                .len(),
12687            &1,
12688            "changed symbols are published",
12689        )?;
12690        require_eq(
12691            &store
12692                .load_symbols(Some("changed.rs"), Some("before"), 10)?
12693                .is_empty(),
12694            &true,
12695            "replaced symbols are invalidated",
12696        )?;
12697        let reserved = store
12698            .load_node_by_path(".projectatlas/projectatlas-nonsource-files.toon")?
12699            .ok_or_else(|| io::Error::other("reserved metadata node missing"))?;
12700        require_eq(
12701            &reserved.purpose.purpose.as_deref(),
12702            &Some(reviewed_reserved_purpose),
12703            "stale reviewed built-in purpose text",
12704        )?;
12705        require_eq(
12706            &reserved.purpose.status,
12707            &PurposeStatus::Approved,
12708            "reviewed built-in purpose state",
12709        )?;
12710        require_eq(
12711            &old_reader
12712                .index_publication()?
12713                .as_ref()
12714                .map(|state| state.generation),
12715            &Some(before.generation),
12716            "old reader remains on the complete prior generation",
12717        )?;
12718        require_eq(
12719            &old_reader
12720                .load_file_text("changed.rs")?
12721                .map(|text| text.content),
12722            &Some(old_text.content),
12723            "old reader remains on prior source text",
12724        )?;
12725        let new_reader = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
12726        require_eq(
12727            &new_reader
12728                .index_publication()?
12729                .as_ref()
12730                .map(|state| state.generation),
12731            &Some(after.generation),
12732            "new reader sees the complete replacement generation",
12733        )?;
12734        require_eq(
12735            &new_reader
12736                .load_file_text("changed.rs")?
12737                .map(|text| text.content),
12738            &Some("pub fn after() {}\n".to_string()),
12739            "new reader sees replacement source text",
12740        )?;
12741        new_reader.finish_index_read_snapshot()?;
12742        old_reader.finish_index_read_snapshot()?;
12743        Ok(())
12744    }
12745
12746    #[test]
12747    fn one_sided_notify_rename_events_require_full_verification() -> Result<(), Box<dyn Error>> {
12748        let temp = tempfile::tempdir()?;
12749        let old_path = temp.path().join("old.rs");
12750        let new_path = temp.path().join("new.rs");
12751        for (mode, path) in [
12752            (notify::event::RenameMode::From, old_path),
12753            (notify::event::RenameMode::To, new_path),
12754        ] {
12755            let event =
12756                Event::new(EventKind::Modify(notify::event::ModifyKind::Name(mode))).add_path(path);
12757            let changes = notify_event_changes(temp.path(), &ScanOptions::default(), &event);
12758            require_eq(
12759                &changes.requires_full_scan,
12760                &true,
12761                "one-sided rename full verification",
12762            )?;
12763        }
12764        Ok(())
12765    }
12766
12767    #[test]
12768    fn notify_rescan_and_ignored_policy_events_require_full_verification()
12769    -> Result<(), Box<dyn Error>> {
12770        let temp = tempfile::tempdir()?;
12771        fs::create_dir_all(temp.path().join(".projectatlas"))?;
12772        fs::write(temp.path().join(".gitignore"), ".projectatlas/\n")?;
12773        let config = temp.path().join(".projectatlas/config.toml");
12774        fs::write(&config, "[project]\nroot = \".\"\n")?;
12775        let config_event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
12776            notify::event::DataChange::Content,
12777        )))
12778        .add_path(config.clone());
12779        let config_changes =
12780            notify_event_changes(temp.path(), &ScanOptions::default(), &config_event);
12781        require_eq(
12782            &config_changes.requires_full_scan,
12783            &true,
12784            "ignored ProjectAtlas config full verification",
12785        )?;
12786        require_eq(
12787            &config_changes.paths.contains(&config),
12788            &true,
12789            "ignored ProjectAtlas config event path",
12790        )?;
12791
12792        let rescan_event = Event::new(EventKind::Any).set_flag(notify::event::Flag::Rescan);
12793        let rescan_changes =
12794            notify_event_changes(temp.path(), &ScanOptions::default(), &rescan_event);
12795        require_eq(
12796            &rescan_changes.requires_full_scan,
12797            &true,
12798            "backend rescan flag full verification",
12799        )?;
12800        Ok(())
12801    }
12802
12803    #[test]
12804    fn nonindexed_document_target_transitions_refresh_the_inbound_closure()
12805    -> Result<(), Box<dyn Error>> {
12806        let temp = tempfile::tempdir()?;
12807        let docs = temp.path().join("docs");
12808        fs::create_dir_all(&docs)?;
12809        fs::write(temp.path().join(".gitignore"), "!docs/target.md\n")?;
12810        fs::write(docs.join("guide.md"), "# Guide\n\n[target](target.md)\n")?;
12811        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12812        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12813        let mut store = open_atlas_store_for_project(
12814            &temp.path().join(".projectatlas/projectatlas.db"),
12815            &plan.root,
12816        )?;
12817        refresh_index(&mut store, &plan, &symbol_options)?;
12818        let documents = GraphRelationKind::Extended(ExtendedRelationKind::Documents);
12819        let reason = |store: &AtlasStore| -> Result<_, Box<dyn Error>> {
12820            let page = store.repository_graph_relation_rows(
12821                RepositoryGraphRelationQuery::Family {
12822                    relation: documents,
12823                },
12824                8,
12825                None,
12826            )?;
12827            require_eq(&page.rows.len(), &1, "document relation count")?;
12828            Ok(page.rows[0].document_unresolved_reason)
12829        };
12830        require_eq(
12831            &reason(&store)?,
12832            &Some(DocumentTargetUnresolvedReason::Missing),
12833            "initial missing document target",
12834        )?;
12835
12836        let outside = tempfile::tempdir()?;
12837        let outside_target = outside.path().join("target.md");
12838        fs::write(&outside_target, "outside\n")?;
12839        let target = docs.join("target.md");
12840        if !create_document_target_symlink(&outside_target, &target)? {
12841            return Ok(());
12842        }
12843        let mut changes = WatchChangeSet::default();
12844        changes.document_paths.insert(target.clone());
12845        refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
12846        require_eq(
12847            &reason(&store)?,
12848            &Some(DocumentTargetUnresolvedReason::OutsideRoot),
12849            "missing-to-outside-symlink document target",
12850        )?;
12851
12852        fs::remove_file(&target)?;
12853        refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
12854        require_eq(
12855            &reason(&store)?,
12856            &Some(DocumentTargetUnresolvedReason::Missing),
12857            "outside-symlink-to-missing document target",
12858        )?;
12859        Ok(())
12860    }
12861
12862    #[cfg(unix)]
12863    fn create_document_target_symlink(target: &Path, link: &Path) -> io::Result<bool> {
12864        std::os::unix::fs::symlink(target, link)?;
12865        Ok(true)
12866    }
12867
12868    #[cfg(windows)]
12869    fn create_document_target_symlink(target: &Path, link: &Path) -> io::Result<bool> {
12870        match std::os::windows::fs::symlink_file(target, link) {
12871            Ok(()) => Ok(true),
12872            Err(source)
12873                if source.kind() == io::ErrorKind::PermissionDenied
12874                    || source.raw_os_error() == Some(1314) =>
12875            {
12876                Ok(false)
12877            }
12878            Err(source) => Err(source),
12879        }
12880    }
12881
12882    #[test]
12883    fn directory_only_deletion_re_resolves_external_inbound_callers() -> Result<(), Box<dyn Error>>
12884    {
12885        let temp = tempfile::tempdir()?;
12886        let atlas_dir = temp.path().join(".projectatlas");
12887        let source_dir = temp.path().join("src");
12888        let removed_dir = source_dir.join("removed");
12889        fs::create_dir_all(&atlas_dir)?;
12890        fs::create_dir_all(&removed_dir)?;
12891        fs::write(
12892            source_dir.join("caller.rs"),
12893            "pub fn caller() { target(); }\n",
12894        )?;
12895        fs::write(removed_dir.join("target.rs"), "pub fn target() {}\n")?;
12896
12897        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12898        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12899        let mut store =
12900            open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
12901        refresh_index(&mut store, &plan, &symbol_options)?;
12902        let before = store
12903            .index_publication()?
12904            .ok_or_else(|| io::Error::other("initial publication missing"))?;
12905        require_caller_resolution(
12906            &store,
12907            "src/caller.rs",
12908            |resolution| matches!(resolution, RelationResolution::Resolved { .. }),
12909            "initial caller resolution",
12910        )?;
12911
12912        fs::remove_dir_all(&removed_dir)?;
12913        let mut changes = WatchChangeSet::default();
12914        changes.paths.insert(removed_dir);
12915        refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
12916
12917        let after = store
12918            .index_publication()?
12919            .ok_or_else(|| io::Error::other("replacement publication missing"))?;
12920        require_eq(
12921            &after.generation,
12922            &before
12923                .generation
12924                .checked_next()
12925                .ok_or_else(|| io::Error::other("test generation overflowed"))?,
12926            "directory deletion generation",
12927        )?;
12928        require_eq(
12929            &store.load_node_by_path("src/removed/target.rs")?.is_none(),
12930            &true,
12931            "deleted descendant node",
12932        )?;
12933        require_eq(
12934            &store
12935                .load_symbols(Some("src/removed/target.rs"), Some("target"), 10)?
12936                .is_empty(),
12937            &true,
12938            "deleted descendant symbol",
12939        )?;
12940        require_caller_resolution(
12941            &store,
12942            "src/caller.rs",
12943            |resolution| matches!(resolution, RelationResolution::Unresolved { .. }),
12944            "caller resolution after directory deletion",
12945        )?;
12946        Ok(())
12947    }
12948
12949    fn require_caller_resolution(
12950        store: &AtlasStore,
12951        caller_path: &str,
12952        expected: impl FnOnce(&RelationResolution) -> bool,
12953        label: &str,
12954    ) -> Result<(), Box<dyn Error>> {
12955        let project = store
12956            .project_instance_id()?
12957            .ok_or_else(|| io::Error::other("project identity missing"))?;
12958        let caller_path = RepositoryNodePath::new(Path::new(caller_path))?;
12959        let caller_entities =
12960            store.repository_graph_entities_by_path(project, &caller_path, 100)?;
12961        let relations = store.repository_graph_relations(
12962            RepositoryGraphRelationQuery::Family {
12963                relation: GraphRelationKind::Legacy(RelationKind::Calls),
12964            },
12965            100,
12966        )?;
12967        let mut matching = relations.rows.iter().filter(|relation| {
12968            caller_entities
12969                .rows
12970                .iter()
12971                .any(|entity| entity.key() == relation.source())
12972        });
12973        let relation = matching
12974            .next()
12975            .ok_or_else(|| io::Error::other(format!("{label}: caller relation missing")))?;
12976        if matching.next().is_some() {
12977            return Err(io::Error::other(format!("{label}: multiple caller relations")).into());
12978        }
12979        if !expected(relation.resolution()) {
12980            return Err(io::Error::other(format!(
12981                "{label}: unexpected resolution {:?}",
12982                relation.resolution()
12983            ))
12984            .into());
12985        }
12986        Ok(())
12987    }
12988
12989    #[test]
12990    fn purpose_curator_handoff_applies_one_stale_safe_batch() -> Result<(), Box<dyn Error>> {
12991        let temp = tempfile::tempdir()?;
12992        let root = temp.path().join("repository");
12993        fs::create_dir(&root)?;
12994        let database = temp.path().join("projectatlas.db");
12995        let mut store = AtlasStore::open_for_project(&database, &root)?;
12996        store.replace_scan(&[
12997            Node {
12998                path: "src/main.rs".to_string(),
12999                kind: NodeKind::File,
13000                parent_path: Some("src".to_string()),
13001                extension: Some(".rs".to_string()),
13002                language: Some("rust".to_string()),
13003                size_bytes: Some(12),
13004                mtime_ns: Some(10),
13005                content_hash: Some("hash-main".to_string()),
13006            },
13007            Node {
13008                path: "src/detail.rs".to_string(),
13009                kind: NodeKind::File,
13010                parent_path: Some("src".to_string()),
13011                extension: Some(".rs".to_string()),
13012                language: Some("rust".to_string()),
13013                size_bytes: Some(12),
13014                mtime_ns: Some(10),
13015                content_hash: Some("hash-detail".to_string()),
13016            },
13017        ])?;
13018        store.set_suggested_purpose("src/detail.rs", "Generated detail suggestion")?;
13019        let task = "runtime-purpose-curator";
13020        let page = purpose_curation_page(
13021            &store,
13022            &HealthQuery {
13023                start_index: 0,
13024                limit: 20,
13025                category: None,
13026                severity: Some(Severity::Warning),
13027                path_prefix: None,
13028                summary_only: false,
13029                scope: HealthScope::all(),
13030            },
13031            task,
13032        )?;
13033        require_eq(&page.actionable, &true, "actionable queue")?;
13034        require_eq(&page.items.len(), &2, "queue item count")?;
13035        require_eq(&page.task, &task.to_string(), "queue task")?;
13036        require_eq(
13037            &page
13038                .items
13039                .iter()
13040                .all(|item| item.classification == Some(ContentClassification::Opaque)),
13041            &true,
13042            "queue file classifications",
13043        )?;
13044        let requests = page
13045            .items
13046            .iter()
13047            .map(|item| PurposeReviewRequest {
13048                path: item.path.clone(),
13049                purpose: Some(format!("Reviewed purpose for {}", item.path)),
13050                confirm_existing: false,
13051                task: Some(page.task.clone()),
13052                work_key: Some(item.work_key.clone()),
13053                state_token: Some(item.state_token.clone()),
13054            })
13055            .collect::<Vec<_>>();
13056        let handoff = purpose_curator_handoff(page);
13057        require_eq(
13058            &handoff.execution_owner,
13059            &"agent_host",
13060            "host-owned execution",
13061        )?;
13062        require_eq(
13063            &handoff.server_started_curator,
13064            &false,
13065            "no server-started curator",
13066        )?;
13067        require_eq(
13068            &handoff.recommended_subagent_reasoning,
13069            &PURPOSE_CURATOR_RECOMMENDED_REASONING,
13070            "lowest reliable host-supported reasoning",
13071        )?;
13072        require_eq(
13073            &handoff.instructions.first().is_some_and(|instruction| {
13074                instruction.contains("lowest reliable reasoning and cost tier the host supports")
13075            }),
13076            &true,
13077            "purpose handoff omitted the reliable reasoning and cost qualification",
13078        )?;
13079        require_eq(&handoff.main_agent_fallback, &true, "main-agent fallback")?;
13080
13081        let mixed = vec![
13082            requests[0].clone(),
13083            PurposeReviewRequest {
13084                path: requests[1].path.clone(),
13085                purpose: Some("Explicit correction must stay separate".to_string()),
13086                confirm_existing: false,
13087                task: None,
13088                work_key: None,
13089                state_token: None,
13090            },
13091        ];
13092        match review_purposes(&store, &mixed, true) {
13093            Err(CliError::InvalidInput(_)) => {}
13094            Err(error) => {
13095                return Err(io::Error::other(format!(
13096                    "mixed purpose batch returned the wrong error: {error}"
13097                ))
13098                .into());
13099            }
13100            Ok(_) => return Err(io::Error::other("mixed purpose batch was accepted").into()),
13101        }
13102        let mut partial = requests[1].clone();
13103        partial.state_token = None;
13104        match review_purposes(&store, &[requests[0].clone(), partial], true) {
13105            Err(CliError::InvalidInput(_)) => {}
13106            Err(error) => {
13107                return Err(io::Error::other(format!(
13108                    "partial conditional batch returned the wrong error: {error}"
13109                ))
13110                .into());
13111            }
13112            Ok(_) => {
13113                return Err(io::Error::other("partial conditional batch was accepted").into());
13114            }
13115        }
13116        drop(store);
13117        store = AtlasStore::open_for_project(&database, &root)?;
13118        let unchanged =
13119            store.load_nodes_by_paths(&["src/main.rs".to_string(), "src/detail.rs".to_string()])?;
13120        require_eq(
13121            &unchanged.iter().all(|node| !node.purpose.agent_reviewed()),
13122            &true,
13123            "rejected batch left every purpose unapproved after reopen",
13124        )?;
13125
13126        let applied = review_purposes(&store, &requests, true)?;
13127        require_eq(&applied.changed, &2, "conditional batch changed count")?;
13128        require_eq(&applied.conflicts, &0, "conditional batch conflicts")?;
13129        require_eq(
13130            &applied
13131                .items
13132                .iter()
13133                .all(|item| item.classification == Some(ContentClassification::Opaque)),
13134            &true,
13135            "purpose review classifications",
13136        )?;
13137        require_eq(
13138            &applied
13139                .items
13140                .iter()
13141                .all(|item| item.action == PurposeReviewAction::Review),
13142            &true,
13143            "conditional batch actions",
13144        )?;
13145
13146        let repeated = review_purposes(&store, &requests, true)?;
13147        require_eq(&repeated.changed, &0, "accepted repeat changed count")?;
13148        require_eq(&repeated.conflicts, &2, "accepted repeat conflicts")?;
13149        require_eq(
13150            &repeated
13151                .items
13152                .iter()
13153                .all(|item| item.action == PurposeReviewAction::Accepted),
13154            &true,
13155            "accepted repeat actions",
13156        )?;
13157        let empty = purpose_curation_page(
13158            &store,
13159            &HealthQuery {
13160                start_index: 0,
13161                limit: 20,
13162                category: None,
13163                severity: Some(Severity::Warning),
13164                path_prefix: None,
13165                summary_only: false,
13166                scope: HealthScope::all(),
13167            },
13168            task,
13169        )?;
13170        require_eq(&empty.actionable, &false, "accepted queue is quiet")?;
13171
13172        let correction = review_purposes(
13173            &store,
13174            &[PurposeReviewRequest {
13175                path: "src/main.rs".to_string(),
13176                purpose: Some("Explicit corrected purpose".to_string()),
13177                confirm_existing: false,
13178                task: None,
13179                work_key: None,
13180                state_token: None,
13181            }],
13182            true,
13183        )?;
13184        require_eq(
13185            &correction.items[0].action,
13186            &PurposeReviewAction::Review,
13187            "explicit correction action",
13188        )?;
13189        let corrected = store
13190            .load_node_by_path("src/main.rs")?
13191            .ok_or_else(|| io::Error::other("corrected runtime path disappeared"))?;
13192        require_eq(
13193            &corrected.purpose.purpose.as_deref(),
13194            &Some("Explicit corrected purpose"),
13195            "explicit correction value",
13196        )?;
13197        Ok(())
13198    }
13199
13200    #[test]
13201    fn purpose_review_admission_bounds_input_and_prevents_partial_apply()
13202    -> Result<(), Box<dyn Error>> {
13203        let temp = tempfile::tempdir()?;
13204        let root = temp.path().join("repository");
13205        fs::create_dir(&root)?;
13206        let database = temp.path().join("projectatlas.db");
13207        let mut store = AtlasStore::open_for_project(&database, &root)?;
13208        store.replace_scan(&[
13209            Node {
13210                path: "src/first.rs".to_string(),
13211                kind: NodeKind::File,
13212                parent_path: Some("src".to_string()),
13213                extension: Some(".rs".to_string()),
13214                language: Some("rust".to_string()),
13215                size_bytes: Some(12),
13216                mtime_ns: Some(10),
13217                content_hash: Some("hash-first".to_string()),
13218            },
13219            Node {
13220                path: "src/second.rs".to_string(),
13221                kind: NodeKind::File,
13222                parent_path: Some("src".to_string()),
13223                extension: Some(".rs".to_string()),
13224                language: Some("rust".to_string()),
13225                size_bytes: Some(12),
13226                mtime_ns: Some(10),
13227                content_hash: Some("hash-second".to_string()),
13228            },
13229        ])?;
13230        store.set_suggested_purpose("src/first.rs", "Generated first purpose")?;
13231        store.set_purpose(
13232            "src/second.rs",
13233            &"x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES + 1),
13234            PurposeSource::Imported,
13235        )?;
13236
13237        let valid_first = PurposeReviewRequest {
13238            path: "src/first.rs".to_string(),
13239            purpose: Some("Reviewed café λ purpose".to_string()),
13240            confirm_existing: false,
13241            task: None,
13242            work_key: None,
13243            state_token: None,
13244        };
13245        let oversized_report = PurposeReviewRequest {
13246            path: "src/second.rs".to_string(),
13247            purpose: None,
13248            confirm_existing: true,
13249            task: None,
13250            work_key: None,
13251            state_token: None,
13252        };
13253        let Err(error) = review_purposes(&store, &[valid_first.clone(), oversized_report], true)
13254        else {
13255            return Err(io::Error::other(
13256                "oversized retained report field was accepted before apply",
13257            )
13258            .into());
13259        };
13260        require_eq(
13261            &error
13262                .to_string()
13263                .contains("purpose review report field purpose"),
13264            &true,
13265            "oversized report field error",
13266        )?;
13267        let unchanged = store
13268            .load_node_by_path("src/first.rs")?
13269            .ok_or_else(|| io::Error::other("first review fixture disappeared"))?;
13270        require_eq(
13271            &unchanged.purpose.agent_reviewed(),
13272            &false,
13273            "report admission failure prevented partial apply",
13274        )?;
13275
13276        let oversized_field = PurposeReviewRequest {
13277            purpose: Some("x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES + 1)),
13278            ..valid_first.clone()
13279        };
13280        let Err(error) = review_purposes(&store, &[oversized_field], true) else {
13281            return Err(io::Error::other("oversized request field passed admission").into());
13282        };
13283        require_eq(
13284            &error.to_string().contains("field purpose"),
13285            &true,
13286            "oversized input field error",
13287        )?;
13288
13289        let too_many = vec![valid_first.clone(); MAX_PURPOSE_CURATION_BATCH_ROWS + 1];
13290        let Err(error) = review_purposes(&store, &too_many, true) else {
13291            return Err(io::Error::other("oversized request count passed admission").into());
13292        };
13293        require_eq(
13294            &error.to_string().contains("maximum is 200"),
13295            &true,
13296            "oversized item count error",
13297        )?;
13298
13299        let aggregate = (0..9)
13300            .map(|index| PurposeReviewRequest {
13301                path: format!("src/{index}.rs"),
13302                purpose: Some("x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES)),
13303                confirm_existing: false,
13304                task: None,
13305                work_key: None,
13306                state_token: None,
13307            })
13308            .collect::<Vec<_>>();
13309        let Err(error) = review_purposes(&store, &aggregate, false) else {
13310            return Err(io::Error::other("oversized aggregate request passed admission").into());
13311        };
13312        require_eq(
13313            &error.to_string().contains("aggregate string bytes"),
13314            &true,
13315            "oversized aggregate input error",
13316        )?;
13317
13318        let preview = review_purposes(&store, &[valid_first], false)?;
13319        require_eq(
13320            &preview.items[0].purpose,
13321            &"Reviewed café λ purpose".to_string(),
13322            "UTF-8 purpose compatibility",
13323        )?;
13324        require_eq(
13325            &render_purpose_review_report(&preview).contains("Reviewed café λ purpose"),
13326            &true,
13327            "UTF-8 TOON compatibility",
13328        )?;
13329        Ok(())
13330    }
13331
13332    #[test]
13333    fn indexed_navigation_read_rejects_stale_or_oversized_source_before_allocation()
13334    -> Result<(), Box<dyn Error>> {
13335        let temp = tempfile::tempdir()?;
13336        let root = temp.path().join("repository");
13337        let source_dir = root.join("src");
13338        fs::create_dir_all(&source_dir)?;
13339        let source = source_dir.join("large.rs");
13340        fs::File::create(&source)?.set_len(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1)?;
13341        let database = temp.path().join("projectatlas.db");
13342        let mut store = AtlasStore::open_for_project(&database, &root)?;
13343        store.replace_scan(&[Node {
13344            path: "src/large.rs".to_string(),
13345            kind: NodeKind::File,
13346            parent_path: Some("src".to_string()),
13347            extension: Some(".rs".to_string()),
13348            language: Some("rust".to_string()),
13349            size_bytes: Some(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1),
13350            mtime_ns: Some(10),
13351            content_hash: Some("unused-oversized-hash".to_string()),
13352        }])?;
13353
13354        let Err(error) = read_indexed_file_content(&store, "src/large.rs") else {
13355            return Err(
13356                io::Error::other("oversized indexed source was allocated and accepted").into(),
13357            );
13358        };
13359        let CliError::VerificationIncomplete(details) = error else {
13360            return Err(io::Error::other("oversized source returned the wrong error type").into());
13361        };
13362        require_eq(
13363            &details.reason,
13364            &IndexVerificationReason::SourceTooLarge,
13365            "oversized source reason",
13366        )?;
13367
13368        fs::File::create(&source)?.set_len(1)?;
13369        let Err(error) = read_indexed_file_content(&store, "src/large.rs") else {
13370            return Err(io::Error::other("changed source size did not require refresh").into());
13371        };
13372        let CliError::RefreshRequired(details) = error else {
13373            return Err(
13374                io::Error::other("changed source size returned the wrong error type").into(),
13375            );
13376        };
13377        require_eq(
13378            &details.reason,
13379            &IndexRefreshReason::SourceChanged,
13380            "changed source size reason",
13381        )?;
13382        Ok(())
13383    }
13384
13385    #[cfg(windows)]
13386    #[test]
13387    fn extended_windows_watch_roots_keep_deleted_paths_in_scope() -> Result<(), Box<dyn Error>> {
13388        let root = Path::new(r"\\?\C:\repo");
13389        let deleted = Path::new(r"C:\repo\src\deleted.rs");
13390        require_eq(
13391            &normalized_deleted_path(root, deleted)?,
13392            &Some("src/deleted.rs".to_string()),
13393            "extended root deleted path",
13394        )?;
13395        Ok(())
13396    }
13397
13398    #[cfg(windows)]
13399    #[test]
13400    fn notify_events_normalize_unicode_paths_against_extended_windows_roots()
13401    -> Result<(), Box<dyn Error>> {
13402        let temp = tempfile::tempdir()?;
13403        let source_dir = temp.path().join("src");
13404        fs::create_dir(&source_dir)?;
13405        let deleted = source_dir.join("Über.rs");
13406        fs::write(&deleted, "pub fn before() {}\n")?;
13407        fs::remove_file(&deleted)?;
13408
13409        let root_text = temp
13410            .path()
13411            .to_str()
13412            .ok_or_else(|| io::Error::other("temporary path is not UTF-8"))?;
13413        let extended_root = if root_text.starts_with(r"\\?\") {
13414            temp.path().to_path_buf()
13415        } else {
13416            PathBuf::from(format!(r"\\?\{root_text}"))
13417        };
13418        let event = Event::new(EventKind::Remove(notify::event::RemoveKind::File))
13419            .add_path(deleted.clone());
13420        let changes = notify_event_changes(&extended_root, &ScanOptions::default(), &event);
13421
13422        require_eq(
13423            &changes.paths.contains(&deleted),
13424            &true,
13425            "native Unicode watcher path",
13426        )?;
13427        require_eq(
13428            &changes.requires_full_scan,
13429            &false,
13430            "source removal full-scan policy",
13431        )?;
13432        require_eq(
13433            &normalized_deleted_path(&extended_root, &deleted)?,
13434            &Some("src/Über.rs".to_string()),
13435            "normalized Unicode deleted path",
13436        )?;
13437        Ok(())
13438    }
13439
13440    #[cfg(unix)]
13441    #[test]
13442    fn notify_events_preserve_native_backslash_paths_on_unix() -> Result<(), Box<dyn Error>> {
13443        let temp = tempfile::tempdir()?;
13444        let source = temp.path().join(r"src\generated.rs");
13445        fs::write(&source, "pub fn generated() {}\n")?;
13446        let event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
13447            notify::event::DataChange::Content,
13448        )))
13449        .add_path(source.clone());
13450
13451        let changes = notify_event_changes(temp.path(), &ScanOptions::default(), &event);
13452
13453        require_eq(
13454            &changes.paths,
13455            &HashSet::from([source]),
13456            "native Unix watcher paths",
13457        )?;
13458        Ok(())
13459    }
13460
13461    fn runtime_pdf_fixture() -> Vec<u8> {
13462        let objects = [
13463            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".as_slice(),
13464            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n".as_slice(),
13465            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(),
13466            b"4 0 obj\n<< /Length 42 >>\nstream\nBT /F1 12 Tf 72 720 Td (Runtime PDF) Tj ET\nendstream\nendobj\n".as_slice(),
13467            b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n".as_slice(),
13468        ];
13469        let mut pdf = b"%PDF-1.4\n".to_vec();
13470        let mut offsets = Vec::new();
13471        for object in objects {
13472            offsets.push(pdf.len());
13473            pdf.extend_from_slice(object);
13474        }
13475        let xref = pdf.len();
13476        pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
13477        pdf.extend_from_slice(b"0000000000 65535 f \n");
13478        for offset in offsets {
13479            pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
13480        }
13481        pdf.extend_from_slice(
13482            format!(
13483                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
13484                objects.len() + 1
13485            )
13486            .as_bytes(),
13487        );
13488        pdf
13489    }
13490
13491    #[test]
13492    fn document_input_does_not_consume_retained_text_capacity() -> Result<(), Box<dyn Error>> {
13493        let temp = tempfile::tempdir()?;
13494        let pdf = runtime_pdf_fixture();
13495        let mut nodes = Vec::new();
13496        for (path, language, bytes) in [
13497            ("seed.txt", "text", b"seed".as_slice()),
13498            ("guide.pdf", "pdf", pdf.as_slice()),
13499        ] {
13500            fs::write(temp.path().join(path), bytes)?;
13501            nodes.push(Node {
13502                path: path.to_owned(),
13503                kind: NodeKind::File,
13504                parent_path: None,
13505                extension: None,
13506                language: Some(language.to_owned()),
13507                size_bytes: Some(bytes.len() as u64),
13508                mtime_ns: Some(1),
13509                content_hash: Some(blake3::hash(bytes).to_hex().to_string()),
13510            });
13511        }
13512        let control = standalone_index_work_control();
13513        for known_size in [true, false] {
13514            nodes[1].size_bytes = known_size.then_some(pdf.len() as u64);
13515            let rows = indexed_file_texts_for_nodes_with_limit(
13516                temp.path(),
13517                &nodes,
13518                TextIndexOptions::new(64),
13519                15,
13520                &control,
13521            )?;
13522            if rows.len() != 2
13523                || rows[1].text.as_ref().map(|text| text.content.as_str()) != Some("Runtime PDF")
13524            {
13525                return Err(
13526                    io::Error::other("document input consumed retained-text capacity").into(),
13527                );
13528            }
13529            if !matches!(
13530                indexed_file_texts_for_nodes_with_limit(
13531                    temp.path(),
13532                    &nodes,
13533                    TextIndexOptions::new(64),
13534                    14,
13535                    &control,
13536                ),
13537                Err(CliError::IndexWork(
13538                    IndexWorkFailure::ResourceLimitExceeded {
13539                        stage: IndexWorkStage::TextIndex,
13540                        resource: IndexWorkResource::TextBytes,
13541                        ..
13542                    }
13543                ))
13544            ) {
13545                return Err(io::Error::other(
13546                    "extracted document text exceeded retained-text capacity",
13547                )
13548                .into());
13549            }
13550        }
13551        Ok(())
13552    }
13553
13554    #[test]
13555    fn document_text_staging_preserves_typed_resource_refusal() -> Result<(), Box<dyn Error>> {
13556        let temp = tempfile::tempdir()?;
13557        let maximum = projectatlas_symbols::MAX_DOCUMENT_COMPRESSED_BYTES;
13558        let mut bytes = vec![b' '; maximum + 1];
13559        bytes[..5].copy_from_slice(b"%PDF-");
13560        fs::write(temp.path().join("oversized.pdf"), &bytes)?;
13561        let node = Node {
13562            path: "oversized.pdf".to_owned(),
13563            kind: NodeKind::File,
13564            parent_path: None,
13565            extension: Some(".pdf".to_owned()),
13566            language: Some("pdf".to_owned()),
13567            size_bytes: Some(bytes.len() as u64),
13568            mtime_ns: Some(1),
13569            content_hash: Some(blake3::hash(&bytes).to_hex().to_string()),
13570        };
13571        let result = indexed_file_texts_for_nodes(
13572            temp.path(),
13573            &[node],
13574            TextIndexOptions::new(MAX_SYMBOL_FILE_BYTES),
13575        );
13576        if matches!(
13577            result,
13578            Err(CliError::IndexWork(
13579                IndexWorkFailure::ResourceLimitExceeded {
13580                    stage: IndexWorkStage::TextIndex,
13581                    resource: IndexWorkResource::SourceBytes,
13582                    ..
13583                }
13584            ))
13585        ) {
13586            Ok(())
13587        } else {
13588            Err(io::Error::other(format!(
13589                "document resource refusal lost its type: {result:?}"
13590            ))
13591            .into())
13592        }
13593    }
13594
13595    #[test]
13596    fn document_bytes_reach_text_and_symbol_publication_paths() -> Result<(), Box<dyn Error>> {
13597        let temp = tempfile::tempdir()?;
13598        let path = temp.path().join("guide.pdf");
13599        let bytes = runtime_pdf_fixture();
13600        fs::write(&path, &bytes)?;
13601        let node = Node {
13602            path: "guide.pdf".to_owned(),
13603            kind: NodeKind::File,
13604            parent_path: None,
13605            extension: Some(".pdf".to_owned()),
13606            language: Some("pdf".to_owned()),
13607            size_bytes: Some(bytes.len() as u64),
13608            mtime_ns: Some(1),
13609            content_hash: Some(blake3::hash(&bytes).to_hex().to_string()),
13610        };
13611        let rows = indexed_file_texts_for_nodes(
13612            temp.path(),
13613            std::slice::from_ref(&node),
13614            TextIndexOptions::new(projectatlas_symbols::MAX_DOCUMENT_OUTPUT_BYTES as u64),
13615        )?;
13616        require_eq(&rows.len(), &1, "document text row count")?;
13617        require_eq(
13618            &rows[0].text.as_ref().map(|text| text.content.as_str()),
13619            &Some("Runtime PDF"),
13620            "document text content",
13621        )?;
13622        let SymbolParseOutcome::Parsed(parsed) = parse_symbol_job(
13623            &SymbolParseJob {
13624                path: node.path,
13625                native_path: path,
13626                expected_content_hash: node.content_hash.unwrap_or_default(),
13627                language: node.language,
13628                fallback_summary: None,
13629                purpose_needs_suggestion: false,
13630            },
13631            &SymbolBuildOptions::new(
13632                projectatlas_symbols::MAX_DOCUMENT_OUTPUT_BYTES as u64,
13633                Some(1),
13634                None,
13635            ),
13636            Instant::now(),
13637        ) else {
13638            return Err(io::Error::other("document symbol job did not parse").into());
13639        };
13640        require_eq(&parsed.graph.symbols.len(), &1, "document symbol count")?;
13641        require_eq(
13642            &parsed.graph.symbols[0]
13643                .signature
13644                .contains("pdf:page=1;text-span="),
13645            &true,
13646            "document locator persisted in graph symbol",
13647        )?;
13648        Ok(())
13649    }
13650
13651    #[test]
13652    fn empty_document_refresh_discards_previous_summary() -> Result<(), Box<dyn Error>> {
13653        let bytes = runtime_pdf_fixture();
13654        let mut job = SymbolParseJob {
13655            path: "guide.pdf".to_owned(),
13656            native_path: PathBuf::from("guide.pdf"),
13657            expected_content_hash: String::new(),
13658            language: Some("pdf".to_owned()),
13659            fallback_summary: None,
13660            purpose_needs_suggestion: true,
13661        };
13662        let options = SymbolBuildOptions::new(
13663            projectatlas_symbols::MAX_DOCUMENT_OUTPUT_BYTES as u64,
13664            Some(1),
13665            None,
13666        );
13667        let control = standalone_index_work_control();
13668        let SymbolParseOutcome::Parsed(previous) =
13669            parse_document_symbol_job(&job, &bytes, &options, &control)
13670        else {
13671            return Err(io::Error::other("document did not parse").into());
13672        };
13673        require_eq(&previous.graph.symbols.len(), &1, "previous document block")?;
13674        require_eq(
13675            &previous.summary.contains("Runtime PDF"),
13676            &true,
13677            "literal document summary",
13678        )?;
13679        require_eq(
13680            &previous
13681                .purpose_suggestion
13682                .as_deref()
13683                .unwrap_or_default()
13684                .contains("Runtime PDF"),
13685            &true,
13686            "literal document purpose suggestion",
13687        )?;
13688        job.fallback_summary = Some(previous.summary);
13689        let empty = String::from_utf8(bytes)?.replace("Runtime PDF", "           ");
13690        let SymbolParseOutcome::Parsed(current) =
13691            parse_document_symbol_job(&job, empty.as_bytes(), &options, &control)
13692        else {
13693            return Err(io::Error::other("empty document did not parse").into());
13694        };
13695        require_eq(&current.graph.symbols.len(), &0, "empty document blocks")?;
13696        require_eq(
13697            &current.summary.contains("Runtime PDF"),
13698            &false,
13699            "removed document text",
13700        )?;
13701        require_eq(
13702            &current.summary.contains("document-block-1"),
13703            &false,
13704            "summary must discard removed document blocks",
13705        )?;
13706        require_eq(
13707            &current
13708                .purpose_suggestion
13709                .as_deref()
13710                .unwrap_or_default()
13711                .contains("document-block-1"),
13712            &false,
13713            "suggested purpose must discard removed document blocks",
13714        )?;
13715        Ok(())
13716    }
13717
13718    #[cfg(unix)]
13719    #[test]
13720    fn notify_events_preserve_non_utf8_worktree_root_identity() -> Result<(), Box<dyn Error>> {
13721        use std::ffi::OsString;
13722        use std::os::unix::ffi::OsStringExt;
13723
13724        let temp = tempfile::tempdir()?;
13725        let root = temp
13726            .path()
13727            .join(OsString::from_vec(b"worktree-root-\xff".to_vec()));
13728        let source = root.join("src").join("generated.rs");
13729        fs::create_dir_all(
13730            source
13731                .parent()
13732                .ok_or_else(|| io::Error::other("watch source has no parent"))?,
13733        )?;
13734        fs::write(&source, "pub fn generated() {}\n")?;
13735        let event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
13736            notify::event::DataChange::Content,
13737        )))
13738        .add_path(source.clone());
13739
13740        let changes = notify_event_changes(&root, &ScanOptions::default(), &event);
13741
13742        require_eq(
13743            &changes.paths,
13744            &HashSet::from([source.clone()]),
13745            "non-UTF-8 worktree-root watcher path",
13746        )?;
13747        require_eq(
13748            &normalized_deleted_path(&root, &source)?,
13749            &Some("src/generated.rs".to_string()),
13750            "non-UTF-8 worktree-root relative watcher path",
13751        )?;
13752        Ok(())
13753    }
13754
13755    /// Require a test condition without relying on a crate-level test helper.
13756    #[cfg(unix)]
13757    fn require_condition(condition: bool, label: &str) -> Result<(), Box<dyn Error>> {
13758        if condition {
13759            Ok(())
13760        } else {
13761            Err(io::Error::other(label).into())
13762        }
13763    }
13764
13765    /// Require equal test values without panicking from a fallible test.
13766    fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
13767    where
13768        T: Debug + PartialEq,
13769    {
13770        if actual == expected {
13771            Ok(())
13772        } else {
13773            Err(io::Error::other(format!(
13774                "{label} mismatch: expected {expected:?}, got {actual:?}"
13775            ))
13776            .into())
13777        }
13778    }
13779}