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    is_scanner_fallback_summary, is_structural_summary_candidate, structural_summary_for_path,
20};
21use crate::{
22    CliError, OutputFormat, WATCH_MODE_NOTIFY, WATCH_MODE_ONCE, WATCH_MODE_POLLING, truthy_env,
23};
24use blake3::Hasher;
25use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
26#[cfg(feature = "optional-parser-supervisor")]
27use projectatlas_cli::optional_parser_lifecycle::{
28    OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH, OptionalParserPackLifecycle,
29    OptionalParserPackLifecycleReport, OptionalParserPackProjectSelection,
30};
31use projectatlas_core::health::{
32    CATEGORY_DUPLICATE_PURPOSE, CATEGORY_MISSING_PURPOSE, CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
33    CATEGORY_REPEATED_TEMPORARY_FOLDER, CATEGORY_STALE_PURPOSE, CATEGORY_SUGGESTED_PURPOSE_REVIEW,
34    Severity,
35};
36use projectatlas_core::language::{
37    ACCEPTED_LANGUAGE_CAPABILITY_SET_VERSION, LANGUAGE_CAPABILITY_REGISTRY_VERSION,
38    LanguageRegistryReport, SymbolParserOwner, accepted_language_capability_digest,
39    language_capability, language_registry_digest, language_registry_report,
40};
41#[cfg(all(test, feature = "optional-parser-supervisor"))]
42use projectatlas_core::optional_parser_pack::OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION;
43use projectatlas_core::outline::estimate_tokens;
44use projectatlas_core::relation_capabilities::{
45    RelationFamilyInventoryReport, relation_family_inventory_report,
46};
47use projectatlas_core::symbols::{
48    ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
49};
50use projectatlas_core::telemetry::{
51    TOKEN_BASELINE_DIRECTORY_WALK, TOKEN_BASELINE_SELECTED_CANDIDATES,
52    TOKEN_BUCKET_NAVIGATION_AVOIDANCE, TOKEN_CONFIDENCE_INFERRED, TOKEN_CONFIDENCE_POLICY_ESTIMATE,
53    UsageInstanceId, UsageInstanceOwner, usage_from_estimates_with_context, usage_from_text,
54};
55use projectatlas_core::toon::{encode_agent_payload, render_ranked_node_rows};
56use projectatlas_core::{
57    IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure, IndexWorkResource,
58    IndexWorkStage, Node, NodeKind, Overview, PurposeSource, PurposeStatus,
59    normalize_native_path_display, normalize_native_path_display_str, normalize_repo_path,
60    purpose_review_signal, repo_path_to_native, validated_repo_file_key, validated_repo_node_key,
61};
62use projectatlas_db::{
63    AtlasStore, DatabasePublicationContractState, DatabasePublicationReport,
64    DatabaseSchemaCompatibility, DatabaseSettingsReport, HealthFindingsPage, HealthQuery,
65    HealthScope, IndexPublication, IndexPublicationGuard, IndexPublicationState, IndexedFileText,
66    MAX_PURPOSE_CURATION_BATCH_ROWS, PurposeConditionalApplyRequest, PurposeConditionalApplyState,
67    TelemetryRetentionState, database_settings_report, read_project_root_read_only,
68    validate_database_location,
69};
70use projectatlas_fs::{
71    FsError, RootScanPolicy, ScanLimits, ScanOptions, gitignore_excludes_path,
72    scan_path_with_policy_controlled, scan_repo, scan_repo_controlled,
73    scan_repo_controlled_with_work,
74};
75use projectatlas_service::{
76    CoverageDiscoveryReport, FederatedInputWork, FederatedStore, FilePathMatcher,
77    MAX_FEDERATED_DATABASE_BYTES, MAX_FEDERATED_INPUT_BYTES, NextStepReport, build_next_report,
78    load_ranked_file_nodes_with_reasons, load_ranked_folder_nodes_with_reasons,
79    validate_federated_root_count,
80};
81use projectatlas_symbols::{extract_symbol_graph_controlled, semantic_resolution_contract_digest};
82use rayon::ThreadPoolBuilder;
83use rayon::prelude::*;
84use serde::{Deserialize, Serialize};
85use serde_json::{Value, json};
86use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
87use std::fmt;
88use std::fmt::Write as _;
89use std::fs;
90use std::io::{self, Read};
91use std::path::{Path, PathBuf};
92use std::process::{Command as StdCommand, Stdio};
93use std::sync::Arc;
94use std::sync::atomic::{AtomicBool, Ordering};
95use std::sync::mpsc::{self, RecvTimeoutError, TrySendError};
96use std::thread;
97use std::time::{Duration, Instant};
98
99/// Maximum file size parsed for symbols by default.
100pub(crate) const MAX_SYMBOL_FILE_BYTES: u64 = 2_000_000;
101/// Default health rows returned when the caller does not request a page size.
102pub(crate) const DEFAULT_HEALTH_LIMIT: usize = 50;
103/// Maximum health rows returned in one payload.
104pub(crate) const MAX_HEALTH_LIMIT: usize = 200;
105/// Maximum JSON bytes read for one CLI purpose-review batch.
106pub(crate) const MAX_PURPOSE_REVIEW_INPUT_FILE_BYTES: u64 = 2 * 1_024 * 1_024;
107/// Maximum output retained from one effective Git config query.
108const MAX_GIT_CONFIG_QUERY_OUTPUT_BYTES: usize = 64 * 1_024;
109/// Maximum time allowed for one effective Git config query.
110const GIT_CONFIG_QUERY_TIMEOUT: Duration = Duration::from_secs(2);
111
112/// Default whole-operation deadline when no narrower parser limit is supplied.
113const DEFAULT_INDEX_WORK_TIMEOUT: Duration = Duration::from_secs(30 * 60);
114/// Maximum UTF-8 source bytes retained while one publication is staged.
115const MAX_STAGED_TEXT_BYTES: u64 = 512 * 1024 * 1024;
116/// Maximum aggregate retained string bytes across one in-memory publication batch.
117const MAX_PUBLICATION_STAGING_BYTES: u64 = 2 * 1024 * 1024 * 1024;
118/// Maximum scan-node mutations applied between publication cancellation checks.
119const PUBLICATION_NODE_BATCH_SIZE: usize = 1_024;
120/// Maximum deleted repository paths applied between publication cancellation checks.
121const PUBLICATION_PATH_BATCH_SIZE: usize = 128;
122/// Maximum persisted source texts applied between publication cancellation checks.
123const PUBLICATION_TEXT_BATCH_SIZE: usize = 32;
124/// Maximum symbol parse results retained before sequential persistence.
125#[cfg(not(feature = "optional-parser-supervisor"))]
126const SYMBOL_PARSE_BATCH_SIZE: usize = 64;
127/// Maximum symbol candidates accepted by one publication.
128const MAX_SYMBOL_PARSE_JOBS: usize = 100_000;
129/// Maximum event paths accepted by one incremental watcher publication.
130const MAX_INCREMENTAL_CHANGED_PATHS: usize = 100_000;
131/// Maximum source bytes accepted by one incremental watcher publication.
132const MAX_INCREMENTAL_SOURCE_BYTES: u64 = 1024 * 1024 * 1024;
133/// Maximum native watcher events buffered before continuity becomes uncertain.
134const WATCH_EVENT_QUEUE_CAPACITY: usize = 1_024;
135/// Maximum indexing workers regardless of a larger caller request.
136pub(crate) const INDEX_WORKER_SAFE_CEILING: usize = 32;
137/// Chunk size used by cancellation-aware bounded source reads.
138const CONTROLLED_SOURCE_READ_BUFFER_BYTES: usize = 8_192;
139/// Maximum aggregate authored-purpose bytes inspected by one publication.
140const MAX_PURPOSE_IMPORT_BYTES: u64 = 512 * 1_024 * 1_024;
141/// Maximum complete config, map, or non-source purpose input size.
142const MAX_PURPOSE_INPUT_FILE_BYTES: u64 = 16 * 1_024 * 1_024;
143/// Maximum bytes in one repository path supplied to purpose review.
144const MAX_PURPOSE_REVIEW_PATH_BYTES: usize = 4 * 1_024;
145/// Maximum bytes in one non-path purpose-review string field.
146const MAX_PURPOSE_REVIEW_FIELD_BYTES: usize = 64 * 1_024;
147/// Purpose-review report field name for an item error.
148const PURPOSE_REVIEW_REPORT_ERROR_FIELD: &str = "error";
149/// Maximum aggregate string bytes admitted to one purpose-review batch.
150const MAX_PURPOSE_REVIEW_INPUT_BYTES: usize = 512 * 1_024;
151/// Maximum retained item/output bytes for one purpose-review report.
152const MAX_PURPOSE_REVIEW_REPORT_BYTES: usize = 4 * 1_024 * 1_024;
153/// Maximum source prefix inspected for a legacy purpose header.
154const MAX_PURPOSE_HEADER_BYTES: u64 = 256 * 1_024;
155/// Maximum normalized legacy purpose rows admitted by one publication.
156const MAX_PURPOSE_IMPORT_RECORDS: u64 = 1_000_000;
157
158/// Built-in purposes for reserved project-local `ProjectAtlas` metadata inputs.
159const BUILTIN_PROJECTATLAS_PURPOSES: &[(&str, &str)] = &[
160    (
161        ".projectatlas",
162        "Store project-local ProjectAtlas metadata, configuration, and runtime state.",
163    ),
164    (
165        ".projectatlas/config.toml",
166        "Configure project-local ProjectAtlas scan, lint, purpose, and output policy.",
167    ),
168    (
169        ".projectatlas/projectatlas-nonsource-files.toon",
170        "Declare project-local non-source file purposes for ProjectAtlas map compatibility.",
171    ),
172    (
173        ".projectatlas/projectatlas-purpose-review.json",
174        "Replay agent-reviewed ProjectAtlas purpose records into the local SQLite index.",
175    ),
176];
177/// Core project-local files whose edits can change source-selection policy.
178const CORE_INDEX_POLICY_PATHS: &[&str] = &[".projectatlas/config.toml", "projectatlas.toml"];
179
180/// Resolved scan runtime policy shared by CLI and MCP adapters.
181pub(crate) struct ScanRuntimePlan {
182    /// Canonical project root.
183    pub(crate) root: PathBuf,
184    /// Optional `ProjectAtlas` config discovered for the root.
185    pub(crate) config: Option<atlas_map::AtlasMapConfig>,
186    /// Exact config file selected for this plan, if one exists.
187    selected_config_path: Option<PathBuf>,
188    /// Explicit config selector supplied by the caller, if any.
189    config_path_override: Option<PathBuf>,
190    /// Filesystem scanner options derived from config.
191    pub(crate) scan_options: ScanOptions,
192    /// `SQLite` text-index options derived from config and command override.
193    pub(crate) text_options: TextIndexOptions,
194    /// Explicit text-index limit supplied by the caller, if any.
195    text_index_max_bytes_override: Option<u64>,
196    /// Content-free optional parser selection bound into derived publication identity.
197    #[cfg(feature = "optional-parser-supervisor")]
198    optional_parser_selection: OptionalParserPackProjectSelection,
199}
200
201/// Deterministic purpose-import rows and the inputs that produced them.
202struct PurposeImportSnapshot {
203    /// Normalized purpose records staged by a full scan.
204    records: Vec<atlas_map::ImportedPurposeRecord>,
205    /// Digest of selected configuration, external inputs, and normalized rows.
206    fingerprint: String,
207}
208
209/// Hard authored-purpose input limits for one publication attempt.
210#[derive(Clone, Copy)]
211struct PurposeImportLimits {
212    /// Aggregate bytes read across all purpose inputs.
213    total_bytes: u64,
214    /// Maximum bytes in a complete config, map, or non-source input.
215    complete_file_bytes: u64,
216    /// Maximum prefix bytes inspected from one source file.
217    header_bytes: u64,
218    /// Maximum normalized records admitted after parsing.
219    records: u64,
220}
221
222impl Default for PurposeImportLimits {
223    fn default() -> Self {
224        Self {
225            total_bytes: MAX_PURPOSE_IMPORT_BYTES,
226            complete_file_bytes: MAX_PURPOSE_INPUT_FILE_BYTES,
227            header_bytes: MAX_PURPOSE_HEADER_BYTES,
228            records: MAX_PURPOSE_IMPORT_RECORDS,
229        }
230    }
231}
232
233/// Operation-owned reader for authored purpose and publication inputs.
234struct PurposeInputReader<'a> {
235    /// Shared cancellation and deadline boundary for the publication.
236    control: &'a IndexWorkControl,
237    /// Byte and record limits for authored purpose inputs.
238    limits: PurposeImportLimits,
239    /// Inputs that must be consumed completely rather than as header prefixes.
240    complete_paths: BTreeSet<PathBuf>,
241    /// Configured legacy folder-purpose filename.
242    purpose_filename: String,
243    /// Exact digests retained only for complete publication-contract inputs.
244    complete_digests: BTreeMap<PathBuf, String>,
245}
246
247/// Maximum changed paths included in a freshness failure payload.
248const INDEX_FRESHNESS_SAMPLE_LIMIT: usize = 8;
249/// Maximum affected paths a normal read may reconcile before answering.
250const NORMAL_READ_REFRESH_MAX_PATHS: usize = 64;
251/// Maximum current source bytes a normal read may reconcile before answering.
252const NORMAL_READ_REFRESH_MAX_BYTES: u64 = 8 * 1024 * 1024;
253/// Maximum current source bytes one navigation read may allocate and inspect.
254const MAX_INDEXED_NAVIGATION_SOURCE_BYTES: u64 = 16 * 1024 * 1024;
255/// Explicit version of the built-in derived-index projection contract.
256const INDEX_DERIVATION_CONTRACT_VERSION: &str = "2";
257
258/// Closed state returned when an index-backed read cannot proceed safely.
259#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
260#[serde(rename_all = "snake_case")]
261pub(crate) enum IndexReadStatus {
262    /// The selected project has not been initialized.
263    InitRequired,
264    /// A bare/common Git directory was selected instead of a source worktree.
265    WorktreeRequired,
266    /// Current saved local source differs from the durable index.
267    RefreshRequired,
268    /// Current saved local source could not be inspected completely.
269    VerificationIncomplete,
270    /// The opened index belongs to a different project root.
271    ProjectMismatch,
272}
273
274/// Closed reason for refusing an index-backed read.
275#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
276#[serde(rename_all = "snake_case")]
277pub(crate) enum IndexRefreshReason {
278    /// Existing indexed source bytes or structural metadata changed.
279    SourceChanged,
280    /// Paths were added, removed, renamed, ignored, or unignored.
281    PathsChanged,
282    /// Parser, source-selection, or indexing policy drifted.
283    PolicyDrift,
284    /// Dependency-aware incremental refresh exceeded its aggregate safe closure.
285    DependencyClosureLimit,
286}
287
288/// Scope required to recover a stale index safely.
289#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
290#[serde(rename_all = "snake_case")]
291pub(crate) enum IndexRefreshScope {
292    /// A bounded affected-path publication can restore current results.
293    Incremental,
294    /// Current publication safety requires a complete one-shot refresh.
295    Full,
296}
297
298/// Current local-source delta detected before a normal indexed read.
299struct IndexFreshnessDelta {
300    /// Typed public report when the delta cannot be reconciled automatically.
301    report: IndexRefreshRequired,
302    /// Complete native path set used for a safe affected-path publication.
303    paths: HashSet<PathBuf>,
304}
305
306/// Measured work performed by one exact source-freshness verification.
307#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
308pub(crate) struct SourceVerificationWork {
309    /// Repository entries inspected by the exact filesystem scan.
310    pub(crate) filesystem_entries: u64,
311    /// Current source bytes hashed by the exact filesystem scan.
312    pub(crate) filesystem_bytes: u64,
313    /// `SQLite` read statements owned directly by freshness verification.
314    pub(crate) sqlite_read_statements: u64,
315    /// Indexed nodes decoded for exact current-versus-durable comparison.
316    pub(crate) decoded_nodes: u64,
317}
318
319/// Exact freshness assessment plus its measured source/database work.
320struct IndexFreshnessAssessment {
321    /// Complete source delta, when current source differs from the index.
322    delta: Option<IndexFreshnessDelta>,
323    /// Work consumed to establish the assessment.
324    work: SourceVerificationWork,
325}
326
327/// Current read snapshot established through exact source verification.
328pub(crate) struct ExactFreshIndexRead {
329    /// Root-bound complete `SQLite` read snapshot.
330    pub(crate) store: AtlasStore,
331    /// Work consumed before this snapshot could be called current.
332    pub(crate) work: SourceVerificationWork,
333}
334
335/// Closed reason why current local source could not be verified.
336#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
337#[serde(rename_all = "snake_case")]
338pub(crate) enum IndexVerificationReason {
339    /// Current scan or ignore policy could not be loaded safely.
340    PolicyUnavailable,
341    /// A root or source path could not be inspected completely.
342    SourceInspectionFailed,
343    /// The selected source exceeds the bounded navigation-read ceiling.
344    SourceTooLarge,
345    /// The opened index does not contain a usable project identity.
346    ProjectIdentityUnavailable,
347    /// A prior multi-projection publication did not complete.
348    PublicationIncomplete,
349    /// The completed index used a different parser or scan-policy contract.
350    PublicationContractMismatch,
351}
352
353/// Typed first-use handoff for one selected project root.
354#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
355pub(crate) struct IndexInitRequired {
356    /// Canonical selected project root that needs initialization.
357    pub(crate) project_root: String,
358    /// Project-local durable index path that initialization will create.
359    pub(crate) database: String,
360    /// Stable first-use state for adapters.
361    pub(crate) status: IndexReadStatus,
362}
363
364/// Typed refusal when a bare/common Git directory is selected as source.
365#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
366pub(crate) struct ProjectWorktreeRequired {
367    /// Canonical bare/common Git directory that was selected.
368    pub(crate) project_root: String,
369    /// Stable source-selection state for adapters.
370    pub(crate) status: IndexReadStatus,
371}
372
373/// Bounded typed report returned before a stale indexed read can execute.
374#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
375pub(crate) struct IndexRefreshRequired {
376    /// Canonical selected project root for a reusable recovery call.
377    pub(crate) project_root: String,
378    /// Stable freshness state for adapters.
379    pub(crate) status: IndexReadStatus,
380    /// Why current saved source differs from the index.
381    pub(crate) reason: IndexRefreshReason,
382    /// Safe recovery scope.
383    pub(crate) scope: IndexRefreshScope,
384    /// Total added, removed, or modified paths.
385    pub(crate) changed: usize,
386    /// Newly visible paths.
387    pub(crate) added: usize,
388    /// Paths no longer visible under current source and ignore policy.
389    pub(crate) removed: usize,
390    /// Existing paths whose source or structural identity changed.
391    pub(crate) modified: usize,
392    /// Deterministic bounded path sample for agent recovery.
393    pub(crate) sample_paths: Vec<String>,
394}
395
396/// Bounded typed report returned when source verification is incomplete.
397#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
398pub(crate) struct IndexVerificationIncomplete {
399    /// Selected project root whose current source could not be verified.
400    pub(crate) project_root: String,
401    /// Stable verification state for adapters.
402    pub(crate) status: IndexReadStatus,
403    /// Why the verification could not complete.
404    pub(crate) reason: IndexVerificationReason,
405    /// Safe recovery scope once the underlying problem is resolved.
406    pub(crate) scope: IndexRefreshScope,
407    /// Bounded diagnostic from the failed policy or source inspection.
408    pub(crate) message: String,
409}
410
411/// Typed refusal when a selected project root and durable index disagree.
412#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
413pub(crate) struct IndexProjectMismatch {
414    /// Stable project binding state for adapters.
415    pub(crate) status: IndexReadStatus,
416    /// Canonical project root selected for this read.
417    pub(crate) selected_project_root: String,
418    /// Canonical project root recorded by the opened index.
419    pub(crate) indexed_project_root: String,
420}
421
422impl fmt::Display for IndexRefreshRequired {
423    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
424        if self.reason == IndexRefreshReason::PolicyDrift {
425            return formatter.write_str(
426                "refresh_required: derived index policy differs from the current project configuration; run `projectatlas watch --once` or `atlas_watch_once` before retrying",
427            );
428        }
429        if self.reason == IndexRefreshReason::DependencyClosureLimit {
430            return formatter.write_str(
431                "refresh_required: the dependency-aware incremental closure exceeded its safe limit; run a complete `projectatlas scan` or `atlas_scan` before retrying",
432            );
433        }
434        write!(
435            formatter,
436            "refresh_required: {} indexed path(s) differ from current local source; run `projectatlas watch --once` or `atlas_watch_once` before retrying",
437            self.changed
438        )
439    }
440}
441
442impl fmt::Display for IndexInitRequired {
443    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
444        write!(
445            formatter,
446            "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`",
447            self.database, self.project_root
448        )
449    }
450}
451
452impl fmt::Display for ProjectWorktreeRequired {
453    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
454        write!(
455            formatter,
456            "worktree_required: '{}' is a bare/common Git directory without checked-out source; select a checked-out worktree and initialize that exact root",
457            self.project_root
458        )
459    }
460}
461
462impl fmt::Display for IndexVerificationIncomplete {
463    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
464        write!(
465            formatter,
466            "verification_incomplete: current local source could not be verified safely: {}",
467            self.message
468        )
469    }
470}
471
472impl fmt::Display for IndexProjectMismatch {
473    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
474        write!(
475            formatter,
476            "project_mismatch: selected project root '{}' does not match index root '{}'",
477            self.selected_project_root, self.indexed_project_root
478        )
479    }
480}
481
482/// Verify current saved local source against the durable index in focused tests.
483#[cfg(test)]
484fn verify_index_freshness(
485    store: &AtlasStore,
486    root: &Path,
487    config_path: Option<&Path>,
488) -> Result<(), CliError> {
489    let plan = ScanRuntimePlan::for_path(config_path, root, None).map_err(|source| {
490        verification_incomplete(root, IndexVerificationReason::PolicyUnavailable, &source)
491    })?;
492    match detect_index_freshness(store, &plan)? {
493        Some(delta) => Err(CliError::RefreshRequired(Box::new(delta.report))),
494        None => Ok(()),
495    }
496}
497
498/// Open a current read snapshot, reconciling one safe bounded delta when possible.
499pub(crate) fn open_fresh_atlas_store_for_project(
500    db_path: &Path,
501    root: &Path,
502    config_path: Option<&Path>,
503) -> Result<AtlasStore, CliError> {
504    let control = standalone_index_work_control();
505    open_fresh_atlas_store_for_project_controlled(db_path, root, config_path, &control)
506}
507
508/// Open a current read snapshot under one cooperative freshness boundary.
509pub(crate) fn open_fresh_atlas_store_for_project_controlled(
510    db_path: &Path,
511    root: &Path,
512    config_path: Option<&Path>,
513    control: &IndexWorkControl,
514) -> Result<AtlasStore, CliError> {
515    Ok(
516        open_exact_fresh_atlas_store_for_project_controlled(db_path, root, config_path, control)?
517            .store,
518    )
519}
520
521/// Open a current snapshot and retain exact freshness work for epoch accounting.
522pub(crate) fn open_exact_fresh_atlas_store_for_project_controlled(
523    db_path: &Path,
524    root: &Path,
525    config_path: Option<&Path>,
526    control: &IndexWorkControl,
527) -> Result<ExactFreshIndexRead, CliError> {
528    open_exact_fresh_atlas_store_for_project_with_repair(
529        db_path,
530        root,
531        config_path,
532        control,
533        true,
534        ScanLimits::default(),
535    )
536}
537
538/// Open a current read snapshot without repairing stale source or durable state.
539fn open_exact_fresh_atlas_store_for_project_with_repair(
540    db_path: &Path,
541    root: &Path,
542    config_path: Option<&Path>,
543    control: &IndexWorkControl,
544    repair_safe_delta: bool,
545    scan_limits: ScanLimits,
546) -> Result<ExactFreshIndexRead, CliError> {
547    let bounded_control = bounded_index_work_control(control);
548    let control = &bounded_control;
549    let store = open_atlas_store_read_only_for_project(db_path, root)?;
550    let plan = ScanRuntimePlan::for_path_controlled(config_path, root, None, control)
551        .map_err(|source| publication_input_error(root, source))?;
552    let assessment = match detect_index_freshness_controlled(&store, &plan, scan_limits, control) {
553        Ok(assessment) => assessment,
554        Err(CliError::VerificationIncomplete(report))
555            if report.reason == IndexVerificationReason::PublicationContractMismatch =>
556        {
557            return Err(CliError::RefreshRequired(Box::new(
558                index_policy_refresh_required(&plan.root),
559            )));
560        }
561        Err(error) => return Err(error),
562    };
563    let mut work = assessment.work;
564    let Some(delta) = assessment.delta else {
565        return Ok(ExactFreshIndexRead { store, work });
566    };
567    if !repair_safe_delta {
568        return Err(CliError::RefreshRequired(Box::new(delta.report)));
569    }
570    if delta.report.scope != IndexRefreshScope::Incremental {
571        return Err(CliError::RefreshRequired(Box::new(delta.report)));
572    }
573
574    let refresh_required = delta.report.clone();
575    drop(store);
576    let repair = (|| {
577        let mut writer = open_atlas_store_for_project(db_path, &plan.root)?;
578        let changes = WatchChangeSet {
579            requires_full_scan: false,
580            paths: delta.paths,
581        };
582        refresh_index_for_changes_controlled(
583            &mut writer,
584            &plan,
585            &changes,
586            &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
587            control,
588        )
589    })();
590    if let Err(error) = repair {
591        if automatic_refresh_write_is_unavailable(&error) {
592            return Err(CliError::RefreshRequired(Box::new(refresh_required)));
593        }
594        return Err(error);
595    }
596
597    let store = open_atlas_store_read_only_for_project(db_path, &plan.root)?;
598    verify_index_project_root(&store, &plan.root)?;
599    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
600    verify_index_publication(&store, &plan)?;
601    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
602    Ok(ExactFreshIndexRead { store, work })
603}
604
605/// Open an explicit ordered set of current project indexes without mutating any root.
606pub(crate) fn open_federated_atlas_stores_for_project(
607    selected_db: &Path,
608    selected_root: &Path,
609    selected_config: Option<&Path>,
610    roots: &[PathBuf],
611    control: &IndexWorkControl,
612) -> Result<Vec<FederatedStore>, CliError> {
613    validate_federated_root_count(roots.len()).map_err(CliError::Service)?;
614    let selected_root = fs::canonicalize(selected_root).map_err(|source| CliError::Io {
615        path: selected_root.to_path_buf(),
616        source,
617    })?;
618    let mut canonical_roots = Vec::with_capacity(roots.len());
619    for root in roots {
620        let root = fs::canonicalize(root).map_err(|source| CliError::Io {
621            path: root.clone(),
622            source,
623        })?;
624        if canonical_roots.contains(&root) {
625            return Err(CliError::Service(
626                projectatlas_service::ServiceError::InvalidInput(
627                    "federated roots must be unique".to_string(),
628                ),
629            ));
630        }
631        canonical_roots.push(root);
632    }
633    if canonical_roots.first() != Some(&selected_root) {
634        return Err(CliError::Service(
635            projectatlas_service::ServiceError::InvalidInput(
636                "the first federated root must be the selected project root".to_string(),
637            ),
638        ));
639    }
640
641    let databases = canonical_roots
642        .iter()
643        .enumerate()
644        .map(|(order, root)| {
645            if order == 0 {
646                selected_db.to_path_buf()
647            } else {
648                root.join(".projectatlas").join("projectatlas.db")
649            }
650        })
651        .collect::<Vec<_>>();
652    let mut database_bytes = 0_u64;
653    for database in &databases {
654        let metadata = fs::metadata(database).map_err(|source| CliError::Io {
655            path: database.clone(),
656            source,
657        })?;
658        if !metadata.is_file() {
659            return Err(CliError::Service(
660                projectatlas_service::ServiceError::InvalidInput(
661                    "federated database path is not a regular file".to_string(),
662                ),
663            ));
664        }
665        database_bytes = database_bytes.checked_add(metadata.len()).ok_or_else(|| {
666            CliError::Service(projectatlas_service::ServiceError::InvalidInput(
667                "participating database byte count overflowed".to_string(),
668            ))
669        })?;
670        if database_bytes > MAX_FEDERATED_DATABASE_BYTES {
671            return Err(CliError::Service(
672                projectatlas_service::ServiceError::InvalidInput(format!(
673                    "participating databases exceed {MAX_FEDERATED_DATABASE_BYTES} bytes"
674                )),
675            ));
676        }
677    }
678
679    let default_scan_limits = ScanLimits::default();
680    let mut remaining_input_bytes = MAX_FEDERATED_INPUT_BYTES;
681    let mut stores: Vec<FederatedStore> = Vec::with_capacity(canonical_roots.len());
682    for (order, (root, database)) in canonical_roots.into_iter().zip(databases).enumerate() {
683        let started = Instant::now();
684        let config = (order == 0).then_some(selected_config).flatten();
685        let exact = match open_exact_fresh_atlas_store_for_project_with_repair(
686            &database,
687            &root,
688            config,
689            control,
690            false,
691            ScanLimits::new(
692                default_scan_limits.max_entries(),
693                remaining_input_bytes,
694                default_scan_limits.max_workers(),
695            ),
696        ) {
697            Ok(exact) => exact,
698            Err(error) => {
699                for store in stores {
700                    drop(store.finish());
701                }
702                return Err(error);
703            }
704        };
705        remaining_input_bytes = remaining_input_bytes.saturating_sub(exact.work.filesystem_bytes);
706        let input_work = FederatedInputWork {
707            filesystem_entries: exact.work.filesystem_entries,
708            filesystem_bytes: exact.work.filesystem_bytes,
709            sqlite_read_statements: exact.work.sqlite_read_statements,
710            decoded_nodes: exact.work.decoded_nodes,
711            elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
712        };
713        match FederatedStore::new(exact.store, database, root, input_work) {
714            Ok(store) => stores.push(store),
715            Err(error) => {
716                for store in stores {
717                    drop(store.finish());
718                }
719                return Err(CliError::Service(error));
720            }
721        }
722    }
723    Ok(stores)
724}
725
726/// Distinguish optional repair contention from database-integrity failures.
727fn automatic_refresh_write_is_unavailable(error: &CliError) -> bool {
728    matches!(error, CliError::Db(source) if source.is_write_unavailable())
729}
730
731/// Return the typed full-refresh state for a changed derivation contract.
732fn index_policy_refresh_required(root: &Path) -> IndexRefreshRequired {
733    IndexRefreshRequired {
734        project_root: normalize_native_path_display(root),
735        status: IndexReadStatus::RefreshRequired,
736        reason: IndexRefreshReason::PolicyDrift,
737        scope: IndexRefreshScope::Full,
738        changed: 0,
739        added: 0,
740        removed: 0,
741        modified: 0,
742        sample_paths: Vec::new(),
743    }
744}
745
746/// Detect the complete current local-source delta for one selected index.
747#[cfg(test)]
748fn detect_index_freshness(
749    store: &AtlasStore,
750    plan: &ScanRuntimePlan,
751) -> Result<Option<IndexFreshnessDelta>, CliError> {
752    let control = standalone_index_work_control();
753    Ok(detect_index_freshness_controlled(store, plan, ScanLimits::default(), &control)?.delta)
754}
755
756/// Detect the complete local-source delta under one cooperative work boundary.
757fn detect_index_freshness_controlled(
758    store: &AtlasStore,
759    plan: &ScanRuntimePlan,
760    scan_limits: ScanLimits,
761    control: &IndexWorkControl,
762) -> Result<IndexFreshnessAssessment, CliError> {
763    let mut work = SourceVerificationWork::default();
764    verify_index_project_root(store, &plan.root)?;
765    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
766    verify_index_publication(store, plan)?;
767    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
768    let scan = scan_repo_controlled_with_work(&plan.root, &plan.scan_options, scan_limits, control)
769        .map_err(|source| source_inspection_error(&plan.root, source))?;
770    work.filesystem_entries = scan.work.entries;
771    work.filesystem_bytes = scan.work.source_bytes;
772    let current_nodes = scan.nodes;
773    let indexed_nodes = store
774        .load_nodes()?
775        .into_iter()
776        .map(|indexed| indexed.node)
777        .collect::<Vec<_>>();
778    work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
779    work.decoded_nodes = u64::try_from(indexed_nodes.len()).unwrap_or(u64::MAX);
780    Ok(IndexFreshnessAssessment {
781        delta: source_node_delta(&plan.root, &current_nodes, &indexed_nodes),
782        work,
783    })
784}
785
786/// Compare a current exact scan with the source-derived nodes being validated.
787fn verify_source_nodes_match(
788    root: &Path,
789    current_nodes: &[Node],
790    indexed_nodes: &[Node],
791) -> Result<(), CliError> {
792    match source_node_delta(root, current_nodes, indexed_nodes) {
793        Some(delta) => Err(CliError::RefreshRequired(Box::new(delta.report))),
794        None => Ok(()),
795    }
796}
797
798/// Build one deterministic affected-path plan from current and indexed nodes.
799fn source_node_delta(
800    root: &Path,
801    current_nodes: &[Node],
802    indexed_nodes: &[Node],
803) -> Option<IndexFreshnessDelta> {
804    let current_by_path = current_nodes
805        .iter()
806        .map(|node| (node.path.as_str(), node))
807        .collect::<BTreeMap<_, _>>();
808    let indexed_by_path = indexed_nodes
809        .iter()
810        .map(|node| (node.path.as_str(), node))
811        .collect::<BTreeMap<_, _>>();
812
813    let added_paths = current_by_path
814        .keys()
815        .filter(|path| !indexed_by_path.contains_key(**path))
816        .copied()
817        .collect::<Vec<_>>();
818    let removed_paths = indexed_by_path
819        .keys()
820        .filter(|path| !current_by_path.contains_key(**path))
821        .copied()
822        .collect::<Vec<_>>();
823    let mut modified_paths = Vec::new();
824    for (path, current) in &current_by_path {
825        let Some(indexed) = indexed_by_path.get(path) else {
826            continue;
827        };
828        if !same_indexed_source(current, indexed) {
829            modified_paths.push(*path);
830        }
831    }
832    let changed = added_paths
833        .len()
834        .saturating_add(removed_paths.len())
835        .saturating_add(modified_paths.len());
836    if changed == 0 {
837        return None;
838    }
839
840    let changed_paths = added_paths
841        .iter()
842        .chain(&removed_paths)
843        .chain(&modified_paths)
844        .copied()
845        .collect::<BTreeSet<_>>()
846        .into_iter()
847        .collect::<Vec<_>>();
848    let changed_bytes = added_paths
849        .iter()
850        .chain(&modified_paths)
851        .filter_map(|path| current_by_path.get(path).and_then(|node| node.size_bytes))
852        .fold(0_u64, u64::saturating_add);
853    let requires_full_scan = changed > NORMAL_READ_REFRESH_MAX_PATHS
854        || changed_bytes > NORMAL_READ_REFRESH_MAX_BYTES
855        || changed_paths
856            .iter()
857            .any(|path| watch_path_requires_full_scan(root, &root.join(repo_path_to_native(path))));
858    let sample_paths = changed_paths
859        .iter()
860        .take(INDEX_FRESHNESS_SAMPLE_LIMIT)
861        .map(|path| (*path).to_string())
862        .collect();
863    Some(IndexFreshnessDelta {
864        report: IndexRefreshRequired {
865            project_root: normalize_native_path_display(root),
866            status: IndexReadStatus::RefreshRequired,
867            reason: if added_paths.is_empty() && removed_paths.is_empty() {
868                IndexRefreshReason::SourceChanged
869            } else {
870                IndexRefreshReason::PathsChanged
871            },
872            scope: if requires_full_scan {
873                IndexRefreshScope::Full
874            } else {
875                IndexRefreshScope::Incremental
876            },
877            changed,
878            added: added_paths.len(),
879            removed: removed_paths.len(),
880            modified: modified_paths.len(),
881            sample_paths,
882        },
883        paths: changed_paths
884            .into_iter()
885            .map(|path| root.join(repo_path_to_native(path)))
886            .collect(),
887    })
888}
889
890/// Verify that the opened database belongs to the selected canonical root.
891fn verify_index_project_root(store: &AtlasStore, selected_root: &Path) -> Result<(), CliError> {
892    let Some(indexed_root) = store.project_root()? else {
893        return Err(verification_incomplete(
894            selected_root,
895            IndexVerificationReason::ProjectIdentityUnavailable,
896            &CliError::InvalidInput("index project root metadata is missing".to_string()),
897        ));
898    };
899    let indexed_root_path = PathBuf::from(&indexed_root);
900    let indexed_root = canonical_project_root(&indexed_root_path).map_err(|source| {
901        verification_incomplete(
902            selected_root,
903            IndexVerificationReason::ProjectIdentityUnavailable,
904            &source,
905        )
906    })?;
907    let selected_root = canonical_project_root(selected_root).map_err(|source| {
908        verification_incomplete(
909            selected_root,
910            IndexVerificationReason::SourceInspectionFailed,
911            &source,
912        )
913    })?;
914    if indexed_root != selected_root {
915        return Err(CliError::ProjectMismatch(Box::new(IndexProjectMismatch {
916            status: IndexReadStatus::ProjectMismatch,
917            selected_project_root: normalize_native_path_display(selected_root),
918            indexed_project_root: normalize_native_path_display(indexed_root),
919        })));
920    }
921    Ok(())
922}
923
924/// Reject mixed or runtime-incompatible derived projections before source reads.
925fn verify_index_publication(store: &AtlasStore, plan: &ScanRuntimePlan) -> Result<(), CliError> {
926    let expected_fingerprint = plan.publication_contract_fingerprint();
927    let Some(publication) = store.index_publication()? else {
928        return Err(verification_incomplete(
929            &plan.root,
930            IndexVerificationReason::PublicationIncomplete,
931            &CliError::InvalidInput(
932                "derived index publication state is missing; run one refresh".to_string(),
933            ),
934        ));
935    };
936    if publication.state == IndexPublicationState::Updating {
937        return Err(verification_incomplete(
938            &plan.root,
939            IndexVerificationReason::PublicationIncomplete,
940            &CliError::InvalidInput(
941                "a prior derived index publication did not complete; run one refresh".to_string(),
942            ),
943        ));
944    }
945    if publication.generation == projectatlas_core::IndexGeneration::ZERO {
946        return Err(verification_incomplete(
947            &plan.root,
948            IndexVerificationReason::PublicationIncomplete,
949            &CliError::InvalidInput(
950                "derived index has no complete publication generation; run one refresh".to_string(),
951            ),
952        ));
953    }
954    if publication.contract_fingerprint.as_deref() != Some(expected_fingerprint.as_str()) {
955        return Err(verification_incomplete(
956            &plan.root,
957            IndexVerificationReason::PublicationContractMismatch,
958            &CliError::InvalidInput(
959                "derived index parser or scan-policy contract changed; run one refresh".to_string(),
960            ),
961        ));
962    }
963    Ok(())
964}
965
966/// Return whether symbol reuse and incremental publication share the current derivation contract.
967fn publication_contract_matches(
968    store: &AtlasStore,
969    plan: &ScanRuntimePlan,
970) -> Result<bool, CliError> {
971    let Some(publication) = store.index_publication()? else {
972        return Ok(false);
973    };
974    Ok(publication.state == IndexPublicationState::Complete
975        && publication.generation != IndexGeneration::ZERO
976        && publication.contract_fingerprint.as_deref()
977            == Some(plan.publication_contract_fingerprint().as_str()))
978}
979
980/// Hash the parser registry and source-selection policy that own derived rows.
981fn index_derivation_fingerprint(
982    scan_options: &ScanOptions,
983    text_options: TextIndexOptions,
984    #[cfg(feature = "optional-parser-supervisor")]
985    optional_parser_selection: &OptionalParserPackProjectSelection,
986) -> String {
987    index_derivation_fingerprint_with_semantic_digest(
988        scan_options,
989        text_options,
990        #[cfg(feature = "optional-parser-supervisor")]
991        optional_parser_selection,
992        &semantic_resolution_contract_digest(),
993    )
994}
995
996/// Hash one exact parser, semantic, and source-selection contract.
997fn index_derivation_fingerprint_with_semantic_digest(
998    scan_options: &ScanOptions,
999    text_options: TextIndexOptions,
1000    #[cfg(feature = "optional-parser-supervisor")]
1001    optional_parser_selection: &OptionalParserPackProjectSelection,
1002    semantic_resolution_digest: &str,
1003) -> String {
1004    let mut hasher = Hasher::new();
1005    hash_index_contract_value(
1006        &mut hasher,
1007        "contract_version",
1008        INDEX_DERIVATION_CONTRACT_VERSION,
1009    );
1010    hash_index_contract_value(
1011        &mut hasher,
1012        "language_registry_version",
1013        &LANGUAGE_CAPABILITY_REGISTRY_VERSION.to_string(),
1014    );
1015    hash_index_contract_value(
1016        &mut hasher,
1017        "accepted_language_set_version",
1018        &ACCEPTED_LANGUAGE_CAPABILITY_SET_VERSION.to_string(),
1019    );
1020    hash_index_contract_value(
1021        &mut hasher,
1022        "language_registry_digest",
1023        &language_registry_digest(),
1024    );
1025    hash_index_contract_value(
1026        &mut hasher,
1027        "accepted_language_set_digest",
1028        &accepted_language_capability_digest(),
1029    );
1030    hash_index_contract_value(
1031        &mut hasher,
1032        "semantic_resolution_contract_digest",
1033        semantic_resolution_digest,
1034    );
1035    for value in &scan_options.exclude_dir_names {
1036        hash_index_contract_value(&mut hasher, "exclude_dir_name", value);
1037    }
1038    for value in &scan_options.exclude_dir_suffixes {
1039        hash_index_contract_value(&mut hasher, "exclude_dir_suffix", value);
1040    }
1041    for value in &scan_options.exclude_path_prefixes {
1042        hash_index_contract_value(&mut hasher, "exclude_path_prefix", value);
1043    }
1044    for (selector, language) in &scan_options.language_overrides {
1045        hash_index_contract_value(&mut hasher, "language_override_selector", selector);
1046        hash_index_contract_value(&mut hasher, "language_override_target", language);
1047    }
1048    hash_index_contract_value(
1049        &mut hasher,
1050        "text_index_max_bytes",
1051        &text_options.max_bytes.to_string(),
1052    );
1053    #[cfg(feature = "optional-parser-supervisor")]
1054    hash_index_contract_value(
1055        &mut hasher,
1056        "optional_parser_selection",
1057        optional_parser_selection
1058            .selection_key()
1059            .map_or("inactive", |selection| selection.as_str()),
1060    );
1061    hasher.finalize().to_hex().to_string()
1062}
1063
1064/// Recheck current policy and source before making staged rows visible.
1065#[cfg(test)]
1066fn revalidate_index_publication_inputs_controlled(
1067    store: &AtlasStore,
1068    plan: &ScanRuntimePlan,
1069    expected_purpose_import_fingerprint: Option<&str>,
1070    control: &IndexWorkControl,
1071) -> Result<(), CliError> {
1072    revalidate_index_publication_inputs_controlled_with_limits(
1073        store,
1074        plan,
1075        expected_purpose_import_fingerprint,
1076        control,
1077        PurposeImportLimits::default(),
1078    )
1079}
1080
1081/// Recheck publication inputs under explicit purpose limits used by focused tests.
1082#[cfg(test)]
1083fn revalidate_index_publication_inputs_controlled_with_limits(
1084    store: &AtlasStore,
1085    plan: &ScanRuntimePlan,
1086    expected_purpose_import_fingerprint: Option<&str>,
1087    control: &IndexWorkControl,
1088    purpose_limits: PurposeImportLimits,
1089) -> Result<(), CliError> {
1090    let staged_nodes = store
1091        .load_nodes()?
1092        .into_iter()
1093        .map(|indexed| indexed.node)
1094        .collect::<Vec<_>>();
1095    revalidate_staged_publication_inputs_controlled_with_limits(
1096        plan,
1097        &staged_nodes,
1098        expected_purpose_import_fingerprint,
1099        None,
1100        control,
1101        purpose_limits,
1102    )
1103}
1104
1105/// Recheck policy and exact source against one off-writer publication batch.
1106fn revalidate_staged_publication_inputs_controlled(
1107    plan: &ScanRuntimePlan,
1108    staged_nodes: &[Node],
1109    expected_purpose_import_fingerprint: Option<&str>,
1110    control: &IndexWorkControl,
1111) -> Result<(), CliError> {
1112    revalidate_staged_publication_inputs_controlled_with_limits(
1113        plan,
1114        staged_nodes,
1115        expected_purpose_import_fingerprint,
1116        None,
1117        control,
1118        PurposeImportLimits::default(),
1119    )
1120}
1121
1122/// Recheck a full scan while reusing purpose rows from exact unchanged source nodes.
1123fn revalidate_staged_publication_inputs_with_purpose_snapshot(
1124    plan: &ScanRuntimePlan,
1125    staged_nodes: &[Node],
1126    purpose_import: Option<&PurposeImportSnapshot>,
1127    control: &IndexWorkControl,
1128) -> Result<(), CliError> {
1129    revalidate_staged_publication_inputs_controlled_with_limits(
1130        plan,
1131        staged_nodes,
1132        purpose_import.map(|snapshot| snapshot.fingerprint.as_str()),
1133        purpose_import.map(|snapshot| snapshot.records.as_slice()),
1134        control,
1135        PurposeImportLimits::default(),
1136    )
1137}
1138
1139/// Recheck a staged batch under explicit purpose-input limits used by tests.
1140fn revalidate_staged_publication_inputs_controlled_with_limits(
1141    plan: &ScanRuntimePlan,
1142    staged_nodes: &[Node],
1143    expected_purpose_import_fingerprint: Option<&str>,
1144    reusable_purpose_records: Option<&[atlas_map::ImportedPurposeRecord]>,
1145    control: &IndexWorkControl,
1146    purpose_limits: PurposeImportLimits,
1147) -> Result<(), CliError> {
1148    control.check(IndexWorkStage::Publication)?;
1149    let current_plan = plan
1150        .reload_controlled_with_limits(control, purpose_limits)
1151        .map_err(|source| publication_input_error(&plan.root, source))?;
1152    control.check(IndexWorkStage::Publication)?;
1153    let staged_fingerprint = plan.publication_contract_fingerprint();
1154    let current_fingerprint = current_plan.publication_contract_fingerprint();
1155    if staged_fingerprint != current_fingerprint {
1156        return Err(verification_incomplete(
1157            &plan.root,
1158            IndexVerificationReason::PublicationContractMismatch,
1159            &CliError::InvalidInput(
1160                "derived index policy changed while publication was being built; retry the refresh"
1161                    .to_string(),
1162            ),
1163        ));
1164    }
1165    let current_nodes = scan_repo_controlled(
1166        &current_plan.root,
1167        &current_plan.scan_options,
1168        ScanLimits::default(),
1169        control,
1170    )
1171    .map_err(|source| source_inspection_error(&current_plan.root, source))?;
1172    verify_source_nodes_match(&current_plan.root, &current_nodes, staged_nodes)?;
1173    if let Some(expected_fingerprint) = expected_purpose_import_fingerprint {
1174        let current_fingerprint = if let Some(records) = reusable_purpose_records {
1175            current_plan
1176                .purpose_import_fingerprint_for_records_controlled_with_limits(
1177                    records,
1178                    control,
1179                    purpose_limits,
1180                )
1181                .map_err(|source| publication_input_error(&plan.root, source))?
1182        } else {
1183            current_plan
1184                .purpose_import_snapshot_controlled_with_limits(
1185                    &current_nodes,
1186                    control,
1187                    purpose_limits,
1188                )
1189                .map_err(|source| publication_input_error(&plan.root, source))?
1190                .fingerprint
1191        };
1192        if current_fingerprint != expected_fingerprint {
1193            return Err(verification_incomplete(
1194                &plan.root,
1195                IndexVerificationReason::PublicationContractMismatch,
1196                &CliError::InvalidInput(
1197                    "purpose-import inputs changed while publication was being built; retry the refresh"
1198                        .to_string(),
1199                ),
1200            ));
1201        }
1202    }
1203    control.check(IndexWorkStage::Publication)?;
1204    Ok(())
1205}
1206
1207/// Preserve typed work failures while adapting authored-input uncertainty.
1208fn publication_input_error(root: &Path, source: CliError) -> CliError {
1209    match source {
1210        source @ CliError::IndexWork(_) => source,
1211        other => verification_incomplete(root, IndexVerificationReason::PolicyUnavailable, &other),
1212    }
1213}
1214
1215/// Preserve typed work failures while adapting ordinary scan uncertainty.
1216fn source_inspection_error(root: &Path, source: FsError) -> CliError {
1217    match source {
1218        FsError::IndexWork(failure) => failure.into(),
1219        FsError::RepositoryBoundary { .. } => {
1220            CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1221                project_root: normalize_native_path_display(root),
1222                status: IndexReadStatus::VerificationIncomplete,
1223                reason: IndexVerificationReason::PolicyUnavailable,
1224                scope: IndexRefreshScope::Full,
1225                message: source.to_string(),
1226            }))
1227        }
1228        other => CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1229            project_root: normalize_native_path_display(root),
1230            status: IndexReadStatus::VerificationIncomplete,
1231            reason: IndexVerificationReason::SourceInspectionFailed,
1232            scope: IndexRefreshScope::Full,
1233            message: other.to_string(),
1234        })),
1235    }
1236}
1237
1238/// Commit only after the shared work boundary still permits publication.
1239fn complete_index_publication(
1240    publication: IndexPublicationGuard<'_>,
1241    control: &IndexWorkControl,
1242) -> Result<(), CliError> {
1243    control.check(IndexWorkStage::Publication)?;
1244    publication.complete()?;
1245    Ok(())
1246}
1247
1248/// Add one unambiguous field/value pair to a derived-index fingerprint.
1249fn hash_index_contract_value(hasher: &mut Hasher, field: &str, value: &str) {
1250    hasher.update(field.as_bytes());
1251    hasher.update(&[0]);
1252    hasher.update(value.as_bytes());
1253    hasher.update(&[0xff]);
1254}
1255
1256/// Convert a policy/root preflight failure into a non-destructive read refusal.
1257fn verification_incomplete(
1258    root: &Path,
1259    reason: IndexVerificationReason,
1260    source: &CliError,
1261) -> CliError {
1262    CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1263        project_root: normalize_native_path_display(root),
1264        status: IndexReadStatus::VerificationIncomplete,
1265        reason,
1266        scope: IndexRefreshScope::Full,
1267        message: source.to_string(),
1268    }))
1269}
1270
1271/// Compare source-derived node identity while ignoring non-semantic mtimes.
1272fn same_indexed_source(current: &Node, indexed: &Node) -> bool {
1273    current.path == indexed.path
1274        && current.kind == indexed.kind
1275        && current.parent_path == indexed.parent_path
1276        && current.extension == indexed.extension
1277        && current.language == indexed.language
1278        && current.size_bytes == indexed.size_bytes
1279        && current.content_hash == indexed.content_hash
1280}
1281
1282/// Refuse publication when source bytes no longer match their staged node.
1283fn source_changed_during_derivation(root: &Path, path: &str) -> CliError {
1284    CliError::RefreshRequired(Box::new(IndexRefreshRequired {
1285        project_root: normalize_native_path_display(root),
1286        status: IndexReadStatus::RefreshRequired,
1287        reason: IndexRefreshReason::SourceChanged,
1288        scope: IndexRefreshScope::Full,
1289        changed: 1,
1290        added: 0,
1291        removed: 0,
1292        modified: 1,
1293        sample_paths: vec![path.to_string()],
1294    }))
1295}
1296
1297impl<'a> PurposeInputReader<'a> {
1298    /// Create one reader whose cancellation and limits belong to the scan operation.
1299    fn new(
1300        plan: &ScanRuntimePlan,
1301        control: &'a IndexWorkControl,
1302        limits: PurposeImportLimits,
1303    ) -> Self {
1304        let mut complete_paths = BTreeSet::new();
1305        if let Some(path) = &plan.selected_config_path {
1306            complete_paths.insert(path.clone());
1307        }
1308        if let Some(config) = &plan.config {
1309            complete_paths.insert(config.map_path.clone());
1310            complete_paths.insert(config.nonsource_files_path.clone());
1311        }
1312        Self::for_complete_paths(
1313            control,
1314            limits,
1315            complete_paths,
1316            plan.config.as_ref().map_or_else(
1317                || ".purpose".to_string(),
1318                |config| config.purpose_filename().to_string(),
1319            ),
1320        )
1321    }
1322
1323    /// Create a bounded reader before a complete runtime plan is available.
1324    fn for_complete_paths(
1325        control: &'a IndexWorkControl,
1326        limits: PurposeImportLimits,
1327        complete_paths: BTreeSet<PathBuf>,
1328        purpose_filename: String,
1329    ) -> Self {
1330        Self {
1331            control,
1332            limits,
1333            complete_paths,
1334            purpose_filename,
1335            complete_digests: BTreeMap::new(),
1336        }
1337    }
1338
1339    /// Read one UTF-8 input, treating non-UTF-8 source headers as purpose-free.
1340    fn read_text(&mut self, path: &Path) -> Result<String, CliError> {
1341        self.control.check(IndexWorkStage::Publication)?;
1342        let is_complete = self.complete_paths.contains(path)
1343            || path
1344                .file_name()
1345                .is_some_and(|name| name == self.purpose_filename.as_str());
1346        let file_limit = if is_complete {
1347            self.limits.complete_file_bytes
1348        } else {
1349            self.limits.header_bytes
1350        };
1351        let mut file = fs::File::open(path).map_err(|source| CliError::Io {
1352            path: path.to_path_buf(),
1353            source,
1354        })?;
1355        let bytes = self.read_bytes(path, &mut file, file_limit, is_complete)?;
1356        if is_complete {
1357            self.complete_digests.insert(
1358                path.to_path_buf(),
1359                blake3::hash(&bytes).to_hex().to_string(),
1360            );
1361        }
1362        match String::from_utf8(bytes) {
1363            Ok(content) => Ok(content),
1364            Err(source) if !is_complete && source.utf8_error().error_len().is_some() => {
1365                Ok(String::new())
1366            }
1367            Err(source) if !is_complete && source.utf8_error().error_len().is_none() => {
1368                let valid_up_to = source.utf8_error().valid_up_to();
1369                String::from_utf8(source.into_bytes()[..valid_up_to].to_vec()).map_err(|source| {
1370                    CliError::InvalidInput(format!(
1371                        "purpose header input is not valid UTF-8 for {}: {source}",
1372                        normalize_native_path_display(path)
1373                    ))
1374                })
1375            }
1376            Err(source) => Err(CliError::InvalidInput(format!(
1377                "purpose input is not valid UTF-8 for {}: {source}",
1378                normalize_native_path_display(path)
1379            ))),
1380        }
1381    }
1382
1383    /// Read bytes with inter-chunk cancellation and aggregate accounting.
1384    fn read_bytes<R: Read>(
1385        &mut self,
1386        path: &Path,
1387        reader: &mut R,
1388        file_limit: u64,
1389        require_complete: bool,
1390    ) -> Result<Vec<u8>, CliError> {
1391        let initial_capacity = usize::try_from(file_limit)
1392            .unwrap_or(usize::MAX)
1393            .min(CONTROLLED_SOURCE_READ_BUFFER_BYTES);
1394        let mut bytes = Vec::with_capacity(initial_capacity);
1395        let mut buffer = [0_u8; CONTROLLED_SOURCE_READ_BUFFER_BYTES];
1396        loop {
1397            self.control.check(IndexWorkStage::Publication)?;
1398            let file_bytes = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
1399            let remaining = file_limit.saturating_sub(file_bytes);
1400            if remaining == 0 {
1401                if !require_complete {
1402                    break;
1403                }
1404                let read = reader
1405                    .read(&mut buffer[..1])
1406                    .map_err(|source| CliError::Io {
1407                        path: path.to_path_buf(),
1408                        source,
1409                    })?;
1410                if read == 0 {
1411                    break;
1412                }
1413                return Err(IndexWorkFailure::resource_limit(
1414                    IndexWorkStage::Publication,
1415                    IndexWorkResource::PurposeBytes,
1416                    file_limit,
1417                    file_limit.saturating_add(1),
1418                )
1419                .into());
1420            }
1421            let read_limit = usize::try_from(remaining)
1422                .unwrap_or(usize::MAX)
1423                .min(buffer.len());
1424            let read = reader
1425                .read(&mut buffer[..read_limit])
1426                .map_err(|source| CliError::Io {
1427                    path: path.to_path_buf(),
1428                    source,
1429                })?;
1430            if read == 0 {
1431                break;
1432            }
1433            self.control.consume_purpose_bytes(
1434                self.limits.total_bytes,
1435                u64::try_from(read).unwrap_or(u64::MAX),
1436            )?;
1437            bytes.extend_from_slice(&buffer[..read]);
1438        }
1439        self.control.check(IndexWorkStage::Publication)?;
1440        Ok(bytes)
1441    }
1442
1443    /// Return the digest of one complete input already read through this boundary.
1444    fn complete_digest(&self, path: &Path) -> Option<&str> {
1445        self.complete_digests.get(path).map(String::as_str)
1446    }
1447}
1448
1449impl ScanRuntimePlan {
1450    /// Resolve scan policy for one project path.
1451    pub(crate) fn for_path(
1452        config_path: Option<&Path>,
1453        path: &Path,
1454        text_index_max_bytes: Option<u64>,
1455    ) -> Result<Self, CliError> {
1456        let control = standalone_index_work_control();
1457        Self::for_path_controlled(config_path, path, text_index_max_bytes, &control)
1458    }
1459
1460    /// Resolve scan policy through the operation-owned bounded config reader.
1461    pub(crate) fn for_path_controlled(
1462        config_path: Option<&Path>,
1463        path: &Path,
1464        text_index_max_bytes: Option<u64>,
1465        control: &IndexWorkControl,
1466    ) -> Result<Self, CliError> {
1467        Self::for_path_controlled_with_limits(
1468            config_path,
1469            path,
1470            text_index_max_bytes,
1471            control,
1472            PurposeImportLimits::default(),
1473        )
1474    }
1475
1476    /// Resolve scan policy under explicit authored-input limits used by focused tests.
1477    fn for_path_controlled_with_limits(
1478        config_path: Option<&Path>,
1479        path: &Path,
1480        text_index_max_bytes: Option<u64>,
1481        control: &IndexWorkControl,
1482        purpose_limits: PurposeImportLimits,
1483    ) -> Result<Self, CliError> {
1484        control.check(IndexWorkStage::Publication)?;
1485        let root = canonical_source_project_root(path)?;
1486        let selected_config_path = selected_scan_import_config_path(config_path, &root)?;
1487        let config = if let Some(path) = selected_config_path.as_deref() {
1488            let mut complete_paths = BTreeSet::new();
1489            complete_paths.insert(path.to_path_buf());
1490            let mut reader = PurposeInputReader::for_complete_paths(
1491                control,
1492                purpose_limits,
1493                complete_paths,
1494                ".purpose".to_string(),
1495            );
1496            let text = reader.read_text(path)?;
1497            let config = load_atlas_config_from_text(path, &text)?;
1498            let config_root = canonical_project_root(&config.root)?;
1499            if config_root != root {
1500                return Err(config_root_mismatch_error(path, &config_root, &root));
1501            }
1502            Some(config)
1503        } else {
1504            None
1505        };
1506        control.check(IndexWorkStage::Publication)?;
1507        let scan_options = config.as_ref().map_or_else(
1508            ScanOptions::default,
1509            atlas_map::AtlasMapConfig::scan_options,
1510        );
1511        let text_options = text_index_options(config.as_ref(), text_index_max_bytes);
1512        #[cfg(feature = "optional-parser-supervisor")]
1513        let optional_parser_selection =
1514            OptionalParserPackLifecycle::new(&root, None)?.derive_project_selection()?;
1515        #[cfg(feature = "optional-parser-supervisor")]
1516        let scan_options = {
1517            let mut scan_options = scan_options;
1518            scan_options.admit_optional_languages =
1519                optional_parser_selection.selection_key().is_some();
1520            scan_options
1521        };
1522        Ok(Self {
1523            root,
1524            config,
1525            selected_config_path,
1526            config_path_override: config_path.map(Path::to_path_buf),
1527            scan_options,
1528            text_options,
1529            text_index_max_bytes_override: text_index_max_bytes,
1530            #[cfg(feature = "optional-parser-supervisor")]
1531            optional_parser_selection,
1532        })
1533    }
1534
1535    /// Reload the effective filesystem and text policy from current local state.
1536    fn reload(&self) -> Result<Self, CliError> {
1537        Self::for_path(
1538            self.config_path_override.as_deref(),
1539            &self.root,
1540            self.text_index_max_bytes_override,
1541        )
1542    }
1543
1544    /// Reload effective policy through the operation-owned bounded input reader.
1545    fn reload_controlled(&self, control: &IndexWorkControl) -> Result<Self, CliError> {
1546        self.reload_controlled_with_limits(control, PurposeImportLimits::default())
1547    }
1548
1549    /// Reload effective policy under explicit authored-input limits used by focused tests.
1550    fn reload_controlled_with_limits(
1551        &self,
1552        control: &IndexWorkControl,
1553        purpose_limits: PurposeImportLimits,
1554    ) -> Result<Self, CliError> {
1555        Self::for_path_controlled_with_limits(
1556            self.config_path_override.as_deref(),
1557            &self.root,
1558            self.text_index_max_bytes_override,
1559            control,
1560            purpose_limits,
1561        )
1562    }
1563
1564    /// Hash the durable parser and configured source/index policy contract.
1565    ///
1566    /// Request-scoped text limits control one scan or watcher operation but do
1567    /// not become project compatibility state that later reads must repeat.
1568    fn publication_contract_fingerprint(&self) -> String {
1569        let configured_text_options = text_index_options(self.config.as_ref(), None);
1570        index_derivation_fingerprint(
1571            &self.scan_options,
1572            configured_text_options,
1573            #[cfg(feature = "optional-parser-supervisor")]
1574            &self.optional_parser_selection,
1575        )
1576    }
1577
1578    /// Capture every purpose input from one existing controlled repository scan.
1579    fn purpose_import_snapshot_controlled(
1580        &self,
1581        nodes: &[Node],
1582        control: &IndexWorkControl,
1583    ) -> Result<PurposeImportSnapshot, CliError> {
1584        self.purpose_import_snapshot_controlled_with_limits(
1585            nodes,
1586            control,
1587            PurposeImportLimits::default(),
1588        )
1589    }
1590
1591    /// Capture purpose inputs under explicit limits used by focused tests.
1592    fn purpose_import_snapshot_controlled_with_limits(
1593        &self,
1594        nodes: &[Node],
1595        control: &IndexWorkControl,
1596        limits: PurposeImportLimits,
1597    ) -> Result<PurposeImportSnapshot, CliError> {
1598        control.check(IndexWorkStage::Publication)?;
1599        let mut hasher = Hasher::new();
1600        hash_index_contract_value(&mut hasher, "purpose_import_version", "2");
1601        let Some(config) = self.config.as_ref() else {
1602            hash_index_contract_value(&mut hasher, "selected_config", "absent");
1603            return Ok(PurposeImportSnapshot {
1604                records: Vec::new(),
1605                fingerprint: hasher.finalize().to_hex().to_string(),
1606            });
1607        };
1608        let selected_config_path = self.selected_config_path.as_deref().ok_or_else(|| {
1609            CliError::InvalidInput(
1610                "purpose import has normalized configuration without a selected config path"
1611                    .to_string(),
1612            )
1613        })?;
1614        if u64::try_from(nodes.len()).unwrap_or(u64::MAX) > limits.records {
1615            return Err(IndexWorkFailure::resource_limit(
1616                IndexWorkStage::Publication,
1617                IndexWorkResource::PurposeRecords,
1618                limits.records,
1619                u64::try_from(nodes.len()).unwrap_or(u64::MAX),
1620            )
1621            .into());
1622        }
1623        let mut reader = PurposeInputReader::new(self, control, limits);
1624        hash_publication_input_file_controlled(
1625            &mut hasher,
1626            "selected_config",
1627            selected_config_path,
1628            &mut reader,
1629        )?;
1630        let records = atlas_map::imported_purpose_records_from_nodes(config, nodes, &mut |path| {
1631            reader.read_text(path)
1632        })?;
1633        let fingerprint = finish_purpose_import_fingerprint_controlled(
1634            &mut hasher,
1635            config,
1636            &records,
1637            &mut reader,
1638            control,
1639            limits,
1640        )?;
1641        Ok(PurposeImportSnapshot {
1642            records,
1643            fingerprint,
1644        })
1645    }
1646
1647    /// Recheck external purpose inputs while reusing records from exact unchanged source nodes.
1648    fn purpose_import_fingerprint_for_records_controlled_with_limits(
1649        &self,
1650        records: &[atlas_map::ImportedPurposeRecord],
1651        control: &IndexWorkControl,
1652        limits: PurposeImportLimits,
1653    ) -> Result<String, CliError> {
1654        control.check(IndexWorkStage::Publication)?;
1655        let mut hasher = Hasher::new();
1656        hash_index_contract_value(&mut hasher, "purpose_import_version", "2");
1657        let Some(config) = self.config.as_ref() else {
1658            hash_index_contract_value(&mut hasher, "selected_config", "absent");
1659            return Ok(hasher.finalize().to_hex().to_string());
1660        };
1661        let selected_config_path = self.selected_config_path.as_deref().ok_or_else(|| {
1662            CliError::InvalidInput(
1663                "purpose import has normalized configuration without a selected config path"
1664                    .to_string(),
1665            )
1666        })?;
1667        let mut reader = PurposeInputReader::new(self, control, limits);
1668        hash_publication_input_file_controlled(
1669            &mut hasher,
1670            "selected_config",
1671            selected_config_path,
1672            &mut reader,
1673        )?;
1674        finish_purpose_import_fingerprint_controlled(
1675            &mut hasher,
1676            config,
1677            records,
1678            &mut reader,
1679            control,
1680            limits,
1681        )
1682    }
1683}
1684
1685/// Finish one purpose-import fingerprint from already normalized source records.
1686fn finish_purpose_import_fingerprint_controlled(
1687    hasher: &mut Hasher,
1688    config: &atlas_map::AtlasMapConfig,
1689    records: &[atlas_map::ImportedPurposeRecord],
1690    reader: &mut PurposeInputReader<'_>,
1691    control: &IndexWorkControl,
1692    limits: PurposeImportLimits,
1693) -> Result<String, CliError> {
1694    let record_count = u64::try_from(records.len()).unwrap_or(u64::MAX);
1695    if record_count > limits.records {
1696        return Err(IndexWorkFailure::resource_limit(
1697            IndexWorkStage::Publication,
1698            IndexWorkResource::PurposeRecords,
1699            limits.records,
1700            record_count,
1701        )
1702        .into());
1703    }
1704    hash_publication_input_file_controlled(hasher, "legacy_map", &config.map_path, reader)?;
1705    hash_publication_input_file_controlled(
1706        hasher,
1707        "nonsource_purposes",
1708        &config.nonsource_files_path,
1709        reader,
1710    )?;
1711    for record in records {
1712        control.check(IndexWorkStage::Publication)?;
1713        hash_index_contract_value(hasher, "purpose_path", &record.path);
1714        hash_index_contract_value(hasher, "purpose_summary", &record.summary);
1715    }
1716    Ok(hasher.finalize().to_hex().to_string())
1717}
1718
1719/// Resolve the exact config file selected for a scan plan without loading it.
1720fn selected_scan_import_config_path(
1721    config_path: Option<&Path>,
1722    root: &Path,
1723) -> Result<Option<PathBuf>, CliError> {
1724    if let Some(config_path) = config_path {
1725        return absolute_path(config_path).map(Some);
1726    }
1727    Ok(config_candidates_for_root(root)
1728        .into_iter()
1729        .find(|candidate| candidate.exists()))
1730}
1731
1732/// Bind one optional file's identity and exact bytes to publication input state.
1733fn hash_publication_input_file_controlled(
1734    hasher: &mut Hasher,
1735    role: &str,
1736    path: &Path,
1737    reader: &mut PurposeInputReader<'_>,
1738) -> Result<(), CliError> {
1739    hash_index_contract_value(hasher, role, &normalize_native_path_display(path));
1740    if !path.exists() {
1741        hash_index_contract_value(hasher, "input_state", "missing");
1742        return Ok(());
1743    }
1744    if reader.complete_digest(path).is_none() {
1745        let _ = reader.read_text(path)?;
1746    }
1747    let digest = reader.complete_digest(path).ok_or_else(|| {
1748        CliError::InvalidInput(format!(
1749            "purpose publication input was not read completely: {}",
1750            normalize_native_path_display(path)
1751        ))
1752    })?;
1753    hash_index_contract_value(hasher, "input_state", "present");
1754    hash_index_contract_value(hasher, "input_digest", digest);
1755    Ok(())
1756}
1757
1758/// Scan command report shared by CLI and MCP adapters.
1759#[derive(Debug, Serialize)]
1760pub(crate) struct ScanReport {
1761    /// Repository overview after scan.
1762    pub(crate) overview: Overview,
1763    /// Legacy purpose records imported into the current index.
1764    pub(crate) purpose_import: PurposeImportReport,
1765    /// Persisted text search index report.
1766    pub(crate) text_index: TextIndexReport,
1767    /// Structural summaries refreshed for declaration-light files.
1768    pub(crate) structural_summaries: StructuralSummaryReport,
1769    /// Symbol graph build report.
1770    pub(crate) symbols: SymbolBuildReport,
1771}
1772
1773/// Legacy purpose import counts from a scan.
1774#[derive(Debug, Default, Serialize)]
1775pub(crate) struct PurposeImportReport {
1776    /// Purpose records imported into indexed nodes.
1777    pub(crate) imported: usize,
1778    /// Legacy purpose records skipped because the path is no longer indexed.
1779    pub(crate) skipped_stale: usize,
1780    /// Legacy purpose records skipped because a curated purpose already exists.
1781    pub(crate) skipped_existing: usize,
1782}
1783
1784/// Options for the first-run initialization bootstrap.
1785pub(crate) struct InitBootstrapOptions {
1786    /// Skip the scan/index phase.
1787    pub(crate) no_scan: bool,
1788    /// Force a scan even when future freshness checks would skip it.
1789    pub(crate) force_rescan: bool,
1790    /// Optional text index byte limit override.
1791    pub(crate) text_index_max_bytes: Option<u64>,
1792}
1793
1794/// Project initialization phase status.
1795#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1796#[serde(rename_all = "snake_case")]
1797pub(crate) enum InitPhaseStatus {
1798    /// Resource was created during this run.
1799    Created,
1800    /// Resource already existed before this run.
1801    Exists,
1802    /// Resource was verified or phase completed.
1803    Verified,
1804    /// Phase was explicitly skipped.
1805    Skipped,
1806    /// Phase failed before the report could finish.
1807    Failed,
1808}
1809
1810/// First-run init report shared by CLI and MCP adapters.
1811#[derive(Debug, Serialize)]
1812pub(crate) struct InitSetupReport {
1813    /// Whether every init phase completed successfully.
1814    pub(crate) ok: bool,
1815    /// Canonical project root initialized by this command.
1816    pub(crate) root: String,
1817    /// Project-local directory status.
1818    pub(crate) project_dir: InitPathStatus,
1819    /// Project-local config status.
1820    pub(crate) config: InitPathStatus,
1821    /// Project-local non-source registry status.
1822    pub(crate) nonsource_files: InitPathStatus,
1823    /// Durable `SQLite` DB status.
1824    pub(crate) db: InitPathStatus,
1825    /// Generated host MCP config files.
1826    pub(crate) host_configs: Vec<InitHostConfigStatus>,
1827    /// Scan/index phase result.
1828    pub(crate) scan: InitScanPhase,
1829    /// Agent harness purpose curation handoff.
1830    pub(crate) purpose_handoff: PurposeCuratorHandoff,
1831    /// Human/agent next steps.
1832    pub(crate) next_steps: Vec<String>,
1833}
1834
1835/// Status for one path managed by init.
1836#[derive(Debug, Serialize)]
1837pub(crate) struct InitPathStatus {
1838    /// Path status.
1839    pub(crate) status: InitPhaseStatus,
1840    /// Normalized native display path.
1841    pub(crate) path: String,
1842}
1843
1844/// Status for one generated host integration config.
1845#[derive(Debug, Serialize)]
1846pub(crate) struct InitHostConfigStatus {
1847    /// Harness/config shape name.
1848    pub(crate) harness: &'static str,
1849    /// File status.
1850    pub(crate) status: InitPhaseStatus,
1851    /// Normalized native display path.
1852    pub(crate) path: String,
1853    /// Error text when this host config could not be generated.
1854    #[serde(skip_serializing_if = "Option::is_none")]
1855    pub(crate) error: Option<String>,
1856}
1857
1858/// Scan/index phase result for init.
1859#[derive(Debug, Serialize)]
1860pub(crate) struct InitScanPhase {
1861    /// Scan phase status.
1862    pub(crate) status: InitPhaseStatus,
1863    /// Whether scan was requested by this run.
1864    pub(crate) requested: bool,
1865    /// Whether force-rescan was requested.
1866    pub(crate) force_rescan: bool,
1867    /// Scan report when the scan ran.
1868    pub(crate) report: Option<ScanReport>,
1869    /// Error text when the scan/index phase failed.
1870    #[serde(skip_serializing_if = "Option::is_none")]
1871    pub(crate) error: Option<String>,
1872}
1873
1874/// Purpose curation handoff for agent/plugin harnesses.
1875#[derive(Debug, Serialize)]
1876#[allow(
1877    clippy::struct_excessive_bools,
1878    reason = "serialized host policy exposes independent capabilities and guarantees"
1879)]
1880pub(crate) struct PurposeCuratorHandoff {
1881    /// Whether this report is intended for an agent harness.
1882    pub(crate) agent_harness_expected: bool,
1883    /// Curator execution is owned by the agent host, never the Rust server.
1884    pub(crate) execution_owner: &'static str,
1885    /// Recommended subagent reasoning selection.
1886    pub(crate) recommended_subagent_reasoning: &'static str,
1887    /// Whether the current main agent may process the same bounded batch.
1888    pub(crate) main_agent_fallback: bool,
1889    /// Explicitly records that `ProjectAtlas` did not spawn a host agent.
1890    pub(crate) server_started_curator: bool,
1891    /// Successful maintenance should not add ordinary conversation output.
1892    pub(crate) silent_on_success: bool,
1893    /// Purpose queue page for initial curation.
1894    pub(crate) queue: PurposeCurationPage,
1895    /// Handoff instructions for plugin/agent harnesses.
1896    pub(crate) instructions: Vec<String>,
1897}
1898
1899/// Return a canonical absolute project root.
1900pub(crate) fn canonical_project_root(root: &Path) -> Result<PathBuf, CliError> {
1901    root.canonicalize().map_err(|source| CliError::Io {
1902        path: root.to_path_buf(),
1903        source,
1904    })
1905}
1906
1907/// Return a canonical checked-out source root, rejecting bare Git control roots.
1908pub(crate) fn canonical_source_project_root(root: &Path) -> Result<PathBuf, CliError> {
1909    let root = canonical_project_root(root)?;
1910    if is_bare_git_control_root(&root)? {
1911        return Err(CliError::WorktreeRequired(Box::new(
1912            ProjectWorktreeRequired {
1913                project_root: normalize_native_path_display(&root),
1914                status: IndexReadStatus::WorktreeRequired,
1915            },
1916        )));
1917    }
1918    Ok(root)
1919}
1920
1921/// Return a typed, non-mutating first-use handoff for one selected root.
1922pub(crate) fn index_init_required(root: &Path, database: &Path) -> CliError {
1923    CliError::InitRequired(Box::new(IndexInitRequired {
1924        project_root: normalize_native_path_display(root),
1925        database: normalize_native_path_display(database),
1926        status: IndexReadStatus::InitRequired,
1927    }))
1928}
1929
1930/// Recognize a bare repository or selected common Git control directory.
1931fn is_bare_git_control_root(root: &Path) -> Result<bool, CliError> {
1932    let git = root.join(".git");
1933    let control_root = match fs::metadata(&git) {
1934        Ok(metadata) if metadata.is_file() => return Ok(false),
1935        Ok(metadata) if metadata.is_dir() => git,
1936        Ok(_) => return Ok(false),
1937        Err(source) if source.kind() == io::ErrorKind::NotFound => root.to_path_buf(),
1938        Err(source) => {
1939            return Err(CliError::Io { path: git, source });
1940        }
1941    };
1942    let head = control_root.join("HEAD");
1943    let config = control_root.join("config");
1944    let objects = control_root.join("objects");
1945    let refs = control_root.join("refs");
1946    for path in [&head, &config, &objects, &refs] {
1947        if !path.try_exists().map_err(|source| CliError::Io {
1948            path: path.clone(),
1949            source,
1950        })? {
1951            return Ok(false);
1952        }
1953    }
1954    let structurally_git = fs::metadata(&head)
1955        .map_err(|source| CliError::Io { path: head, source })?
1956        .is_file()
1957        && fs::metadata(&config)
1958            .map_err(|source| CliError::Io {
1959                path: config.clone(),
1960                source,
1961            })?
1962            .is_file()
1963        && fs::metadata(&objects)
1964            .map_err(|source| CliError::Io {
1965                path: objects,
1966                source,
1967            })?
1968            .is_dir()
1969        && fs::metadata(&refs)
1970            .map_err(|source| CliError::Io { path: refs, source })?
1971            .is_dir();
1972    if !structurally_git {
1973        return Ok(false);
1974    }
1975    if control_root == root {
1976        return Ok(true);
1977    }
1978
1979    effective_git_config_bare_setting(&control_root, &config).map(|bare| bare.unwrap_or(false))
1980}
1981
1982/// Query Git's effective local `core.bare` value, including configured includes.
1983fn effective_git_config_bare_setting(
1984    control_root: &Path,
1985    config: &Path,
1986) -> Result<Option<bool>, CliError> {
1987    let mut child = StdCommand::new("git")
1988        .arg("--git-dir")
1989        .arg(normalize_native_path_display(control_root))
1990        .args([
1991            "config",
1992            "--local",
1993            "--includes",
1994            "--get",
1995            "--bool",
1996            "core.bare",
1997        ])
1998        .stdin(Stdio::null())
1999        .stdout(Stdio::piped())
2000        .stderr(Stdio::piped())
2001        .spawn()
2002        .map_err(|source| CliError::Io {
2003            path: config.to_path_buf(),
2004            source,
2005        })?;
2006    let deadline = Instant::now() + GIT_CONFIG_QUERY_TIMEOUT;
2007    loop {
2008        match child.try_wait().map_err(|source| CliError::Io {
2009            path: config.to_path_buf(),
2010            source,
2011        })? {
2012            Some(_) => break,
2013            None if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)),
2014            None => {
2015                child.kill().map_err(|source| CliError::Io {
2016                    path: config.to_path_buf(),
2017                    source,
2018                })?;
2019                child.wait().map_err(|source| CliError::Io {
2020                    path: config.to_path_buf(),
2021                    source,
2022                })?;
2023                return Err(CliError::Io {
2024                    path: config.to_path_buf(),
2025                    source: io::Error::new(
2026                        io::ErrorKind::TimedOut,
2027                        "effective Git config query exceeded its deadline",
2028                    ),
2029                });
2030            }
2031        }
2032    }
2033    let output = child.wait_with_output().map_err(|source| CliError::Io {
2034        path: config.to_path_buf(),
2035        source,
2036    })?;
2037    if output.stdout.len().saturating_add(output.stderr.len()) > MAX_GIT_CONFIG_QUERY_OUTPUT_BYTES {
2038        return Err(CliError::Io {
2039            path: config.to_path_buf(),
2040            source: io::Error::new(
2041                io::ErrorKind::InvalidData,
2042                "effective Git config query exceeded its output bound",
2043            ),
2044        });
2045    }
2046    if output.status.success() {
2047        return match std::str::from_utf8(&output.stdout)
2048            .map(str::trim)
2049            .map_err(|source| CliError::Io {
2050                path: config.to_path_buf(),
2051                source: io::Error::new(io::ErrorKind::InvalidData, source),
2052            })? {
2053            "true" => Ok(Some(true)),
2054            "false" => Ok(Some(false)),
2055            value => Err(CliError::Io {
2056                path: config.to_path_buf(),
2057                source: io::Error::new(
2058                    io::ErrorKind::InvalidData,
2059                    format!("Git returned invalid core.bare value {value:?}"),
2060                ),
2061            }),
2062        };
2063    }
2064    if output.status.code() == Some(1) {
2065        return Ok(None);
2066    }
2067    Err(CliError::Io {
2068        path: config.to_path_buf(),
2069        source: io::Error::new(
2070            io::ErrorKind::InvalidData,
2071            format!(
2072                "effective Git config query failed: {}",
2073                String::from_utf8_lossy(&output.stderr).trim()
2074            ),
2075        ),
2076    })
2077}
2078
2079/// Load map configuration for purpose import during scan.
2080pub(crate) fn load_scan_import_config(
2081    config_path: Option<&Path>,
2082    scan_path: &Path,
2083) -> Result<Option<atlas_map::AtlasMapConfig>, CliError> {
2084    if let Some(config_path) = config_path {
2085        return Ok(Some(load_atlas_config(Some(config_path))?));
2086    }
2087    let project_config = scan_path.join(".projectatlas").join("config.toml");
2088    if project_config.exists() {
2089        return Ok(Some(load_atlas_config(Some(&project_config))?));
2090    }
2091    let flat_config = scan_path.join("projectatlas.toml");
2092    if flat_config.exists() {
2093        return Ok(Some(load_atlas_config(Some(&flat_config))?));
2094    }
2095    Ok(None)
2096}
2097
2098/// Open or create a durable index bound to one selected project root.
2099pub(crate) fn open_atlas_store_for_project(
2100    path: &Path,
2101    root: &Path,
2102) -> Result<AtlasStore, CliError> {
2103    open_atlas_store_for_project_with_location_validator(path, root, validate_database_location)
2104}
2105
2106/// Validate storage before creating the database parent and opening the store.
2107fn open_atlas_store_for_project_with_location_validator<F>(
2108    path: &Path,
2109    root: &Path,
2110    validate_location: F,
2111) -> Result<AtlasStore, CliError>
2112where
2113    F: FnOnce(&Path) -> projectatlas_db::DbResult<()>,
2114{
2115    validate_location(path).map_err(project_store_error)?;
2116    ensure_parent_dir(path)?;
2117    AtlasStore::open_for_project(path, root).map_err(project_store_error)
2118}
2119
2120/// Open a current durable index read snapshot bound to one selected root.
2121pub(crate) fn open_atlas_store_read_only_for_project(
2122    path: &Path,
2123    root: &Path,
2124) -> Result<AtlasStore, CliError> {
2125    AtlasStore::open_read_only_for_project(path, root).map_err(project_store_error)
2126}
2127
2128/// Preserve typed selected-root mismatch diagnostics across store adapters.
2129pub(crate) fn project_store_error(source: projectatlas_db::DbError) -> CliError {
2130    match source {
2131        projectatlas_db::DbError::ProjectRootMismatch { expected, found } => {
2132            CliError::ProjectMismatch(Box::new(IndexProjectMismatch {
2133                status: IndexReadStatus::ProjectMismatch,
2134                selected_project_root: expected,
2135                indexed_project_root: found,
2136            }))
2137        }
2138        other => CliError::Db(other),
2139    }
2140}
2141
2142/// Return the config path init should preserve or create for a project root.
2143pub(crate) fn init_config_path(root: &Path, explicit: Option<&Path>) -> PathBuf {
2144    if let Some(config_path) = explicit {
2145        return if config_path.is_absolute() {
2146            config_path.to_path_buf()
2147        } else {
2148            root.join(config_path)
2149        };
2150    }
2151    let nested_config = root.join(".projectatlas").join("config.toml");
2152    if nested_config.exists() {
2153        return nested_config;
2154    }
2155    let flat_config = root.join("projectatlas.toml");
2156    if flat_config.exists() {
2157        return flat_config;
2158    }
2159    nested_config
2160}
2161
2162/// Run the one-call first-run init bootstrap.
2163pub(crate) fn run_init_bootstrap(
2164    root: &Path,
2165    db_path: &Path,
2166    config_path: Option<&Path>,
2167    options: &InitBootstrapOptions,
2168) -> Result<InitSetupReport, CliError> {
2169    let root = canonical_source_project_root(root)?;
2170    let project_dir = root.join(".projectatlas");
2171    let config_file = init_config_path(&root, config_path);
2172    let nonsource_file = project_dir.join("projectatlas-nonsource-files.toon");
2173    let project_dir_existed = project_dir.exists();
2174    let config_existed = config_file.exists();
2175    let nonsource_existed = nonsource_file.exists();
2176    let db_existed = db_path.exists();
2177
2178    init_project_with_config(&root, Some(&config_file))?;
2179    let mut store = open_atlas_store_for_project(db_path, &root)?;
2180
2181    let mut ok = true;
2182    let (scan_status, scan_report, scan_error) = if options.no_scan {
2183        (InitPhaseStatus::Skipped, None, None)
2184    } else {
2185        let symbol_options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None);
2186        let control = index_work_control(&symbol_options);
2187        match ScanRuntimePlan::for_path_controlled(
2188            config_path,
2189            &root,
2190            options.text_index_max_bytes,
2191            &control,
2192        )
2193        .and_then(|plan| run_scan_pipeline_controlled(&mut store, &plan, &symbol_options, &control))
2194        {
2195            Ok(report) => (InitPhaseStatus::Verified, Some(report), None),
2196            Err(error) => {
2197                ok = false;
2198                (InitPhaseStatus::Failed, None, Some(error.to_string()))
2199            }
2200        }
2201    };
2202
2203    let purpose_query = HealthQuery {
2204        start_index: 0,
2205        limit: DEFAULT_HEALTH_LIMIT,
2206        category: None,
2207        severity: None,
2208        path_prefix: None,
2209        summary_only: false,
2210        scope: HealthScope::purpose_default(),
2211    };
2212    let purpose_queue = purpose_curation_page(&store, &purpose_query, "project-init")?;
2213    let next_steps = init_next_steps(options.no_scan, scan_error.is_some(), purpose_queue.total);
2214
2215    Ok(InitSetupReport {
2216        ok,
2217        root: normalize_native_path_display(&root),
2218        project_dir: InitPathStatus {
2219            status: init_path_status(project_dir_existed),
2220            path: normalize_native_path_display(project_dir),
2221        },
2222        config: InitPathStatus {
2223            status: init_path_status(config_existed),
2224            path: normalize_native_path_display(config_file),
2225        },
2226        nonsource_files: InitPathStatus {
2227            status: init_path_status(nonsource_existed),
2228            path: normalize_native_path_display(nonsource_file),
2229        },
2230        db: InitPathStatus {
2231            status: init_path_status(db_existed),
2232            path: normalize_native_path_display(db_path),
2233        },
2234        host_configs: Vec::new(),
2235        scan: InitScanPhase {
2236            status: scan_status,
2237            requested: !options.no_scan,
2238            force_rescan: options.force_rescan,
2239            report: scan_report,
2240            error: scan_error,
2241        },
2242        purpose_handoff: purpose_curator_handoff(purpose_queue),
2243        next_steps,
2244    })
2245}
2246
2247/// Return created/existing status for a path.
2248pub(crate) fn init_path_status(existed: bool) -> InitPhaseStatus {
2249    if existed {
2250        InitPhaseStatus::Exists
2251    } else {
2252        InitPhaseStatus::Created
2253    }
2254}
2255
2256/// Return stable purpose handoff instructions for agent harnesses.
2257fn purpose_handoff_instructions() -> Vec<String> {
2258    vec![
2259        "If the host supports isolated subagents, delegate this actionable low-scope batch at the lowest reasoning tier the host can enforce; otherwise let the main agent process the same bounded rows without blocking navigation.".to_string(),
2260        "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(),
2261        "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(),
2262        "Keep successful curator maintenance out of normal conversation; ProjectAtlas reports a handoff and never claims that the Rust server spawned an agent.".to_string(),
2263    ]
2264}
2265
2266/// Build one host-owned purpose-curator handoff shared by init and session brief.
2267pub(crate) fn purpose_curator_handoff(queue: PurposeCurationPage) -> PurposeCuratorHandoff {
2268    PurposeCuratorHandoff {
2269        agent_harness_expected: true,
2270        execution_owner: "agent_host",
2271        recommended_subagent_reasoning: "lowest_host_enforced",
2272        main_agent_fallback: true,
2273        server_started_curator: false,
2274        silent_on_success: true,
2275        queue,
2276        instructions: purpose_handoff_instructions(),
2277    }
2278}
2279
2280/// Return concise next steps for humans and agents.
2281fn init_next_steps(
2282    scan_skipped: bool,
2283    scan_failed: bool,
2284    purpose_queue_total: usize,
2285) -> Vec<String> {
2286    let mut steps = Vec::new();
2287    if scan_skipped {
2288        steps.push("Run projectatlas scan when you are ready to build the deep index.".to_string());
2289    } else if scan_failed {
2290        steps.push(
2291            "Fix the scan/index error and rerun projectatlas init or projectatlas scan."
2292                .to_string(),
2293        );
2294    }
2295    if purpose_queue_total > 0 {
2296        steps.push(
2297            "Use the purpose_handoff queue to delegate purpose creation/correction at the lowest reasoning tier the host can enforce."
2298                .to_string(),
2299        );
2300    } else {
2301        steps.push("Purpose queue is empty for the default high-impact scope.".to_string());
2302    }
2303    steps.push("Run projectatlas overview to confirm repository orientation.".to_string());
2304    steps
2305}
2306
2307/// Create the parent directory for a path when it has one.
2308pub(crate) fn ensure_parent_dir(path: &Path) -> Result<(), CliError> {
2309    let Some(parent) = path.parent() else {
2310        return Ok(());
2311    };
2312    if parent.as_os_str().is_empty() {
2313        return Ok(());
2314    }
2315    fs::create_dir_all(parent).map_err(|source| CliError::Io {
2316        path: parent.to_path_buf(),
2317        source,
2318    })
2319}
2320
2321/// Build the standard config/root mismatch error.
2322pub(crate) fn config_root_mismatch_error(
2323    config_path: &Path,
2324    config_root: &Path,
2325    selected_root: &Path,
2326) -> CliError {
2327    CliError::InvalidInput(format!(
2328        "ProjectAtlas config '{}' resolves project root '{}' outside selected project root '{}'",
2329        config_path.display(),
2330        config_root.display(),
2331        selected_root.display()
2332    ))
2333}
2334
2335/// Resolve the default MCP project root without trusting the process cwd.
2336pub(crate) fn default_mcp_project_root(
2337    db: &Path,
2338    config_path: Option<&Path>,
2339) -> Result<PathBuf, CliError> {
2340    if let Some(config_path) = config_path {
2341        let config = load_atlas_config(Some(config_path))?;
2342        let config_root = canonical_source_project_root(&config.root)?;
2343        if let Some(db_root) = project_root_from_db_path(db) {
2344            let db_root = canonical_source_project_root(&db_root)?;
2345            if config_root != db_root {
2346                return Err(config_root_mismatch_error(
2347                    config_path,
2348                    &config_root,
2349                    &db_root,
2350                ));
2351            }
2352        }
2353        return Ok(config_root);
2354    }
2355    if db.exists()
2356        && let Some(project_root) = read_project_root_read_only(db)?
2357    {
2358        return canonical_source_project_root(Path::new(&project_root));
2359    }
2360    if let Some(project_root) = project_root_from_db_path(db) {
2361        return canonical_source_project_root(&project_root);
2362    }
2363    let current_dir = std::env::current_dir().map_err(|source| CliError::Io {
2364        path: PathBuf::from("."),
2365        source,
2366    })?;
2367    canonical_source_project_root(&current_dir)
2368}
2369
2370/// Resolve the default CLI project root before opening an implicit database.
2371pub(crate) fn default_cli_project_root(
2372    db: &Path,
2373    config_path: Option<&Path>,
2374    database_path_is_explicit: bool,
2375) -> Result<PathBuf, CliError> {
2376    if !database_path_is_explicit
2377        && config_path.is_none()
2378        && let Some(project_root) = project_root_from_db_path(db)
2379    {
2380        return canonical_source_project_root(&project_root);
2381    }
2382    default_mcp_project_root(db, config_path)
2383}
2384
2385/// Resolve a CLI repository-root argument, using indexed state for the default `.`.
2386pub(crate) fn defaultable_cli_project_root(
2387    path: &Path,
2388    db: &Path,
2389    config_path: Option<&Path>,
2390    database_path_is_explicit: bool,
2391) -> Result<PathBuf, CliError> {
2392    if path == Path::new(".") {
2393        return default_cli_project_root(db, config_path, database_path_is_explicit);
2394    }
2395    Ok(path.to_path_buf())
2396}
2397
2398/// Infer a project root from a default `.projectatlas/projectatlas.db` path.
2399fn project_root_from_db_path(db: &Path) -> Option<PathBuf> {
2400    let parent = db.parent()?;
2401    let cache_dir_name = parent.file_name()?;
2402    if cache_dir_name != ".projectatlas" {
2403        return None;
2404    }
2405    parent
2406        .parent()
2407        .filter(|root| !root.as_os_str().is_empty())
2408        .map_or_else(|| Some(PathBuf::from(".")), |root| Some(root.to_path_buf()))
2409}
2410
2411/// Load scan options for a project root from `ProjectAtlas` config when present.
2412pub(crate) fn scan_options_for_root(
2413    config_path: Option<&Path>,
2414    root: &Path,
2415) -> Result<ScanOptions, CliError> {
2416    Ok(load_scan_import_config(config_path, root)?
2417        .as_ref()
2418        .map_or_else(
2419            ScanOptions::default,
2420            atlas_map::AtlasMapConfig::scan_options,
2421        ))
2422}
2423
2424/// Resolve text-index persistence options from command override and config.
2425pub(crate) fn text_index_options(
2426    config: Option<&atlas_map::AtlasMapConfig>,
2427    max_bytes_override: Option<u64>,
2428) -> TextIndexOptions {
2429    let max_bytes = max_bytes_override
2430        .filter(|value| *value > 0)
2431        .or_else(|| config.map(atlas_map::AtlasMapConfig::text_index_max_bytes))
2432        .unwrap_or(atlas_map::DEFAULT_TEXT_INDEX_MAX_BYTES);
2433    TextIndexOptions::new(max_bytes)
2434}
2435
2436/// Capture the last complete generation used as a publication compare-and-swap anchor.
2437fn publication_base_generation(store: &AtlasStore) -> Result<IndexGeneration, CliError> {
2438    Ok(store
2439        .index_publication()?
2440        .map_or(IndexGeneration::ZERO, |publication| publication.generation))
2441}
2442
2443/// Prepare a complete source/index batch without acquiring the `SQLite` writer.
2444fn stage_full_index_publication(
2445    store: &AtlasStore,
2446    plan: &ScanRuntimePlan,
2447    symbol_options: &SymbolBuildOptions,
2448    reuse_unchanged_symbols: bool,
2449    import_legacy_purposes: bool,
2450    control: &IndexWorkControl,
2451) -> Result<IndexPublicationBatch, CliError> {
2452    let base_generation = publication_base_generation(store)?;
2453    let contract_fingerprint = plan.publication_contract_fingerprint();
2454    let previous_hashes = reuse_unchanged_symbols
2455        .then(|| indexed_file_hashes(store))
2456        .transpose()?;
2457    let nodes = scan_repo_controlled(
2458        &plan.root,
2459        &plan.scan_options,
2460        ScanLimits::default(),
2461        control,
2462    )
2463    .map_err(|source| source_inspection_error(&plan.root, source))?;
2464    control.check(IndexWorkStage::Publication)?;
2465    let purpose_import = import_legacy_purposes
2466        .then(|| plan.purpose_import_snapshot_controlled(&nodes, control))
2467        .transpose()?;
2468    let protected_purpose_paths = protected_purpose_paths(&nodes, purpose_import.as_ref());
2469    let text_paths = nodes
2470        .iter()
2471        .filter(|node| node.kind == NodeKind::File)
2472        .map(|node| node.path.clone())
2473        .collect::<Vec<_>>();
2474    let text = stage_text_index_for_changed_paths_controlled(
2475        &plan.root,
2476        &nodes,
2477        plan.text_options,
2478        control,
2479    )?;
2480    let retained_before_symbols =
2481        staged_publication_identity_bytes(&plan.root, &contract_fingerprint)
2482            .saturating_add(staged_string_bytes(&text_paths))
2483            .saturating_add(staged_node_bytes(&nodes))
2484            .saturating_add(staged_text_bytes(&text))
2485            .saturating_add(staged_purpose_bytes(purpose_import.as_ref()));
2486    let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
2487    let symbols = stage_symbols_for_nodes_with_limits(
2488        store,
2489        &plan.root,
2490        #[cfg(feature = "optional-parser-supervisor")]
2491        &plan.optional_parser_selection,
2492        &nodes,
2493        symbol_options,
2494        previous_hashes.as_ref(),
2495        None,
2496        &protected_purpose_paths,
2497        control,
2498        symbol_limits,
2499    )?;
2500    let graph = graph_projection::stage_full_repository_graph(
2501        store,
2502        &plan.root,
2503        base_generation,
2504        &nodes,
2505        &symbols,
2506        control,
2507    )?;
2508    let structural_summaries = stage_structural_summaries_for_nodes_controlled(
2509        store,
2510        &nodes,
2511        &text.rows,
2512        Some(&symbols),
2513        &protected_purpose_paths,
2514        symbol_options.effective_workers(),
2515        control,
2516    )?;
2517    enforce_publication_staging_budget(
2518        retained_before_symbols
2519            .saturating_add(symbols.retained_bytes)
2520            .saturating_add(graph.retained_bytes())
2521            .saturating_add(structural_summaries.retained_bytes),
2522    )?;
2523    Ok(IndexPublicationBatch {
2524        base_generation,
2525        contract_fingerprint,
2526        root: plan.root.clone(),
2527        nodes: NodePublicationBatch::Full { nodes },
2528        purpose_import,
2529        text_paths,
2530        text,
2531        symbols,
2532        graph,
2533        structural_summaries,
2534    })
2535}
2536
2537/// Return paths whose reviewed or built-in purpose must suppress generated suggestions.
2538fn protected_purpose_paths(
2539    nodes: &[Node],
2540    purpose_import: Option<&PurposeImportSnapshot>,
2541) -> HashSet<String> {
2542    let indexed_paths = nodes
2543        .iter()
2544        .map(|node| node.path.as_str())
2545        .collect::<HashSet<_>>();
2546    let mut protected = BUILTIN_PROJECTATLAS_PURPOSES
2547        .iter()
2548        .filter(|(path, _purpose)| indexed_paths.contains(*path))
2549        .map(|(path, _purpose)| (*path).to_string())
2550        .collect::<HashSet<_>>();
2551    if let Some(snapshot) = purpose_import {
2552        protected.extend(
2553            snapshot
2554                .records
2555                .iter()
2556                .filter(|record| indexed_paths.contains(record.path.as_str()))
2557                .map(|record| record.path.clone()),
2558        );
2559    }
2560    protected
2561}
2562
2563/// Count retained node string bytes for one bounded in-memory publication batch.
2564fn staged_node_bytes(nodes: &[Node]) -> u64 {
2565    nodes.iter().fold(0_u64, |bytes, node| {
2566        bytes
2567            .saturating_add(node.path.len() as u64)
2568            .saturating_add(
2569                node.parent_path
2570                    .as_ref()
2571                    .map_or(0, |value| value.len() as u64),
2572            )
2573            .saturating_add(
2574                node.extension
2575                    .as_ref()
2576                    .map_or(0, |value| value.len() as u64),
2577            )
2578            .saturating_add(node.language.as_ref().map_or(0, |value| value.len() as u64))
2579            .saturating_add(
2580                node.content_hash
2581                    .as_ref()
2582                    .map_or(0, |value| value.len() as u64),
2583            )
2584    })
2585}
2586
2587/// Count retained persisted-text strings for one staged batch.
2588fn staged_text_bytes(text: &TextIndexRefresh) -> u64 {
2589    text.rows.iter().fold(0_u64, |bytes, row| {
2590        let bytes = bytes.saturating_add(row.path.len() as u64);
2591        row.text.as_ref().map_or(bytes, |text| {
2592            bytes
2593                .saturating_add(text.path.len() as u64)
2594                .saturating_add(
2595                    text.content_hash
2596                        .as_ref()
2597                        .map_or(0, |value| value.len() as u64),
2598                )
2599                .saturating_add(text.content.len() as u64)
2600        })
2601    })
2602}
2603
2604/// Count retained legacy-purpose strings for one staged batch.
2605fn staged_purpose_bytes(purpose_import: Option<&PurposeImportSnapshot>) -> u64 {
2606    purpose_import.map_or(0, |snapshot| {
2607        snapshot
2608            .records
2609            .iter()
2610            .fold(snapshot.fingerprint.len() as u64, |bytes, record| {
2611                bytes
2612                    .saturating_add(record.path.len() as u64)
2613                    .saturating_add(record.summary.len() as u64)
2614            })
2615    })
2616}
2617
2618/// Count retained strings duplicated into one publication batch.
2619fn staged_string_bytes(values: &[String]) -> u64 {
2620    values.iter().fold(0_u64, |bytes, value| {
2621        bytes.saturating_add(value.len() as u64)
2622    })
2623}
2624
2625/// Count the selected root and derivation identity retained by one batch.
2626fn staged_publication_identity_bytes(root: &Path, contract_fingerprint: &str) -> u64 {
2627    (normalize_native_path_display(root).len() as u64)
2628        .saturating_add(contract_fingerprint.len() as u64)
2629}
2630
2631/// Restrict parser output to the remaining aggregate publication-staging budget.
2632fn symbol_limits_with_remaining_staging_bytes(
2633    retained_bytes: u64,
2634) -> Result<SymbolPublicationLimits, CliError> {
2635    enforce_publication_staging_budget(retained_bytes)?;
2636    Ok(SymbolPublicationLimits {
2637        output_bytes: SymbolPublicationLimits::STANDARD
2638            .output_bytes
2639            .min(MAX_PUBLICATION_STAGING_BYTES.saturating_sub(retained_bytes)),
2640        ..SymbolPublicationLimits::STANDARD
2641    })
2642}
2643
2644/// Fail before writer acquisition when retained publication state exceeds its budget.
2645fn enforce_publication_staging_budget(retained_bytes: u64) -> Result<(), CliError> {
2646    if retained_bytes > MAX_PUBLICATION_STAGING_BYTES {
2647        return Err(IndexWorkFailure::resource_limit(
2648            IndexWorkStage::Publication,
2649            IndexWorkResource::OutputBytes,
2650            MAX_PUBLICATION_STAGING_BYTES,
2651            retained_bytes,
2652        )
2653        .into());
2654    }
2655    Ok(())
2656}
2657
2658/// Build the complete expected source state after one normalized incremental delta.
2659fn expected_nodes_after_incremental(
2660    baseline_nodes: Vec<Node>,
2661    changed_nodes: &[Node],
2662    absent_paths: &[String],
2663) -> Vec<Node> {
2664    let absent_paths = absent_paths
2665        .iter()
2666        .map(String::as_str)
2667        .filter(|path| !matches!(*path, "" | "."))
2668        .collect::<HashSet<_>>();
2669    let mut expected = baseline_nodes
2670        .into_iter()
2671        .filter(|node| !repository_path_is_absent(&node.path, &absent_paths))
2672        .map(|node| (node.path.clone(), node))
2673        .collect::<BTreeMap<_, _>>();
2674    for node in changed_nodes {
2675        expected.insert(node.path.clone(), node.clone());
2676    }
2677    expected.into_values().collect()
2678}
2679
2680/// Match an exact absent repository key or one of its slash-delimited ancestors.
2681fn repository_path_is_absent(path: &str, absent_paths: &HashSet<&str>) -> bool {
2682    absent_paths.contains(path)
2683        || path
2684            .match_indices('/')
2685            .any(|(separator, _)| absent_paths.contains(&path[..separator]))
2686}
2687
2688/// Apply one fully prepared index batch in a short generation-checked transaction.
2689fn publish_index_batch(
2690    store: &mut AtlasStore,
2691    batch: IndexPublicationBatch,
2692    control: &IndexWorkControl,
2693) -> Result<IndexPublicationOutcome, CliError> {
2694    control.check(IndexWorkStage::Publication)?;
2695    let IndexPublicationBatch {
2696        base_generation,
2697        contract_fingerprint,
2698        root,
2699        nodes,
2700        purpose_import,
2701        text_paths,
2702        text,
2703        symbols,
2704        graph,
2705        structural_summaries,
2706    } = batch;
2707    let mut publication =
2708        store.begin_index_publication_from(&contract_fingerprint, base_generation)?;
2709    publication.set_project_root(&root)?;
2710    let indexed_nodes = match nodes {
2711        NodePublicationBatch::Full { nodes } => {
2712            publication.begin_scan_replacement()?;
2713            for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
2714                control.check(IndexWorkStage::Publication)?;
2715                publication.upsert_scan_node_batch(batch)?;
2716            }
2717            control.check(IndexWorkStage::Publication)?;
2718            publication.finish_scan_replacement()?;
2719            nodes
2720        }
2721        NodePublicationBatch::Incremental {
2722            nodes,
2723            absent_paths,
2724            expected_nodes: _,
2725        } => {
2726            for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
2727                control.check(IndexWorkStage::Publication)?;
2728                publication.upsert_scan_node_batch(batch)?;
2729            }
2730            for batch in absent_paths.chunks(PUBLICATION_PATH_BATCH_SIZE) {
2731                control.check(IndexWorkStage::Publication)?;
2732                publication.mark_paths_absent(batch)?;
2733            }
2734            nodes
2735        }
2736    };
2737    seed_builtin_projectatlas_purposes(&publication, &indexed_nodes)?;
2738    apply_text_index_stage(&mut publication, &text_paths, &text, control)?;
2739    let purpose_import = purpose_import.map_or_else(
2740        || Ok(PurposeImportReport::default()),
2741        |snapshot| apply_purpose_import_snapshot(&publication, &indexed_nodes, &snapshot, control),
2742    )?;
2743    apply_symbol_build_stage(&mut publication, &symbols, control)?;
2744    graph.apply(&mut publication, control)?;
2745    apply_structural_summary_stage(&mut publication, &structural_summaries, control)?;
2746    complete_index_publication(publication, control)?;
2747    Ok(IndexPublicationOutcome {
2748        purpose_import,
2749        text_index: text.report,
2750        structural_summaries: structural_summaries.report,
2751        symbols: symbols.report,
2752    })
2753}
2754
2755/// Apply staged legacy-purpose rows without overwriting current reviewed intent.
2756fn apply_purpose_import_snapshot(
2757    store: &AtlasStore,
2758    nodes: &[Node],
2759    snapshot: &PurposeImportSnapshot,
2760    control: &IndexWorkControl,
2761) -> Result<PurposeImportReport, CliError> {
2762    let indexed_paths = nodes
2763        .iter()
2764        .map(|node| node.path.as_str())
2765        .collect::<HashSet<_>>();
2766    let mut report = PurposeImportReport::default();
2767    for record in &snapshot.records {
2768        control.check(IndexWorkStage::Publication)?;
2769        if !indexed_paths.contains(record.path.as_str()) {
2770            report.skipped_stale += 1;
2771            continue;
2772        }
2773        let Some(indexed) = store.load_node_by_path(&record.path)? else {
2774            report.skipped_stale += 1;
2775            continue;
2776        };
2777        if matches!(
2778            indexed.purpose.status,
2779            PurposeStatus::Approved | PurposeStatus::Stale
2780        ) {
2781            report.skipped_existing += 1;
2782            continue;
2783        }
2784        store.set_purpose(&record.path, &record.summary, PurposeSource::Imported)?;
2785        report.imported += 1;
2786    }
2787    Ok(report)
2788}
2789
2790/// Execute the full scan/index/symbol pipeline for a resolved project plan.
2791#[cfg(test)]
2792pub(crate) fn run_scan_pipeline(
2793    store: &mut AtlasStore,
2794    plan: &ScanRuntimePlan,
2795    symbol_options: &SymbolBuildOptions,
2796) -> Result<ScanReport, CliError> {
2797    let control = index_work_control(symbol_options);
2798    run_scan_pipeline_controlled(store, plan, symbol_options, &control)
2799}
2800
2801/// Execute the full pipeline under one cancellation and resource boundary.
2802pub(crate) fn run_scan_pipeline_controlled(
2803    store: &mut AtlasStore,
2804    plan: &ScanRuntimePlan,
2805    symbol_options: &SymbolBuildOptions,
2806    control: &IndexWorkControl,
2807) -> Result<ScanReport, CliError> {
2808    let bounded_control = bounded_index_work_control(control);
2809    let control = &bounded_control;
2810    let batch = stage_full_index_publication(store, plan, symbol_options, false, true, control)?;
2811    revalidate_staged_publication_inputs_with_purpose_snapshot(
2812        plan,
2813        batch.nodes.expected_nodes(),
2814        batch.purpose_import.as_ref(),
2815        control,
2816    )?;
2817    let outcome = publish_index_batch(store, batch, control)?;
2818    let overview = store.overview()?;
2819    Ok(ScanReport {
2820        overview,
2821        purpose_import: outcome.purpose_import,
2822        text_index: outcome.text_index,
2823        structural_summaries: outcome.structural_summaries,
2824        symbols: outcome.symbols,
2825    })
2826}
2827
2828/// Rebuild symbol projections while keeping incomplete work non-queryable.
2829#[cfg(test)]
2830pub(crate) fn run_symbol_build_pipeline(
2831    store: &mut AtlasStore,
2832    plan: &ScanRuntimePlan,
2833    symbol_options: &SymbolBuildOptions,
2834    previous_hashes: Option<&HashMap<String, String>>,
2835) -> Result<SymbolBuildReport, CliError> {
2836    let control = index_work_control(symbol_options);
2837    run_symbol_build_pipeline_controlled(store, plan, symbol_options, previous_hashes, &control)
2838}
2839
2840/// Rebuild symbol projections under one cancellation and publication boundary.
2841pub(crate) fn run_symbol_build_pipeline_controlled(
2842    store: &mut AtlasStore,
2843    plan: &ScanRuntimePlan,
2844    symbol_options: &SymbolBuildOptions,
2845    previous_hashes: Option<&HashMap<String, String>>,
2846    control: &IndexWorkControl,
2847) -> Result<SymbolBuildReport, CliError> {
2848    let bounded_control = bounded_index_work_control(control);
2849    let control = &bounded_control;
2850    control.check(IndexWorkStage::SymbolParsing)?;
2851    verify_index_project_root(store, &plan.root)?;
2852    verify_index_publication(store, plan)?;
2853    let base_generation = publication_base_generation(store)?;
2854    let nodes = store
2855        .load_nodes()?
2856        .into_iter()
2857        .map(|indexed| indexed.node)
2858        .collect::<Vec<_>>();
2859    let contract_fingerprint = plan.publication_contract_fingerprint();
2860    let retained_before_symbols =
2861        staged_publication_identity_bytes(&plan.root, &contract_fingerprint)
2862            .saturating_add(staged_node_bytes(&nodes));
2863    let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
2864    let staged = stage_symbols_for_nodes_with_limits(
2865        store,
2866        &plan.root,
2867        #[cfg(feature = "optional-parser-supervisor")]
2868        &plan.optional_parser_selection,
2869        &nodes,
2870        symbol_options,
2871        previous_hashes,
2872        None,
2873        &HashSet::new(),
2874        control,
2875        symbol_limits,
2876    )?;
2877    let graph = graph_projection::stage_full_repository_graph(
2878        store,
2879        &plan.root,
2880        base_generation,
2881        &nodes,
2882        &staged,
2883        control,
2884    )?;
2885    enforce_publication_staging_budget(
2886        retained_before_symbols
2887            .saturating_add(staged.retained_bytes)
2888            .saturating_add(graph.retained_bytes()),
2889    )?;
2890    revalidate_staged_publication_inputs_controlled(plan, &nodes, None, control)?;
2891    control.check(IndexWorkStage::Publication)?;
2892    let mut publication =
2893        store.begin_index_projection_refresh_from(&contract_fingerprint, base_generation)?;
2894    apply_symbol_build_stage(&mut publication, &staged, control)?;
2895    graph.apply(&mut publication, control)?;
2896    complete_index_publication(publication, control)?;
2897    Ok(staged.report)
2898}
2899
2900/// One optional telemetry identity owned by a CLI invocation or MCP process.
2901#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2902pub(crate) struct UsageRuntimeInstance {
2903    /// Opaque runtime-owned identity persisted with usage events.
2904    id: UsageInstanceId,
2905    /// Adapter lifecycle that owns sealing this identity.
2906    owner: UsageInstanceOwner,
2907}
2908
2909impl UsageRuntimeInstance {
2910    /// Create an opaque runtime identity when operating-system entropy is available.
2911    #[must_use]
2912    pub(crate) fn new(owner: UsageInstanceOwner) -> Option<Self> {
2913        let mut bytes = [0u8; 16];
2914        getrandom::fill(&mut bytes).ok()?;
2915        UsageInstanceId::from_bytes(bytes)
2916            .ok()
2917            .map(|id| Self { id, owner })
2918    }
2919
2920    /// Record one event using the lifecycle implied by this runtime owner.
2921    fn record(
2922        self,
2923        store: &AtlasStore,
2924        event: &projectatlas_core::telemetry::UsageEvent,
2925    ) -> Result<(), CliError> {
2926        store.record_usage_for_instance(
2927            self.id,
2928            self.owner,
2929            event,
2930            matches!(self.owner, UsageInstanceOwner::CliInvocation),
2931        )?;
2932        Ok(())
2933    }
2934
2935    /// Seal this runtime instance in one selected project database.
2936    pub(crate) fn seal(self, store: &AtlasStore) -> Result<(), CliError> {
2937        store.seal_usage_instance(self.id)?;
2938        Ok(())
2939    }
2940}
2941
2942/// Record a usage event from a fast baseline estimate and actual atlas payload.
2943pub(crate) fn record_usage_estimate(
2944    store: &AtlasStore,
2945    usage_instance: Option<UsageRuntimeInstance>,
2946    session: &str,
2947    command: &str,
2948    path: Option<String>,
2949    query: Option<String>,
2950    estimated_without_projectatlas: usize,
2951    projectatlas_text: &str,
2952) -> Result<(), CliError> {
2953    record_usage_estimate_with_context(
2954        store,
2955        usage_instance,
2956        session,
2957        command,
2958        path,
2959        query,
2960        estimated_without_projectatlas,
2961        projectatlas_text,
2962        TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
2963        TOKEN_BASELINE_SELECTED_CANDIDATES,
2964        TOKEN_CONFIDENCE_INFERRED,
2965    )
2966}
2967
2968/// Record a usage event from a fast baseline estimate and explicit baseline semantics.
2969#[allow(clippy::too_many_arguments)]
2970pub(crate) fn record_usage_estimate_with_context(
2971    store: &AtlasStore,
2972    usage_instance: Option<UsageRuntimeInstance>,
2973    session: &str,
2974    command: &str,
2975    path: Option<String>,
2976    query: Option<String>,
2977    estimated_without_projectatlas: usize,
2978    projectatlas_text: &str,
2979    token_savings_bucket: &str,
2980    baseline_kind: &str,
2981    confidence: &str,
2982) -> Result<(), CliError> {
2983    let Some(usage_instance) = usage_instance.filter(|_| !telemetry_disabled()) else {
2984        return Ok(());
2985    };
2986    store.finish_index_read_snapshot()?;
2987    usage_instance.record(
2988        store,
2989        &usage_from_estimates_with_context(
2990            session,
2991            command,
2992            path,
2993            query,
2994            estimated_without_projectatlas,
2995            estimate_tokens(projectatlas_text),
2996            token_savings_bucket,
2997            baseline_kind,
2998            confidence,
2999        ),
3000    )?;
3001    Ok(())
3002}
3003
3004/// Record a broad directory-walk avoidance estimate.
3005pub(crate) fn record_directory_walk_usage_estimate(
3006    store: &AtlasStore,
3007    usage_instance: Option<UsageRuntimeInstance>,
3008    session: &str,
3009    command: &str,
3010    path: Option<String>,
3011    query: Option<String>,
3012    estimated_without_projectatlas: usize,
3013    projectatlas_text: &str,
3014) -> Result<(), CliError> {
3015    record_usage_estimate_with_context(
3016        store,
3017        usage_instance,
3018        session,
3019        command,
3020        path,
3021        query,
3022        estimated_without_projectatlas,
3023        projectatlas_text,
3024        TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
3025        TOKEN_BASELINE_DIRECTORY_WALK,
3026        TOKEN_CONFIDENCE_POLICY_ESTIMATE,
3027    )
3028}
3029
3030/// Record a usage event from baseline and emitted text unless telemetry is disabled.
3031pub(crate) fn record_usage_text(
3032    store: &AtlasStore,
3033    usage_instance: Option<UsageRuntimeInstance>,
3034    session: &str,
3035    command: &str,
3036    path: Option<String>,
3037    query: Option<String>,
3038    baseline_text: &str,
3039    projectatlas_text: &str,
3040) -> Result<(), CliError> {
3041    let Some(usage_instance) = usage_instance.filter(|_| !telemetry_disabled()) else {
3042        return Ok(());
3043    };
3044    store.finish_index_read_snapshot()?;
3045    usage_instance.record(
3046        store,
3047        &usage_from_text(
3048            session,
3049            command,
3050            path,
3051            query,
3052            baseline_text,
3053            projectatlas_text,
3054        ),
3055    )?;
3056    Ok(())
3057}
3058
3059/// Return whether telemetry writes are disabled for read-only review contexts.
3060pub(crate) fn telemetry_disabled() -> bool {
3061    truthy_env("PROJECTATLAS_NO_TELEMETRY")
3062}
3063
3064/// Estimate broad source tokens represented by indexed files with SQL aggregates.
3065pub(crate) fn estimated_source_tokens_for_indexed_files(
3066    store: &AtlasStore,
3067    folder: Option<&str>,
3068    file_pattern: Option<&str>,
3069) -> Result<usize, CliError> {
3070    let matcher = FilePathMatcher::new(file_pattern)?;
3071    let mut total = 0usize;
3072    store.visit_file_token_estimates(folder, |path, size_bytes| {
3073        if matcher.is_match(&path) {
3074            total =
3075                total.saturating_add(estimated_source_tokens_for_file_metadata(&path, size_bytes));
3076        }
3077        Ok(true)
3078    })?;
3079    Ok(total)
3080}
3081
3082/// Estimate source tokens for one indexed file without reading it.
3083pub(crate) fn estimated_source_tokens_for_file_node(node: &Node) -> usize {
3084    estimated_source_tokens_for_file_metadata(&node.path, node.size_bytes)
3085}
3086
3087/// Estimate source tokens for persisted file metadata.
3088pub(crate) fn estimated_source_tokens_for_file_metadata(
3089    path: &str,
3090    size_bytes: Option<u64>,
3091) -> usize {
3092    size_bytes.map_or_else(|| estimate_tokens(path), byte_size_to_tokens)
3093}
3094
3095/// Estimate source tokens from a byte count with the shared token heuristic.
3096pub(crate) fn byte_size_to_tokens(bytes: u64) -> usize {
3097    let token_estimate = bytes.div_ceil(4);
3098    usize::try_from(token_estimate).unwrap_or(usize::MAX)
3099}
3100
3101/// Estimate source tokens from a searched byte count.
3102pub(crate) fn byte_count_to_tokens(bytes: usize) -> usize {
3103    if bytes == 0 { 0 } else { bytes.div_ceil(4) }
3104}
3105
3106/// Load ranked folder nodes with concise reasons.
3107pub(crate) fn ranked_folder_nodes_with_reasons(
3108    store: &AtlasStore,
3109    query: &str,
3110    limit: usize,
3111) -> Result<Vec<projectatlas_core::RankedNode>, CliError> {
3112    Ok(load_ranked_folder_nodes_with_reasons(store, query, limit)?)
3113}
3114
3115/// Load ranked file nodes with concise reasons.
3116pub(crate) fn ranked_file_nodes_with_reasons(
3117    store: &AtlasStore,
3118    query: &str,
3119    folder: Option<&str>,
3120    file_pattern: Option<&str>,
3121    limit: usize,
3122    include_content: bool,
3123) -> Result<Vec<projectatlas_core::RankedNode>, CliError> {
3124    Ok(load_ranked_file_nodes_with_reasons(
3125        store,
3126        query,
3127        folder,
3128        file_pattern,
3129        limit,
3130        include_content,
3131    )?)
3132}
3133
3134/// Build a next-step recommendation report from indexed metadata.
3135pub(crate) fn next_step_report(
3136    store: &AtlasStore,
3137    query: &str,
3138    limit: Option<usize>,
3139) -> Result<NextStepReport, CliError> {
3140    Ok(build_next_report(store, query, limit)?)
3141}
3142
3143/// Build the flattened agent-facing next-step payload.
3144pub(crate) fn next_step_report_payload(report: &NextStepReport) -> Value {
3145    json!({
3146        "query": &report.query,
3147        "folders": render_ranked_node_rows("folders", &report.folders),
3148        "files": render_ranked_node_rows("files", &report.files),
3149        "suggestions": &report.suggestions,
3150    })
3151}
3152
3153/// Agent-facing purpose curation queue with bounded health metadata.
3154#[derive(Debug, Serialize)]
3155#[allow(
3156    clippy::struct_excessive_bools,
3157    reason = "serialized queue paging and scope fields are independent wire facts"
3158)]
3159pub(crate) struct PurposeCurationPage {
3160    /// Selected project identity bound into every work key.
3161    pub(crate) project_instance_id: String,
3162    /// Active generation bound into every work key and state token.
3163    pub(crate) active_generation: u64,
3164    /// Host-owned task label for this bounded batch.
3165    pub(crate) task: String,
3166    /// Deterministic identity for the complete returned batch.
3167    pub(crate) work_key: String,
3168    /// Whether this page contains work a host or main agent can process.
3169    pub(crate) actionable: bool,
3170    /// Purpose policy scope; automatic handoffs are always low scope.
3171    pub(crate) curation_scope: &'static str,
3172    /// Findings after filters are applied.
3173    pub(crate) total: usize,
3174    /// Findings before filters are applied, after resolved findings are removed.
3175    pub(crate) unfiltered_total: usize,
3176    /// Findings returned in this page.
3177    pub(crate) returned: usize,
3178    /// Pagination start index used for this page.
3179    pub(crate) start_index: usize,
3180    /// Maximum findings requested for this page.
3181    pub(crate) limit: usize,
3182    /// Maximum allowed page size.
3183    pub(crate) max_limit: usize,
3184    /// Next start index when more rows are available.
3185    pub(crate) next_start_index: Option<usize>,
3186    /// Whether more rows are available.
3187    pub(crate) truncated: bool,
3188    /// Whether the queue is restricted to source-relevant paths.
3189    pub(crate) source_only: bool,
3190    /// Folder scope included in the queue.
3191    pub(crate) folder_scope: String,
3192    /// File scope included in the queue.
3193    pub(crate) file_scope: String,
3194    /// Applied category filter.
3195    pub(crate) category: String,
3196    /// Applied severity filter.
3197    pub(crate) severity: String,
3198    /// Applied path-prefix filter.
3199    pub(crate) path_prefix: String,
3200    /// Whether rows were intentionally omitted.
3201    pub(crate) summary_only: bool,
3202    /// Queue items that need agent inspection or approval.
3203    pub(crate) items: Vec<PurposeCurationItem>,
3204}
3205
3206/// One path that needs purpose curation.
3207#[derive(Debug, Serialize)]
3208pub(crate) struct PurposeCurationItem {
3209    /// Deterministic project/generation/task/path identity for duplicate coalescing.
3210    pub(crate) work_key: String,
3211    /// Opaque current-row token required for stale-safe conditional review.
3212    pub(crate) state_token: String,
3213    /// Finding severity.
3214    pub(crate) severity: String,
3215    /// Stable health finding id.
3216    pub(crate) id: String,
3217    /// Health finding category.
3218    pub(crate) category: String,
3219    /// Indexed repository-relative path.
3220    pub(crate) path: String,
3221    /// Related path for structural findings.
3222    pub(crate) related_path: String,
3223    /// Node kind when the path is still indexed.
3224    pub(crate) kind: String,
3225    /// Detected language for source files.
3226    pub(crate) language: String,
3227    /// Current approved or suggested purpose text.
3228    pub(crate) purpose: String,
3229    /// Purpose lifecycle status.
3230    pub(crate) purpose_status: String,
3231    /// Purpose source.
3232    pub(crate) purpose_source: String,
3233    /// Whether an agent explicitly reviewed or set this purpose.
3234    pub(crate) purpose_agent_reviewed: bool,
3235    /// Priority for agent curation.
3236    pub(crate) review_priority: String,
3237    /// Stable reason explaining the priority.
3238    pub(crate) review_reason: String,
3239    /// Current deterministic content summary.
3240    pub(crate) content_summary: String,
3241    /// Recommended agent action.
3242    pub(crate) recommendation: String,
3243}
3244
3245/// One agent-reviewed purpose update requested by a batch review.
3246#[derive(Clone, Debug, Deserialize, Serialize)]
3247pub(crate) struct PurposeReviewRequest {
3248    /// Indexed repository-relative path.
3249    pub(crate) path: String,
3250    /// Agent-reviewed purpose one-liner. Required for generated suggestions.
3251    #[serde(default)]
3252    pub(crate) purpose: Option<String>,
3253    /// Confirm the currently stored non-generated purpose after inspection.
3254    #[serde(default)]
3255    pub(crate) confirm_existing: bool,
3256    /// Queue task copied from the selected purpose-curation batch.
3257    #[serde(default)]
3258    pub(crate) task: Option<String>,
3259    /// Queue item work key copied without modification.
3260    #[serde(default)]
3261    pub(crate) work_key: Option<String>,
3262    /// Queue item state token copied without modification.
3263    #[serde(default)]
3264    pub(crate) state_token: Option<String>,
3265}
3266
3267/// Batch purpose review result.
3268#[derive(Debug, Serialize)]
3269pub(crate) struct PurposeReviewReport {
3270    /// Whether the review changed the database.
3271    pub(crate) applied: bool,
3272    /// Number of requested review rows.
3273    pub(crate) total: usize,
3274    /// Number of rows changed when applied or that would change in dry-run.
3275    pub(crate) changed: usize,
3276    /// Number of rows skipped because they were already agent-reviewed with the same purpose.
3277    pub(crate) skipped: usize,
3278    /// Number of accepted, stale, or unavailable rows left unchanged.
3279    pub(crate) conflicts: usize,
3280    /// Number of rows that could not be reviewed.
3281    pub(crate) failed: usize,
3282    /// Per-path review details.
3283    pub(crate) items: Vec<PurposeReviewItem>,
3284}
3285
3286/// Per-path batch review result.
3287#[derive(Debug, Serialize)]
3288pub(crate) struct PurposeReviewItem {
3289    /// Indexed repository-relative path.
3290    pub(crate) path: String,
3291    /// Action selected for this path.
3292    pub(crate) action: PurposeReviewAction,
3293    /// Current purpose lifecycle status.
3294    pub(crate) current_status: String,
3295    /// Current purpose source.
3296    pub(crate) current_source: String,
3297    /// Purpose that will be or was written.
3298    pub(crate) purpose: String,
3299    /// Validation or persistence error.
3300    pub(crate) error: String,
3301}
3302
3303/// Stable purpose-review action values.
3304#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
3305#[serde(rename_all = "kebab-case")]
3306pub(crate) enum PurposeReviewAction {
3307    /// The item failed validation.
3308    Error,
3309    /// The existing reviewed purpose already matches.
3310    Skip,
3311    /// The reviewed purpose was applied.
3312    Review,
3313    /// The reviewed purpose would be applied in preview mode.
3314    WouldReview,
3315    /// Project, generation, task, path, or row state changed after queue selection.
3316    Stale,
3317    /// The path now carries accepted authored intent and was not overwritten.
3318    Accepted,
3319    /// The selected path is no longer active in the index.
3320    Unavailable,
3321}
3322
3323/// Validate and optionally apply a batch of agent-reviewed purpose records.
3324pub(crate) fn review_purposes(
3325    store: &AtlasStore,
3326    requests: &[PurposeReviewRequest],
3327    apply: bool,
3328) -> Result<PurposeReviewReport, CliError> {
3329    validate_purpose_review_admission(requests)?;
3330    let has_conditional_fields = requests.iter().any(has_conditional_purpose_review_field);
3331    if apply
3332        && has_conditional_fields
3333        && !requests.iter().all(is_complete_conditional_purpose_review)
3334    {
3335        return Err(CliError::InvalidInput(
3336            "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"
3337                .to_string(),
3338        ));
3339    }
3340
3341    // Explicit correction remains item-oriented. Preflight every row and the
3342    // complete report before the first write so an admission failure cannot
3343    // partially apply an otherwise valid batch.
3344    if apply && !has_conditional_fields {
3345        let preview = collect_purpose_reviews(store, requests, false)?;
3346        validate_purpose_review_report(&preview)?;
3347    }
3348
3349    let report = if apply && has_conditional_fields {
3350        apply_conditional_purpose_reviews(store, requests)?
3351    } else {
3352        collect_purpose_reviews(store, requests, apply)?
3353    };
3354    validate_purpose_review_report(&report)?;
3355    Ok(report)
3356}
3357
3358/// Enforce shared CLI/MCP purpose-review request limits before database work.
3359pub(crate) fn validate_purpose_review_admission(
3360    requests: &[PurposeReviewRequest],
3361) -> Result<(), CliError> {
3362    if requests.is_empty() {
3363        return Err(CliError::InvalidInput(
3364            "purpose review input must contain at least one item".to_string(),
3365        ));
3366    }
3367    if requests.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
3368        return Err(CliError::InvalidInput(format!(
3369            "purpose review input contains {} items; maximum is {}",
3370            requests.len(),
3371            MAX_PURPOSE_CURATION_BATCH_ROWS
3372        )));
3373    }
3374
3375    let mut aggregate_bytes = 0usize;
3376    for (index, request) in requests.iter().enumerate() {
3377        validate_purpose_review_field(index, "path", &request.path, MAX_PURPOSE_REVIEW_PATH_BYTES)?;
3378        aggregate_bytes = aggregate_bytes
3379            .checked_add(request.path.len())
3380            .ok_or_else(|| purpose_review_input_too_large(usize::MAX))?;
3381        for (name, value) in [
3382            ("purpose", request.purpose.as_deref()),
3383            ("task", request.task.as_deref()),
3384            ("work_key", request.work_key.as_deref()),
3385            ("state_token", request.state_token.as_deref()),
3386        ] {
3387            if let Some(value) = value {
3388                validate_purpose_review_field(index, name, value, MAX_PURPOSE_REVIEW_FIELD_BYTES)?;
3389                aggregate_bytes = aggregate_bytes
3390                    .checked_add(value.len())
3391                    .ok_or_else(|| purpose_review_input_too_large(usize::MAX))?;
3392            }
3393        }
3394        if aggregate_bytes > MAX_PURPOSE_REVIEW_INPUT_BYTES {
3395            return Err(purpose_review_input_too_large(aggregate_bytes));
3396        }
3397    }
3398    Ok(())
3399}
3400
3401/// Validate one caller-controlled purpose-review string before retaining output.
3402fn validate_purpose_review_field(
3403    index: usize,
3404    name: &str,
3405    value: &str,
3406    maximum: usize,
3407) -> Result<(), CliError> {
3408    if value.len() > maximum {
3409        return Err(CliError::InvalidInput(format!(
3410            "purpose review item {index} field {name} contains {} bytes; maximum is {maximum}",
3411            value.len()
3412        )));
3413    }
3414    Ok(())
3415}
3416
3417/// Build the stable aggregate-byte admission failure.
3418fn purpose_review_input_too_large(actual: usize) -> CliError {
3419    CliError::InvalidInput(format!(
3420        "purpose review input contains {actual} aggregate string bytes; maximum is {MAX_PURPOSE_REVIEW_INPUT_BYTES}"
3421    ))
3422}
3423
3424/// Review one admitted item-oriented batch while bounding retained report data.
3425fn collect_purpose_reviews(
3426    store: &AtlasStore,
3427    requests: &[PurposeReviewRequest],
3428    apply: bool,
3429) -> Result<PurposeReviewReport, CliError> {
3430    let mut items = Vec::with_capacity(requests.len());
3431    let mut retained_bytes = 0usize;
3432    for request in requests {
3433        let item = review_purpose_request(store, request, apply)?;
3434        retained_bytes = retained_bytes
3435            .checked_add(purpose_review_item_bytes(&item)?)
3436            .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
3437        if retained_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
3438            return Err(purpose_review_report_too_large(retained_bytes));
3439        }
3440        items.push(item);
3441    }
3442    Ok(summarize_purpose_review(requests.len(), apply, items))
3443}
3444
3445/// Return retained string bytes for one report row after per-field admission.
3446fn purpose_review_item_bytes(item: &PurposeReviewItem) -> Result<usize, CliError> {
3447    let mut total = 0usize;
3448    for (name, value, maximum) in [
3449        ("path", item.path.as_str(), MAX_PURPOSE_REVIEW_PATH_BYTES),
3450        (
3451            "current_status",
3452            item.current_status.as_str(),
3453            MAX_PURPOSE_REVIEW_FIELD_BYTES,
3454        ),
3455        (
3456            "current_source",
3457            item.current_source.as_str(),
3458            MAX_PURPOSE_REVIEW_FIELD_BYTES,
3459        ),
3460        (
3461            "purpose",
3462            item.purpose.as_str(),
3463            MAX_PURPOSE_REVIEW_FIELD_BYTES,
3464        ),
3465        (
3466            PURPOSE_REVIEW_REPORT_ERROR_FIELD,
3467            item.error.as_str(),
3468            MAX_PURPOSE_REVIEW_FIELD_BYTES,
3469        ),
3470    ] {
3471        if value.len() > maximum {
3472            return Err(CliError::InvalidInput(format!(
3473                "purpose review report field {name} contains {} bytes; maximum is {maximum}",
3474                value.len()
3475            )));
3476        }
3477        total = total
3478            .checked_add(value.len())
3479            .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
3480    }
3481    Ok(total)
3482}
3483
3484/// Enforce exact supported adapter output caps for one completed report.
3485fn validate_purpose_review_report(report: &PurposeReviewReport) -> Result<(), CliError> {
3486    let json_bytes = serde_json::to_string_pretty(report)?
3487        .len()
3488        .checked_add(1)
3489        .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
3490    if json_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
3491        return Err(purpose_review_report_too_large(json_bytes));
3492    }
3493    let toon_bytes = render_purpose_review_report(report).len();
3494    if toon_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
3495        return Err(purpose_review_report_too_large(toon_bytes));
3496    }
3497    Ok(())
3498}
3499
3500/// Build the stable purpose-review report/output limit failure.
3501fn purpose_review_report_too_large(actual: usize) -> CliError {
3502    CliError::InvalidInput(format!(
3503        "purpose review report contains {actual} bytes; maximum is {MAX_PURPOSE_REVIEW_REPORT_BYTES}"
3504    ))
3505}
3506
3507/// Apply one host-selected conditional batch with one database writer transaction.
3508fn apply_conditional_purpose_reviews(
3509    store: &AtlasStore,
3510    requests: &[PurposeReviewRequest],
3511) -> Result<PurposeReviewReport, CliError> {
3512    let prepared = requests
3513        .iter()
3514        .map(|request| {
3515            let path = validated_repo_node_key(Path::new(&request.path)).map_err(|source| {
3516                CliError::InvalidInput(format!(
3517                    "invalid purpose review path {:?}: {source}",
3518                    request.path
3519                ))
3520            })?;
3521            let purpose = request
3522                .purpose
3523                .as_deref()
3524                .map(str::trim)
3525                .filter(|value| !value.is_empty())
3526                .ok_or_else(|| {
3527                    CliError::InvalidInput(
3528                        "conditional purpose review requires an explicit reviewed purpose"
3529                            .to_string(),
3530                    )
3531                })?
3532                .to_string();
3533            let task = request.task.clone().ok_or_else(|| {
3534                CliError::InvalidInput(
3535                    "conditional purpose review requires task, work_key, and state_token together"
3536                        .to_string(),
3537                )
3538            })?;
3539            let work_key = request.work_key.clone().ok_or_else(|| {
3540                CliError::InvalidInput(
3541                    "conditional purpose review requires task, work_key, and state_token together"
3542                        .to_string(),
3543                )
3544            })?;
3545            let state_token = request.state_token.clone().ok_or_else(|| {
3546                CliError::InvalidInput(
3547                    "conditional purpose review requires task, work_key, and state_token together"
3548                        .to_string(),
3549                )
3550            })?;
3551            Ok((
3552                path.clone(),
3553                purpose.clone(),
3554                PurposeConditionalApplyRequest {
3555                    task,
3556                    path,
3557                    work_key,
3558                    state_token,
3559                    purpose,
3560                },
3561            ))
3562        })
3563        .collect::<Result<Vec<_>, CliError>>()?;
3564    let database_requests = prepared
3565        .iter()
3566        .map(|(_, _, request)| request.clone())
3567        .collect::<Vec<_>>();
3568    let results = store.conditionally_set_purposes(&database_requests)?;
3569    let items = prepared
3570        .into_iter()
3571        .zip(results)
3572        .map(|((path, purpose, _), result)| {
3573            debug_assert_eq!(path, result.path);
3574            PurposeReviewItem {
3575                path,
3576                action: conditional_purpose_review_action(result.state, true),
3577                current_status: result
3578                    .current_purpose
3579                    .as_ref()
3580                    .map(|purpose| purpose.status.to_string())
3581                    .unwrap_or_default(),
3582                current_source: result
3583                    .current_purpose
3584                    .as_ref()
3585                    .map(|purpose| purpose.source.to_string())
3586                    .unwrap_or_default(),
3587                purpose,
3588                error: String::new(),
3589            }
3590        })
3591        .collect::<Vec<_>>();
3592    Ok(summarize_purpose_review(requests.len(), true, items))
3593}
3594
3595/// Return whether a row carries one complete queue-bound conditional review.
3596fn is_complete_conditional_purpose_review(request: &PurposeReviewRequest) -> bool {
3597    request.task.is_some()
3598        && request.work_key.is_some()
3599        && request.state_token.is_some()
3600        && request
3601            .purpose
3602            .as_deref()
3603            .is_some_and(|purpose| !purpose.trim().is_empty())
3604}
3605
3606/// Return whether a row attempts to use queue-bound conditional review.
3607fn has_conditional_purpose_review_field(request: &PurposeReviewRequest) -> bool {
3608    request.task.is_some() || request.work_key.is_some() || request.state_token.is_some()
3609}
3610
3611/// Aggregate stable batch counters from per-path review outcomes.
3612fn summarize_purpose_review(
3613    total: usize,
3614    applied: bool,
3615    items: Vec<PurposeReviewItem>,
3616) -> PurposeReviewReport {
3617    let changed = items
3618        .iter()
3619        .filter(|item| {
3620            matches!(
3621                item.action,
3622                PurposeReviewAction::Review | PurposeReviewAction::WouldReview
3623            )
3624        })
3625        .count();
3626    let skipped = items
3627        .iter()
3628        .filter(|item| {
3629            matches!(
3630                item.action,
3631                PurposeReviewAction::Skip | PurposeReviewAction::Accepted
3632            )
3633        })
3634        .count();
3635    let conflicts = items
3636        .iter()
3637        .filter(|item| {
3638            matches!(
3639                item.action,
3640                PurposeReviewAction::Stale
3641                    | PurposeReviewAction::Accepted
3642                    | PurposeReviewAction::Unavailable
3643            )
3644        })
3645        .count();
3646    let failed = items.iter().filter(|item| !item.error.is_empty()).count();
3647    PurposeReviewReport {
3648        applied,
3649        total,
3650        changed,
3651        skipped,
3652        conflicts,
3653        failed,
3654        items,
3655    }
3656}
3657
3658/// Validate and optionally apply one agent-reviewed purpose record.
3659fn review_purpose_request(
3660    store: &AtlasStore,
3661    request: &PurposeReviewRequest,
3662    apply: bool,
3663) -> Result<PurposeReviewItem, CliError> {
3664    let path = validated_repo_node_key(Path::new(&request.path)).map_err(|source| {
3665        CliError::InvalidInput(format!(
3666            "invalid purpose review path {:?}: {source}",
3667            request.path
3668        ))
3669    })?;
3670    let conditional = match (
3671        request.task.as_deref(),
3672        request.work_key.as_deref(),
3673        request.state_token.as_deref(),
3674    ) {
3675        (Some(task), Some(work_key), Some(state_token)) => Some((task, work_key, state_token)),
3676        (None, None, None) => None,
3677        _ => {
3678            return Ok(PurposeReviewItem {
3679                path,
3680                action: PurposeReviewAction::Error,
3681                current_status: String::new(),
3682                current_source: String::new(),
3683                purpose: request.purpose.clone().unwrap_or_default(),
3684                error:
3685                    "conditional purpose review requires task, work_key, and state_token together"
3686                        .to_string(),
3687            });
3688        }
3689    };
3690    if let Some((task, work_key, state_token)) = conditional {
3691        let reviewed_purpose = request
3692            .purpose
3693            .as_deref()
3694            .map(str::trim)
3695            .filter(|value| !value.is_empty());
3696        let Some(reviewed_purpose) = reviewed_purpose else {
3697            return Ok(PurposeReviewItem {
3698                path,
3699                action: PurposeReviewAction::Error,
3700                current_status: String::new(),
3701                current_source: String::new(),
3702                purpose: String::new(),
3703                error: "conditional purpose review requires an explicit reviewed purpose"
3704                    .to_string(),
3705            });
3706        };
3707        let state = if apply {
3708            store.conditionally_set_purpose(task, &path, work_key, state_token, reviewed_purpose)?
3709        } else {
3710            preview_conditional_purpose_review(store, task, &path, work_key, state_token)?
3711        };
3712        let current = store.load_node_by_path(&path)?;
3713        return Ok(PurposeReviewItem {
3714            path,
3715            action: conditional_purpose_review_action(state, apply),
3716            current_status: current
3717                .as_ref()
3718                .map(|node| node.purpose.status.to_string())
3719                .unwrap_or_default(),
3720            current_source: current
3721                .as_ref()
3722                .map(|node| node.purpose.source.to_string())
3723                .unwrap_or_default(),
3724            purpose: reviewed_purpose.to_string(),
3725            error: String::new(),
3726        });
3727    }
3728    let Some(indexed) = store.load_node_by_path(&path)? else {
3729        return Ok(PurposeReviewItem {
3730            path,
3731            action: PurposeReviewAction::Error,
3732            current_status: String::new(),
3733            current_source: String::new(),
3734            purpose: request.purpose.clone().unwrap_or_default(),
3735            error: "path is not indexed".to_string(),
3736        });
3737    };
3738    let current_status = indexed.purpose.status.to_string();
3739    let current_source = indexed.purpose.source.to_string();
3740    let current_purpose = indexed.purpose.purpose.clone().unwrap_or_default();
3741    let explicit_purpose = request
3742        .purpose
3743        .as_deref()
3744        .map(str::trim)
3745        .filter(|value| !value.is_empty());
3746    let Some(reviewed_purpose) = explicit_purpose.or_else(|| {
3747        request
3748            .confirm_existing
3749            .then_some(current_purpose.as_str())
3750            .filter(|value| !value.trim().is_empty())
3751    }) else {
3752        return Ok(PurposeReviewItem {
3753            path,
3754            action: PurposeReviewAction::Error,
3755            current_status,
3756            current_source,
3757            purpose: String::new(),
3758            error: "provide a reviewed purpose or set confirm_existing=true".to_string(),
3759        });
3760    };
3761
3762    if request.confirm_existing
3763        && explicit_purpose.is_none()
3764        && (indexed.purpose.status == PurposeStatus::Suggested
3765            || indexed.purpose.source == PurposeSource::Generated)
3766    {
3767        return Ok(PurposeReviewItem {
3768            path,
3769            action: PurposeReviewAction::Error,
3770            current_status,
3771            current_source,
3772            purpose: current_purpose,
3773            error: "generated suggestions require an explicit reviewed purpose".to_string(),
3774        });
3775    }
3776
3777    let reviewed_purpose = reviewed_purpose.trim().to_string();
3778    let action = if indexed.purpose.agent_reviewed() && current_purpose == reviewed_purpose {
3779        PurposeReviewAction::Skip
3780    } else if apply {
3781        store.set_purpose(&path, &reviewed_purpose, PurposeSource::Agent)?;
3782        PurposeReviewAction::Review
3783    } else {
3784        PurposeReviewAction::WouldReview
3785    };
3786    Ok(PurposeReviewItem {
3787        path,
3788        action,
3789        current_status,
3790        current_source,
3791        purpose: reviewed_purpose,
3792        error: String::new(),
3793    })
3794}
3795
3796/// Preview one conditional review against the current queue state without writing.
3797fn preview_conditional_purpose_review(
3798    store: &AtlasStore,
3799    task: &str,
3800    path: &str,
3801    work_key: &str,
3802    state_token: &str,
3803) -> Result<PurposeConditionalApplyState, CliError> {
3804    let batch = store.load_purpose_curation_batch(task, &[path.to_string()])?;
3805    if let Some(candidate) = batch.items.first() {
3806        return Ok(
3807            if candidate.work_key == work_key && candidate.state_token == state_token {
3808                PurposeConditionalApplyState::Applied
3809            } else {
3810                PurposeConditionalApplyState::Stale
3811            },
3812        );
3813    }
3814    Ok(store
3815        .load_node_by_path(path)?
3816        .map_or(PurposeConditionalApplyState::PathUnavailable, |_node| {
3817            PurposeConditionalApplyState::Accepted
3818        }))
3819}
3820
3821/// Map database conditional-apply state into the stable review action contract.
3822const fn conditional_purpose_review_action(
3823    state: PurposeConditionalApplyState,
3824    apply: bool,
3825) -> PurposeReviewAction {
3826    match state {
3827        PurposeConditionalApplyState::Applied if apply => PurposeReviewAction::Review,
3828        PurposeConditionalApplyState::Applied => PurposeReviewAction::WouldReview,
3829        PurposeConditionalApplyState::Stale => PurposeReviewAction::Stale,
3830        PurposeConditionalApplyState::Accepted => PurposeReviewAction::Accepted,
3831        PurposeConditionalApplyState::PathUnavailable => PurposeReviewAction::Unavailable,
3832    }
3833}
3834
3835/// Build a purpose curation queue from the bounded health page.
3836pub(crate) fn purpose_curation_page(
3837    store: &AtlasStore,
3838    query: &HealthQuery,
3839    task: &str,
3840) -> Result<PurposeCurationPage, CliError> {
3841    let page = store.purpose_curation_findings_page_current(query)?;
3842    let paths = page
3843        .findings
3844        .iter()
3845        .map(|finding| finding.path.clone())
3846        .collect::<Vec<_>>();
3847    let batch = store.load_purpose_curation_batch(task, &paths)?;
3848    let project_instance_id = batch.project_instance_id.to_string();
3849    let active_generation = batch.active_generation.get();
3850    let task = batch.task.clone();
3851    let work_key = batch.work_key.clone();
3852    let candidates = batch
3853        .items
3854        .into_iter()
3855        .map(|candidate| (candidate.node.node.path.clone(), candidate))
3856        .collect::<HashMap<_, _>>();
3857    let items = page
3858        .findings
3859        .iter()
3860        .filter_map(|finding| {
3861            let candidate = candidates.get(&finding.path)?;
3862            let node = &candidate.node;
3863            let review_signal = purpose_review_signal(&node.node, &node.purpose);
3864            Some(PurposeCurationItem {
3865                work_key: candidate.work_key.clone(),
3866                state_token: candidate.state_token.clone(),
3867                severity: health_severity_name(finding.severity).to_string(),
3868                id: finding.id.clone(),
3869                category: finding.category.clone(),
3870                path: finding.path.clone(),
3871                related_path: finding.related_path.clone().unwrap_or_default(),
3872                kind: node.node.kind.to_string(),
3873                language: node.node.language.clone().unwrap_or_default(),
3874                purpose: node.purpose.purpose.clone().unwrap_or_default(),
3875                purpose_status: node.purpose.status.to_string(),
3876                purpose_source: node.purpose.source.to_string(),
3877                purpose_agent_reviewed: node.purpose.agent_reviewed(),
3878                review_priority: review_signal.priority.to_string(),
3879                review_reason: review_signal.reason.to_string(),
3880                content_summary: node.summary.clone().unwrap_or_default(),
3881                recommendation: "Inspect bounded context, then use conditional purpose review with this task, work_key, and state_token."
3882                    .to_string(),
3883            })
3884        })
3885        .collect::<Vec<_>>();
3886    let returned = items.len();
3887    Ok(PurposeCurationPage {
3888        project_instance_id,
3889        active_generation,
3890        task,
3891        work_key,
3892        actionable: returned > 0,
3893        curation_scope: purpose_queue_curation_scope(query),
3894        total: page.total,
3895        unfiltered_total: page.unfiltered_total,
3896        returned,
3897        start_index: page.start_index,
3898        limit: page.limit,
3899        max_limit: MAX_HEALTH_LIMIT,
3900        next_start_index: health_next_start_index(&page),
3901        truncated: health_next_start_index(&page).is_some(),
3902        source_only: query.scope.is_source_focused(),
3903        folder_scope: purpose_queue_folder_scope(query).to_string(),
3904        file_scope: purpose_queue_file_scope(query).to_string(),
3905        category: query.category.clone().unwrap_or_default(),
3906        severity: query.severity.map_or("", health_severity_name).to_string(),
3907        path_prefix: query.path_prefix.clone().unwrap_or_default(),
3908        summary_only: query.summary_only,
3909        items,
3910    })
3911}
3912
3913/// Render a bounded health page as compact TOON.
3914pub(crate) fn render_health_page(page: &HealthFindingsPage, query: &HealthQuery) -> String {
3915    let rows = page
3916        .findings
3917        .iter()
3918        .map(|finding| {
3919            json!({
3920                "severity": health_severity_name(finding.severity),
3921                "id": finding.id,
3922                "category": finding.category,
3923                "path": finding.path,
3924                "related_path": finding.related_path.as_deref().unwrap_or(""),
3925                "message": finding.message,
3926                "recommendation": finding.recommendation,
3927            })
3928        })
3929        .collect::<Vec<_>>();
3930    encode_agent_payload(&json!({
3931        "health": {
3932            "total": page.total,
3933            "unfiltered_total": page.unfiltered_total,
3934            "returned": page.returned,
3935            "start_index": page.start_index,
3936            "limit": page.limit,
3937            "max_limit": MAX_HEALTH_LIMIT,
3938            "next_start_index": health_next_start_index(page),
3939            "truncated": health_next_start_index(page).is_some(),
3940            "summary_only": query.summary_only,
3941            "source_only": query.scope.is_source_focused(),
3942            "category": query.category.as_deref().unwrap_or(""),
3943            "severity": query.severity.map_or("", health_severity_name),
3944            "path_prefix": query.path_prefix.as_deref().unwrap_or(""),
3945        },
3946        "health_findings": rows,
3947    }))
3948}
3949
3950/// Render one bounded current coverage page as compact TOON.
3951pub(crate) fn render_coverage_report(report: &CoverageDiscoveryReport) -> String {
3952    encode_agent_payload(&json!({ "coverage": report }))
3953}
3954
3955/// Render a purpose curation queue as compact TOON.
3956pub(crate) fn render_purpose_curation_page(page: &PurposeCurationPage) -> String {
3957    encode_agent_payload(&json!({
3958        "purpose_curation": {
3959            "project_instance_id": page.project_instance_id,
3960            "active_generation": page.active_generation,
3961            "task": page.task,
3962            "work_key": page.work_key,
3963            "actionable": page.actionable,
3964            "curation_scope": page.curation_scope,
3965            "total": page.total,
3966            "unfiltered_total": page.unfiltered_total,
3967            "returned": page.returned,
3968            "start_index": page.start_index,
3969            "limit": page.limit,
3970            "max_limit": page.max_limit,
3971            "next_start_index": page.next_start_index,
3972            "truncated": page.truncated,
3973            "source_only": page.source_only,
3974            "folder_scope": page.folder_scope,
3975            "file_scope": page.file_scope,
3976            "category": page.category,
3977            "severity": page.severity,
3978            "path_prefix": page.path_prefix,
3979            "summary_only": page.summary_only,
3980        },
3981        "purpose_curation_items": page.items,
3982    }))
3983}
3984
3985/// Render a batch purpose review report as compact TOON.
3986pub(crate) fn render_purpose_review_report(report: &PurposeReviewReport) -> String {
3987    encode_agent_payload(&json!({
3988        "purpose_review": {
3989            "applied": report.applied,
3990            "total": report.total,
3991            "changed": report.changed,
3992            "skipped": report.skipped,
3993            "conflicts": report.conflicts,
3994            "failed": report.failed,
3995        },
3996        "purpose_review_items": report.items,
3997    }))
3998}
3999
4000/// Return the folder inclusion scope for purpose curation metadata.
4001fn purpose_queue_folder_scope(query: &HealthQuery) -> &'static str {
4002    match query.scope {
4003        HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "source_relevant",
4004        _ => "all",
4005    }
4006}
4007
4008/// Return the file inclusion scope for purpose curation metadata.
4009fn purpose_queue_file_scope(query: &HealthQuery) -> &'static str {
4010    match query.scope {
4011        HealthScope::PurposeDefault => "high_impact",
4012        HealthScope::PurposeWithAssets => "high_impact_and_assets",
4013        HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "all_source",
4014        HealthScope::All | HealthScope::PurposeStrict => "all",
4015    }
4016}
4017
4018/// Return the truthful curation tier selected by explicit queue scope flags.
4019fn purpose_queue_curation_scope(query: &HealthQuery) -> &'static str {
4020    match query.scope {
4021        HealthScope::PurposeDefault => "low",
4022        HealthScope::PurposeWithAssets => "low_with_assets",
4023        HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "medium",
4024        HealthScope::All | HealthScope::PurposeStrict => "strict",
4025    }
4026}
4027
4028/// Return a stable lowercase severity name.
4029pub(crate) fn health_severity_name(severity: Severity) -> &'static str {
4030    severity.as_str()
4031}
4032
4033/// Return the next start index for a bounded health page.
4034fn health_next_start_index(page: &HealthFindingsPage) -> Option<usize> {
4035    let page_width = page.limit.min(page.total.saturating_sub(page.start_index));
4036    let page_end = page.start_index.saturating_add(page_width);
4037    if page_width == 0 || page_end >= page.total {
4038        None
4039    } else {
4040        Some(page_end)
4041    }
4042}
4043
4044/// Estimate source tokens for repository paths referenced by symbols/relations.
4045pub(crate) fn estimated_source_tokens_for_paths<'a>(
4046    store: &AtlasStore,
4047    paths: impl Iterator<Item = &'a str>,
4048) -> Result<usize, CliError> {
4049    let mut seen = HashSet::new();
4050    let mut total = 0usize;
4051    for path in paths {
4052        if seen.insert(path.to_string()) {
4053            total = total.saturating_add(estimated_source_tokens_for_path(store, path)?);
4054        }
4055    }
4056    Ok(total)
4057}
4058
4059/// Estimate source tokens for one indexed path, falling back safely for stale rows.
4060pub(crate) fn estimated_source_tokens_for_path(
4061    store: &AtlasStore,
4062    path: &str,
4063) -> Result<usize, CliError> {
4064    if let Some(indexed) = store.load_node_by_path(path)?
4065        && indexed.node.kind == NodeKind::File
4066    {
4067        return Ok(estimated_source_tokens_for_file_node(&indexed.node));
4068    }
4069    Ok(read_indexed_file_content(store, path).map_or_else(
4070        |_| estimate_tokens(path),
4071        |content| estimate_tokens(&content),
4072    ))
4073}
4074
4075/// Persisted file-text index report.
4076#[derive(Clone, Debug, Serialize)]
4077pub(crate) struct TextIndexReport {
4078    /// File nodes considered for indexed text.
4079    pub(crate) candidates: usize,
4080    /// UTF-8 files persisted for `SQLite`-backed search.
4081    pub(crate) indexed: usize,
4082    /// Files skipped because text could not be decoded as UTF-8.
4083    pub(crate) binary_or_non_utf8: usize,
4084    /// Files skipped because they exceeded the configured text-index size cap.
4085    pub(crate) too_large: usize,
4086    /// Total files skipped from the persisted text index.
4087    pub(crate) skipped: usize,
4088    /// Maximum UTF-8 file size persisted into `SQLite` text search.
4089    pub(crate) max_bytes: u64,
4090    /// Source bytes stored in the text index.
4091    pub(crate) bytes: usize,
4092}
4093
4094/// Deterministic structural-summary refresh report.
4095#[derive(Clone, Debug, Default, Serialize)]
4096pub(crate) struct StructuralSummaryReport {
4097    /// Indexed files considered for structural summaries.
4098    pub(crate) candidates: usize,
4099    /// Files whose observed summaries were refreshed.
4100    pub(crate) summarized: usize,
4101    /// Existing observed summaries cleared because current content was not summarizable.
4102    pub(crate) cleared: usize,
4103    /// Files skipped because they exceeded the parser size limit.
4104    pub(crate) too_large: usize,
4105    /// Files skipped because content was not valid UTF-8.
4106    pub(crate) binary_or_non_utf8: usize,
4107    /// Generated purpose suggestions that still need agent review.
4108    pub(crate) purpose_suggestions: usize,
4109}
4110
4111/// Options controlling full-text persistence for `SQLite` search.
4112#[derive(Clone, Copy, Debug)]
4113pub(crate) struct TextIndexOptions {
4114    /// Maximum UTF-8 file size persisted into `SQLite` text search.
4115    pub(crate) max_bytes: u64,
4116}
4117
4118impl TextIndexOptions {
4119    /// Create text-index options from config and command overrides.
4120    pub(crate) fn new(max_bytes: u64) -> Self {
4121        Self { max_bytes }
4122    }
4123}
4124
4125/// Outcome of considering one file for persisted text search.
4126#[derive(Clone, Debug)]
4127pub(crate) struct TextIndexRow {
4128    /// Repository-relative path considered for text indexing.
4129    path: String,
4130    /// Persistable text row when the file is search-indexed.
4131    text: Option<IndexedFileText>,
4132    /// Indexing outcome for reporting.
4133    reason: TextIndexSkipReason,
4134}
4135
4136/// Persisted text refresh result plus rows reused by structural summarizers.
4137pub(crate) struct TextIndexRefresh {
4138    /// Aggregate report rendered to callers.
4139    pub(crate) report: TextIndexReport,
4140    /// Per-file text outcomes from the same scan batch.
4141    pub(crate) rows: Vec<TextIndexRow>,
4142}
4143
4144/// Text-index outcome categories.
4145#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4146pub(crate) enum TextIndexSkipReason {
4147    /// File text was persisted for search.
4148    Indexed,
4149    /// File exceeded the configured text-index size cap.
4150    TooLarge,
4151    /// File was binary or not valid UTF-8.
4152    BinaryOrNonUtf8,
4153}
4154
4155/// Symbol graph build report.
4156#[derive(Debug, Serialize)]
4157pub(crate) struct SymbolBuildReport {
4158    /// Indexed file candidates considered for symbols.
4159    pub(crate) candidates: usize,
4160    /// Files parsed during this build.
4161    pub(crate) parsed: usize,
4162    /// Files skipped because they were unchanged and already had symbols.
4163    pub(crate) unchanged: usize,
4164    /// Files skipped because they exceeded the configured size limit.
4165    pub(crate) too_large: usize,
4166    /// Files skipped because content was not valid UTF-8.
4167    pub(crate) binary_or_non_utf8: usize,
4168    /// Files skipped because the build deadline was reached.
4169    pub(crate) timed_out: usize,
4170    /// Worker thread count used for parser work.
4171    pub(crate) max_workers: usize,
4172    /// Optional timeout seconds requested for parser work.
4173    pub(crate) timeout_seconds: Option<u64>,
4174    /// Symbols persisted.
4175    pub(crate) symbols: usize,
4176    /// Relations persisted.
4177    pub(crate) relations: usize,
4178    /// Node summaries refreshed from symbol graphs.
4179    pub(crate) summaries: usize,
4180    /// Generated purpose suggestions that still need agent review.
4181    pub(crate) purpose_suggestions: usize,
4182}
4183
4184/// Filesystem and derived facts prepared before acquiring the `SQLite` writer.
4185struct IndexPublicationBatch {
4186    /// Complete publication generation observed before source preparation.
4187    base_generation: IndexGeneration,
4188    /// Derivation contract bound to every staged projection.
4189    contract_fingerprint: String,
4190    /// Canonical selected source root.
4191    root: PathBuf,
4192    /// Full or affected-path node mutation plus the final expected source state.
4193    nodes: NodePublicationBatch,
4194    /// Optional legacy-purpose inputs consumed by a full scan.
4195    purpose_import: Option<PurposeImportSnapshot>,
4196    /// Repository paths whose persisted source text must be replaced.
4197    text_paths: Vec<String>,
4198    /// Prepared persisted source-text rows and report.
4199    text: TextIndexRefresh,
4200    /// Prepared symbol graph, summary, and suggestion mutations.
4201    symbols: SymbolBuildStage,
4202    /// Prepared normalized repository graph and canonical resolution-key mutation.
4203    graph: graph_projection::StagedRepositoryGraph,
4204    /// Prepared structural-summary and suggestion mutations.
4205    structural_summaries: StructuralSummaryStage,
4206}
4207
4208/// Node mutations owned by one full or incremental publication.
4209enum NodePublicationBatch {
4210    /// Replace the complete observed source tree.
4211    Full {
4212        /// Complete staged node set.
4213        nodes: Vec<Node>,
4214    },
4215    /// Apply a bounded changed-path delta.
4216    Incremental {
4217        /// Added or modified nodes.
4218        nodes: Vec<Node>,
4219        /// Deleted paths and descendants to mark absent.
4220        absent_paths: Vec<String>,
4221        /// Complete expected source state used only for pre-publication revalidation.
4222        expected_nodes: Vec<Node>,
4223    },
4224}
4225
4226impl NodePublicationBatch {
4227    /// Return the complete source state that must still exist before publication.
4228    fn expected_nodes(&self) -> &[Node] {
4229        match self {
4230            Self::Full { nodes } => nodes,
4231            Self::Incremental { expected_nodes, .. } => expected_nodes,
4232        }
4233    }
4234}
4235
4236/// Reports produced after a staged batch commits successfully.
4237struct IndexPublicationOutcome {
4238    /// Legacy-purpose import decisions made against current authored state.
4239    purpose_import: PurposeImportReport,
4240    /// Persisted source-text report.
4241    text_index: TextIndexReport,
4242    /// Deterministic structural-summary report.
4243    structural_summaries: StructuralSummaryReport,
4244    /// Deep symbol graph report.
4245    symbols: SymbolBuildReport,
4246}
4247
4248/// Symbol mutations retained outside the `SQLite` writer transaction.
4249struct SymbolBuildStage {
4250    /// Aggregate symbol build report.
4251    report: SymbolBuildReport,
4252    /// Deterministically ordered projection changes.
4253    changes: Vec<SymbolProjectionChange>,
4254    /// Retained parser-output string bytes admitted by the resource boundary.
4255    retained_bytes: u64,
4256}
4257
4258/// One closed symbol projection mutation.
4259enum SymbolProjectionChange {
4260    /// Persist one successfully parsed graph and its derived metadata.
4261    Parsed(SymbolParseSuccess),
4262    /// Clear stale symbol output for a skipped source file.
4263    Clear {
4264        /// Repository-relative path.
4265        path: String,
4266        /// Detected language used to preserve structural summaries where applicable.
4267        language: Option<String>,
4268    },
4269}
4270
4271/// Structural summary mutations retained outside the `SQLite` writer transaction.
4272struct StructuralSummaryStage {
4273    /// Aggregate structural-summary report.
4274    report: StructuralSummaryReport,
4275    /// Deterministically ordered summary changes.
4276    changes: Vec<StructuralSummaryChange>,
4277    /// Retained summary and suggestion string bytes.
4278    retained_bytes: u64,
4279}
4280
4281/// One file's closed structural-summary derivation.
4282#[derive(Default)]
4283struct StructuralSummaryDerivation {
4284    /// Optional persistence mutation for this file.
4285    change: Option<StructuralSummaryChange>,
4286    /// Observed summaries derived or reused.
4287    summarized: usize,
4288    /// Existing observed summaries cleared.
4289    cleared: usize,
4290    /// Files cleared because they exceeded the parser limit.
4291    too_large: usize,
4292    /// Files cleared because their content was not valid text.
4293    binary_or_non_utf8: usize,
4294    /// Unapproved purpose suggestions derived from observed summaries.
4295    purpose_suggestions: usize,
4296    /// String bytes retained until publication.
4297    retained_bytes: u64,
4298}
4299
4300/// One closed structural-summary projection mutation.
4301enum StructuralSummaryChange {
4302    /// Replace one observed summary and optional unreviewed purpose suggestion.
4303    Set {
4304        /// Repository-relative path.
4305        path: String,
4306        /// Deterministic observed summary.
4307        summary: String,
4308        /// Optional generated purpose suggestion.
4309        purpose_suggestion: Option<String>,
4310    },
4311    /// Clear a stale observed summary.
4312    Clear {
4313        /// Repository-relative path.
4314        path: String,
4315    },
4316}
4317
4318/// Watch command report.
4319#[derive(Debug, Serialize)]
4320pub(crate) struct WatchReport {
4321    /// Watcher mode.
4322    pub(crate) mode: String,
4323    /// Completed refresh cycles.
4324    pub(crate) cycles: usize,
4325    /// Whether the command ran a single refresh and exited.
4326    pub(crate) once: bool,
4327    /// Reason the watcher fell back from event mode, if any.
4328    #[serde(skip_serializing_if = "Option::is_none")]
4329    pub(crate) fallback_reason: Option<String>,
4330    /// Last persisted text search index report.
4331    pub(crate) text_index: TextIndexReport,
4332    /// Last structural summary refresh report.
4333    pub(crate) structural_summaries: StructuralSummaryReport,
4334    /// Last symbol refresh report.
4335    pub(crate) last_symbols: SymbolBuildReport,
4336}
4337
4338/// Debounced filesystem changes observed by watcher mode.
4339#[derive(Debug, Default)]
4340pub(crate) struct WatchChangeSet {
4341    /// Whether a full scan is required for correctness.
4342    requires_full_scan: bool,
4343    /// Relevant native paths from event batches.
4344    paths: HashSet<PathBuf>,
4345}
4346
4347impl WatchChangeSet {
4348    /// Return whether there is work to refresh.
4349    fn has_changes(&self) -> bool {
4350        self.requires_full_scan || !self.paths.is_empty()
4351    }
4352
4353    /// Merge another event batch into this set.
4354    fn merge(&mut self, other: Self) {
4355        self.requires_full_scan |= other.requires_full_scan;
4356        self.paths.extend(other.paths);
4357    }
4358}
4359
4360/// Legacy purpose cleanup report.
4361#[derive(Debug, Serialize)]
4362pub(crate) struct LegacyPurposeReport {
4363    /// Whether files were modified.
4364    pub(crate) applied: bool,
4365    /// Number of `.purpose` files found.
4366    pub(crate) purpose_files_found: usize,
4367    /// Number of `.purpose` files removed.
4368    pub(crate) purpose_files_removed: usize,
4369    /// Source header candidates found.
4370    pub(crate) source_header_candidates: Vec<String>,
4371    /// Legacy purpose file paths.
4372    pub(crate) purpose_files: Vec<String>,
4373}
4374
4375/// Local settings report.
4376#[derive(Debug, Serialize)]
4377pub(crate) struct SettingsReport {
4378    /// Runtime cache directory that owns local `ProjectAtlas` state.
4379    pub(crate) cache_dir: PathStatus,
4380    /// `SQLite` database file status.
4381    pub(crate) db: PathStatus,
4382    /// `SQLite` write-ahead log file status.
4383    pub(crate) db_wal: PathStatus,
4384    /// `SQLite` shared-memory sidecar file status.
4385    pub(crate) db_shm: PathStatus,
4386    /// `SQLite` rollback journal sidecar file status.
4387    pub(crate) db_journal: PathStatus,
4388    /// Project-local MCP configuration file status.
4389    pub(crate) mcp_config: PathStatus,
4390    /// Config file used for map/lint/scan imports, when discovered.
4391    pub(crate) config_path: Option<String>,
4392    /// Repository root used by map/lint config.
4393    pub(crate) repo_root: String,
4394    /// Source that selected the repository root.
4395    pub(crate) root_detection_source: String,
4396    /// Whether config and DB root metadata agree.
4397    pub(crate) root_verified: bool,
4398    /// Root mismatches that should be fixed before trusting the binding.
4399    pub(crate) root_mismatches: Vec<String>,
4400    /// Generated map path.
4401    pub(crate) map_path: String,
4402    /// Non-source summary path.
4403    pub(crate) nonsource_files_path: String,
4404    /// Default output format.
4405    pub(crate) default_format: String,
4406    /// Default search case sensitivity.
4407    pub(crate) default_search_case_sensitive: bool,
4408    /// Source used by search commands.
4409    pub(crate) search_source: String,
4410    /// Maximum UTF-8 file size persisted into `SQLite` text search.
4411    pub(crate) text_index_max_bytes: u64,
4412    /// Watcher runtime status.
4413    pub(crate) watcher: WatchStatusReport,
4414    /// Current index statistics, if the index exists.
4415    pub(crate) index: Option<SettingsIndexStats>,
4416    /// Content-free telemetry retention and maintenance state, when the index exists.
4417    pub(crate) telemetry: Option<TelemetryRetentionState>,
4418    /// Read-only schema, publication, coverage, and `SQLite` operating diagnostics.
4419    pub(crate) database: DatabaseSettingsReport,
4420    /// Content-free language capability registry identity and derived counts.
4421    pub(crate) language_registry: LanguageRegistryReport,
4422    /// Digest of the currently implemented provider-backed relation contract.
4423    pub(crate) semantic_relation_contract_digest: String,
4424    /// Versioned accepted relation-family inventory and lifecycle state.
4425    pub(crate) relation_family_inventory: RelationFamilyInventoryReport,
4426    /// Typed search-mode readiness without an implicit index build.
4427    pub(crate) search: SettingsSearchReport,
4428    /// Content-free optional parser-pack lifecycle state.
4429    pub(crate) optional_parser_pack: OptionalParserSettingsReport,
4430}
4431
4432/// Readiness of one settings capability.
4433#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
4434#[serde(rename_all = "snake_case")]
4435pub(crate) enum SettingsCapabilityState {
4436    /// The capability has usable persisted state.
4437    Ready,
4438    /// The capability is implemented but the selected index has no rows yet.
4439    Empty,
4440    /// The capability is not available in the current runtime/index combination.
4441    Unavailable,
4442}
4443
4444/// Typed settings projection for one search mode.
4445#[derive(Debug, Serialize)]
4446pub(crate) struct SettingsSearchModeReport {
4447    /// Current readiness.
4448    pub(crate) state: SettingsCapabilityState,
4449}
4450
4451/// Typed lexical-search settings projection.
4452#[derive(Debug, Serialize)]
4453pub(crate) struct SettingsLexicalSearchReport {
4454    /// Current readiness.
4455    pub(crate) state: SettingsCapabilityState,
4456    /// Authoritative source searched by the correctness path.
4457    pub(crate) source: &'static str,
4458    /// Whether returned candidates are verified against persisted exact text.
4459    pub(crate) exact_verification: bool,
4460}
4461
4462/// Content-free readiness for supported and planned search modes.
4463#[derive(Debug, Serialize)]
4464pub(crate) struct SettingsSearchReport {
4465    /// Compatible default when no explicit mode is supplied.
4466    pub(crate) default_mode: &'static str,
4467    /// Deterministic persisted-text search.
4468    pub(crate) lexical: SettingsLexicalSearchReport,
4469    /// Optional FTS candidate acceleration.
4470    pub(crate) fts: SettingsSearchModeReport,
4471    /// Optional semantic retrieval.
4472    pub(crate) semantic: SettingsSearchModeReport,
4473    /// Optional lexical-complete hybrid retrieval.
4474    pub(crate) hybrid: SettingsSearchModeReport,
4475}
4476
4477/// Optional parser state present in both feature-enabled and default-core-only builds.
4478#[derive(Debug, Serialize)]
4479pub(crate) struct OptionalParserSettingsReport {
4480    /// Whether the supervised optional-parser lifecycle is compiled into this binary.
4481    pub(crate) compiled: bool,
4482    /// Bounded lifecycle metadata when the supervisor feature is present.
4483    #[cfg(feature = "optional-parser-supervisor")]
4484    pub(crate) lifecycle: OptionalParserPackLifecycleReport,
4485    /// Honest state for a binary compiled without the optional lifecycle.
4486    #[cfg(not(feature = "optional-parser-supervisor"))]
4487    pub(crate) state: &'static str,
4488}
4489
4490/// Filesystem status for a diagnostic path.
4491#[derive(Debug, Serialize)]
4492pub(crate) struct PathStatus {
4493    /// Normalized native path.
4494    pub(crate) path: String,
4495    /// Whether the path exists.
4496    pub(crate) exists: bool,
4497    /// File size in bytes when the path is an existing file.
4498    pub(crate) size_bytes: Option<u64>,
4499}
4500
4501/// Indexed state summary for settings diagnostics.
4502#[derive(Debug, Serialize)]
4503pub(crate) struct SettingsIndexStats {
4504    /// Canonical project root stored in the index metadata.
4505    pub(crate) project_root: Option<String>,
4506    /// Indexed file count.
4507    pub(crate) files: usize,
4508    /// Indexed folder count.
4509    pub(crate) folders: usize,
4510    /// Missing purpose count.
4511    pub(crate) missing_purposes: usize,
4512    /// Stale purpose count.
4513    pub(crate) stale_purposes: usize,
4514    /// Suggested purpose count.
4515    pub(crate) suggested_purposes: usize,
4516    /// Persisted searchable text rows.
4517    pub(crate) indexed_text_files: usize,
4518    /// Persisted searchable text bytes.
4519    pub(crate) indexed_text_bytes: usize,
4520    /// Persisted symbol count.
4521    pub(crate) symbols: usize,
4522    /// Persisted symbol relation count.
4523    pub(crate) relations: usize,
4524    /// Token telemetry event count.
4525    pub(crate) token_calls: usize,
4526    /// Unresolved structural health finding count.
4527    pub(crate) health_findings: usize,
4528}
4529
4530/// Watcher status report.
4531#[derive(Debug, Serialize)]
4532pub(crate) struct WatchStatusReport {
4533    /// Whether a watcher implementation is available in this binary.
4534    pub(crate) available: bool,
4535    /// Whether a watcher is active.
4536    pub(crate) active: bool,
4537    /// Watcher mode.
4538    pub(crate) mode: String,
4539    /// Whether event-backed watching is available.
4540    pub(crate) event_backend_available: bool,
4541    /// Operational recommendation.
4542    pub(crate) recommendation: String,
4543}
4544
4545/// Runtime index/cache cleanup report.
4546#[derive(Debug, Serialize)]
4547pub(crate) struct ResetIndexReport {
4548    /// Whether files were modified.
4549    pub(crate) applied: bool,
4550    /// Whether the command only previewed paths.
4551    pub(crate) dry_run: bool,
4552    /// Runtime files selected for cleanup.
4553    files: Vec<PathStatus>,
4554    /// Number of selected files removed.
4555    pub(crate) removed: usize,
4556}
4557
4558/// Build settings diagnostics shared by CLI and MCP.
4559pub(crate) fn build_settings_report(
4560    db: &Path,
4561    config_path: Option<&Path>,
4562    format: OutputFormat,
4563) -> Result<SettingsReport, CliError> {
4564    let absolute_db = absolute_path(db)?;
4565    let resolved_config = resolved_mcp_config_path(&absolute_db, config_path)?;
4566    let config = if let Some(config_path) = resolved_config.as_deref() {
4567        load_atlas_config(Some(config_path))?
4568    } else {
4569        let project_root = default_mcp_project_root(&absolute_db, None)?;
4570        load_atlas_config_for_root(&project_root)?
4571    };
4572    let cache_dir = absolute_db
4573        .parent()
4574        .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
4575    let database = database_settings_report(&absolute_db)?;
4576    let (index, telemetry, file_text_fts) =
4577        if database.schema.compatibility == DatabaseSchemaCompatibility::Current {
4578            let store = AtlasStore::open_read_only(&absolute_db)?;
4579            let snapshot_publication = store.index_publication()?;
4580            if settings_publication_matches(
4581                database.publication.as_ref(),
4582                snapshot_publication.as_ref(),
4583            ) {
4584                (
4585                    Some(settings_index_stats(&store)?),
4586                    Some(store.telemetry_retention_state()?),
4587                    Some(store.file_text_fts_state()?),
4588                )
4589            } else {
4590                (None, None, None)
4591            }
4592        } else {
4593            (None, None, None)
4594        };
4595    let repo_root = normalize_display_path(&config.root);
4596    let db_project_root = index
4597        .as_ref()
4598        .and_then(|stats| stats.project_root.as_ref())
4599        .cloned();
4600    let mut root_mismatches = Vec::new();
4601    if let Some(db_root) = db_project_root.as_ref()
4602        && db_root != &repo_root
4603    {
4604        root_mismatches.push(format!(
4605            "db root {db_root:?} does not match config root {repo_root:?}"
4606        ));
4607    }
4608    let root_detection_source = if resolved_config.is_some() {
4609        "config"
4610    } else if db_project_root.is_some() {
4611        "db"
4612    } else {
4613        "db-path-or-cwd"
4614    }
4615    .to_string();
4616    let lexical_publication_ready = database.publication.as_ref().is_some_and(|publication| {
4617        publication.state == IndexPublicationState::Complete
4618            && publication.generation != IndexGeneration::ZERO
4619            && publication.contract_fingerprint_state == DatabasePublicationContractState::Valid
4620    });
4621    let lexical_state = match (lexical_publication_ready, index.as_ref()) {
4622        (true, Some(stats)) if stats.indexed_text_files == 0 => SettingsCapabilityState::Empty,
4623        (true, Some(_)) => SettingsCapabilityState::Ready,
4624        _ => SettingsCapabilityState::Unavailable,
4625    };
4626    let fts_state = match (lexical_publication_ready, file_text_fts.as_ref()) {
4627        (true, Some(state)) if !state.synchronized => SettingsCapabilityState::Unavailable,
4628        (true, Some(state)) if state.source_rows == 0 => SettingsCapabilityState::Empty,
4629        (true, Some(_)) => SettingsCapabilityState::Ready,
4630        _ => SettingsCapabilityState::Unavailable,
4631    };
4632    let search = SettingsSearchReport {
4633        default_mode: "lexical",
4634        lexical: SettingsLexicalSearchReport {
4635            state: lexical_state,
4636            source: "persisted_text",
4637            exact_verification: true,
4638        },
4639        fts: SettingsSearchModeReport { state: fts_state },
4640        semantic: SettingsSearchModeReport {
4641            state: SettingsCapabilityState::Unavailable,
4642        },
4643        hybrid: SettingsSearchModeReport {
4644            state: SettingsCapabilityState::Unavailable,
4645        },
4646    };
4647    #[cfg(feature = "optional-parser-supervisor")]
4648    let optional_parser_pack = OptionalParserSettingsReport {
4649        compiled: true,
4650        lifecycle: OptionalParserPackLifecycle::new(&config.root, None)?.status()?,
4651    };
4652    #[cfg(not(feature = "optional-parser-supervisor"))]
4653    let optional_parser_pack = OptionalParserSettingsReport {
4654        compiled: false,
4655        state: "compiled_unavailable",
4656    };
4657    Ok(SettingsReport {
4658        cache_dir: path_status(&cache_dir)?,
4659        db: path_status(&absolute_db)?,
4660        db_wal: path_status(&db_sidecar_path(&absolute_db, "wal"))?,
4661        db_shm: path_status(&db_sidecar_path(&absolute_db, "shm"))?,
4662        db_journal: path_status(&db_sidecar_path(&absolute_db, "journal"))?,
4663        mcp_config: path_status(&mcp_config_path_for_db(&absolute_db))?,
4664        config_path: resolved_config.map(|path| normalize_display_path(&path)),
4665        repo_root,
4666        root_detection_source,
4667        root_verified: root_mismatches.is_empty(),
4668        root_mismatches,
4669        map_path: normalize_display_path(&config.map_path),
4670        nonsource_files_path: normalize_display_path(&config.nonsource_files_path),
4671        default_format: format!("{format:?}").to_ascii_lowercase(),
4672        default_search_case_sensitive: false,
4673        search_source: "sqlite-file-text".to_string(),
4674        text_index_max_bytes: config.text_index_max_bytes(),
4675        watcher: watcher_status_report(false),
4676        index,
4677        telemetry,
4678        database,
4679        language_registry: language_registry_report(),
4680        semantic_relation_contract_digest: semantic_resolution_contract_digest(),
4681        relation_family_inventory: relation_family_inventory_report(),
4682        search,
4683        optional_parser_pack,
4684    })
4685}
4686
4687/// Reject mixed settings projections when publication changed between read snapshots.
4688fn settings_publication_matches(
4689    diagnostic: Option<&DatabasePublicationReport>,
4690    snapshot: Option<&IndexPublication>,
4691) -> bool {
4692    match (diagnostic, snapshot) {
4693        (None, None) => true,
4694        (Some(diagnostic), Some(snapshot))
4695            if diagnostic.state == snapshot.state
4696                && diagnostic.generation == snapshot.generation =>
4697        {
4698            match diagnostic.contract_fingerprint_state {
4699                DatabasePublicationContractState::Missing => {
4700                    snapshot.contract_fingerprint.is_none()
4701                }
4702                DatabasePublicationContractState::Valid => {
4703                    diagnostic.contract_fingerprint.as_deref()
4704                        == snapshot.contract_fingerprint.as_deref()
4705                }
4706                DatabasePublicationContractState::Invalid => false,
4707            }
4708        }
4709        _ => false,
4710    }
4711}
4712
4713/// Build index statistics for settings diagnostics.
4714pub(crate) fn settings_index_stats(store: &AtlasStore) -> Result<SettingsIndexStats, CliError> {
4715    let overview = store.overview()?;
4716    let health_findings = store.unresolved_health_finding_count_current()?;
4717    Ok(SettingsIndexStats {
4718        project_root: store
4719            .project_root()?
4720            .map(|path| normalize_native_path_display_str(&path)),
4721        files: overview.files,
4722        folders: overview.folders,
4723        missing_purposes: overview.missing_purposes,
4724        stale_purposes: overview.stale_purposes,
4725        suggested_purposes: overview.suggested_purposes,
4726        indexed_text_files: store.file_text_count()?,
4727        indexed_text_bytes: store.file_text_byte_count()?,
4728        symbols: store.symbol_count()?,
4729        relations: store.symbol_relation_count()?,
4730        token_calls: store.token_overview(None)?.calls,
4731        health_findings,
4732    })
4733}
4734
4735/// Preview or remove local runtime index/cache files.
4736pub(crate) fn reset_index_files(
4737    db: &Path,
4738    apply: bool,
4739    dry_run: bool,
4740    include_mcp_config: bool,
4741) -> Result<ResetIndexReport, CliError> {
4742    let absolute_db = absolute_path(db)?;
4743    let mut targets = vec![
4744        absolute_db.clone(),
4745        db_sidecar_path(&absolute_db, "wal"),
4746        db_sidecar_path(&absolute_db, "shm"),
4747        db_sidecar_path(&absolute_db, "journal"),
4748    ];
4749    if include_mcp_config {
4750        targets.push(mcp_config_path_for_db(&absolute_db));
4751    }
4752    targets.sort();
4753    targets.dedup();
4754    let files = targets
4755        .iter()
4756        .map(|path| path_status(path))
4757        .collect::<Result<Vec<_>, _>>()?;
4758    let should_apply = apply && !dry_run;
4759    let mut removed = 0;
4760    if should_apply {
4761        for target in &targets {
4762            if target.is_file() {
4763                fs::remove_file(target).map_err(|source| CliError::Io {
4764                    path: target.clone(),
4765                    source,
4766                })?;
4767                removed += 1;
4768            }
4769        }
4770    }
4771    Ok(ResetIndexReport {
4772        applied: should_apply,
4773        dry_run: !should_apply,
4774        files,
4775        removed,
4776    })
4777}
4778
4779/// Resolve the config path that should travel with generated MCP configs.
4780pub(crate) fn resolved_mcp_config_path(
4781    db: &Path,
4782    config: Option<&Path>,
4783) -> Result<Option<PathBuf>, CliError> {
4784    if let Some(path) = config {
4785        return Ok(Some(absolute_path(path)?));
4786    }
4787    let mut candidate_roots = Vec::new();
4788    if db.exists()
4789        && let Some(project_root) = read_project_root_read_only(db)?
4790    {
4791        candidate_roots.push(PathBuf::from(project_root));
4792    }
4793    let absolute_db = absolute_path(db)?;
4794    if let Some(project_root) = project_root_from_db_path(&absolute_db) {
4795        candidate_roots.push(project_root);
4796    }
4797    for root in candidate_roots {
4798        for candidate in config_candidates_for_root(&root) {
4799            if candidate.exists() {
4800                return Ok(Some(absolute_path(&candidate)?));
4801            }
4802        }
4803    }
4804    Ok(None)
4805}
4806
4807/// Return supported config paths for one project root.
4808fn config_candidates_for_root(root: &Path) -> [PathBuf; 2] {
4809    [
4810        root.join(".projectatlas").join("config.toml"),
4811        root.join("projectatlas.toml"),
4812    ]
4813}
4814
4815/// Return an absolute path without requiring the target to exist.
4816pub(crate) fn absolute_path(path: &Path) -> Result<PathBuf, CliError> {
4817    if path.is_absolute() {
4818        return Ok(path.to_path_buf());
4819    }
4820    let current_dir = std::env::current_dir().map_err(|source| CliError::Io {
4821        path: PathBuf::from("."),
4822        source,
4823    })?;
4824    Ok(current_dir.join(path))
4825}
4826
4827/// Return a diagnostic status for one path.
4828pub(crate) fn path_status(path: &Path) -> Result<PathStatus, CliError> {
4829    let absolute = absolute_path(path)?;
4830    let metadata = fs::metadata(&absolute).ok();
4831    Ok(PathStatus {
4832        path: normalize_display_path(&absolute),
4833        exists: metadata.is_some(),
4834        size_bytes: metadata
4835            .as_ref()
4836            .and_then(|metadata| metadata.is_file().then_some(metadata.len())),
4837    })
4838}
4839
4840/// Return the path to a `SQLite` sidecar file.
4841pub(crate) fn db_sidecar_path(db: &Path, suffix: &str) -> PathBuf {
4842    PathBuf::from(format!("{}-{suffix}", db.display()))
4843}
4844
4845/// Return the project-local MCP config path associated with a database path.
4846pub(crate) fn mcp_config_path_for_db(db: &Path) -> PathBuf {
4847    db.parent().map_or_else(
4848        || PathBuf::from("projectatlas.mcp.json"),
4849        |parent| parent.join("projectatlas.mcp.json"),
4850    )
4851}
4852
4853/// Normalize a path for JSON/TOON diagnostics.
4854pub(crate) fn normalize_display_path(path: &Path) -> String {
4855    normalize_native_path_display(path)
4856}
4857
4858/// Build a watcher status report from a lightweight runtime probe.
4859pub(crate) fn watcher_status_report(active: bool) -> WatchStatusReport {
4860    let notify_available = notify_runtime_available();
4861    let mode = if notify_available {
4862        WATCH_MODE_NOTIFY
4863    } else {
4864        WATCH_MODE_POLLING
4865    };
4866    let recommendation = if notify_available {
4867        "Run `projectatlas watch --once` for one refresh or `projectatlas watch` for event-backed refresh with portable polling fallback."
4868    } else {
4869        "Run `projectatlas watch --once` for one refresh or `projectatlas watch` for portable polling refresh."
4870    };
4871    WatchStatusReport {
4872        available: true,
4873        active,
4874        mode: mode.to_string(),
4875        event_backend_available: notify_available,
4876        recommendation: recommendation.to_string(),
4877    }
4878}
4879
4880/// Build lint output for an existing `SQLite` index.
4881pub(crate) fn lint_database_if_present(
4882    db: &Path,
4883    root: &Path,
4884    config_path: Option<&Path>,
4885    purpose_level: PurposeLintLevel,
4886) -> Result<(String, i32), CliError> {
4887    match db.try_exists() {
4888        Ok(false) => return Ok((String::new(), 0)),
4889        Ok(true) => {}
4890        Err(source) => {
4891            return Err(CliError::Io {
4892                path: db.to_path_buf(),
4893                source,
4894            });
4895        }
4896    }
4897    let store = open_fresh_atlas_store_for_project(db, root, config_path)?;
4898    let query = purpose_level.health_query();
4899    let page = store.unresolved_health_findings_page_current(&query)?;
4900    let blocking = page
4901        .findings
4902        .iter()
4903        .filter(|finding| purpose_level.blocks_category(finding.category.as_str()))
4904        .collect::<Vec<_>>();
4905    if blocking.is_empty() {
4906        return Ok((String::new(), 0));
4907    }
4908    let mut report = format!(
4909        "ProjectAtlas SQLite index health findings (purpose-level {}, showing {} of {}):\n",
4910        purpose_level.as_str(),
4911        blocking.len(),
4912        page.total
4913    );
4914    for finding in blocking {
4915        writeln!(
4916            &mut report,
4917            "- [{}] {}: {}",
4918            finding.category, finding.path, finding.recommendation
4919        )
4920        .map_err(|source| CliError::Output(io::Error::other(source.to_string())))?;
4921    }
4922    Ok((report, 1))
4923}
4924
4925/// Purpose curation strictness used by `projectatlas lint`.
4926#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4927pub(crate) enum PurposeLintLevel {
4928    /// Advisory first-pass curation scope for folders and high-impact files.
4929    Low,
4930    /// Also require agent review for all source files.
4931    Medium,
4932    /// Require agent review for every indexed file and folder.
4933    Strict,
4934}
4935
4936impl PurposeLintLevel {
4937    /// Stable CLI/report label.
4938    pub(crate) fn as_str(self) -> &'static str {
4939        match self {
4940            Self::Low => "low",
4941            Self::Medium => "medium",
4942            Self::Strict => "strict",
4943        }
4944    }
4945
4946    /// Convert lint strictness into the bounded DB health query.
4947    fn health_query(self) -> HealthQuery {
4948        let scope = match self {
4949            Self::Low => HealthScope::purpose_default(),
4950            Self::Medium => HealthScope::purpose_with_source_files(),
4951            Self::Strict => HealthScope::purpose_strict(),
4952        };
4953        HealthQuery {
4954            start_index: 0,
4955            limit: MAX_HEALTH_LIMIT,
4956            category: None,
4957            severity: Some(Severity::Warning),
4958            path_prefix: None,
4959            summary_only: false,
4960            scope,
4961        }
4962    }
4963
4964    /// Return whether a category should make lint fail at this strictness.
4965    fn blocks_category(self, category: &str) -> bool {
4966        match category {
4967            CATEGORY_STALE_PURPOSE
4968            | CATEGORY_DUPLICATE_PURPOSE
4969            | CATEGORY_REPEATED_TEMPORARY_FOLDER => true,
4970            CATEGORY_MISSING_PURPOSE
4971            | CATEGORY_SUGGESTED_PURPOSE_REVIEW
4972            | CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => self != Self::Low,
4973            _ => false,
4974        }
4975    }
4976}
4977
4978/// Return whether the platform watcher can be constructed in this process.
4979pub(crate) fn notify_runtime_available() -> bool {
4980    let (sender, _receiver) = mpsc::channel();
4981    RecommendedWatcher::new(
4982        move |result: notify::Result<Event>| {
4983            if sender.send(result).is_err() {
4984                // Receiver shutdown only means this status probe is done.
4985            }
4986        },
4987        Config::default(),
4988    )
4989    .is_ok()
4990}
4991
4992/// Options controlling source parsing during symbol graph builds.
4993#[derive(Clone, Copy, Debug)]
4994pub(crate) struct SymbolBuildOptions {
4995    /// Maximum file size parsed for symbols.
4996    pub(crate) max_bytes: u64,
4997    /// Optional maximum worker threads for parser work.
4998    max_workers: Option<usize>,
4999    /// Optional deadline for starting parser work.
5000    timeout: Option<Duration>,
5001    /// Serialized timeout value for reports.
5002    pub(crate) timeout_seconds: Option<u64>,
5003}
5004
5005impl SymbolBuildOptions {
5006    /// Create symbol build options from CLI/MCP values.
5007    pub(crate) fn new(
5008        max_bytes: u64,
5009        max_workers: Option<usize>,
5010        timeout_seconds: Option<u64>,
5011    ) -> Self {
5012        Self {
5013            max_bytes: max_bytes.min(MAX_SYMBOL_FILE_BYTES),
5014            max_workers: max_workers.filter(|workers| *workers > 0),
5015            timeout: timeout_seconds.map(Duration::from_secs),
5016            timeout_seconds,
5017        }
5018    }
5019
5020    /// Apply a worker ceiling without weakening a tighter caller limit.
5021    #[must_use]
5022    pub(crate) fn with_worker_ceiling(mut self, max_workers: usize) -> Self {
5023        let ceiling = max_workers.max(1);
5024        self.max_workers = Some(
5025            self.max_workers
5026                .map_or(ceiling, |workers| workers.min(ceiling)),
5027        );
5028        self
5029    }
5030
5031    /// Return the worker count that will be reported.
5032    pub(crate) fn reported_workers(self) -> usize {
5033        self.effective_workers()
5034    }
5035
5036    /// Derive the worker count from caller policy, host availability, and the safety ceiling.
5037    fn effective_workers(self) -> usize {
5038        let available = thread::available_parallelism().map_or(1, usize::from);
5039        self.max_workers
5040            .unwrap_or(available)
5041            .min(available)
5042            .min(INDEX_WORKER_SAFE_CEILING)
5043    }
5044
5045    /// Return whether the parser build deadline has elapsed.
5046    pub(crate) fn is_timed_out(self, started_at: Instant) -> bool {
5047        self.timeout
5048            .is_some_and(|timeout| started_at.elapsed() >= timeout)
5049    }
5050}
5051
5052/// Bound a worker pool by its work cardinality and runtime ceiling.
5053fn worker_count_for_work(work_items: usize, max_workers: usize) -> usize {
5054    work_items.min(max_workers.clamp(1, INDEX_WORKER_SAFE_CEILING))
5055}
5056
5057/// Aggregate rows and retained string bytes admitted by one symbol publication.
5058#[derive(Clone, Copy, Debug)]
5059struct SymbolPublicationLimits {
5060    /// Maximum symbol rows persisted by the operation.
5061    symbol_rows: u64,
5062    /// Maximum relation rows persisted by the operation.
5063    relation_rows: u64,
5064    /// Maximum retained parser-output string bytes persisted by the operation.
5065    output_bytes: u64,
5066}
5067
5068impl SymbolPublicationLimits {
5069    /// Durable process-safe limits used by CLI and MCP indexing operations.
5070    const STANDARD: Self = Self {
5071        symbol_rows: 2_000_000,
5072        relation_rows: 8_000_000,
5073        output_bytes: MAX_PUBLICATION_STAGING_BYTES,
5074    };
5075}
5076
5077/// Create one indexing boundary with the caller timeout capped by the safe default.
5078pub(crate) fn index_work_control(options: &SymbolBuildOptions) -> IndexWorkControl {
5079    IndexWorkControl::new(IndexCancellation::new(), options.timeout)
5080        .with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT)
5081        .with_worker_ceiling(options.effective_workers())
5082}
5083
5084/// Create a bounded work boundary for runtime paths without symbol options.
5085pub(crate) fn standalone_index_work_control() -> IndexWorkControl {
5086    IndexWorkControl::new(IndexCancellation::new(), Some(DEFAULT_INDEX_WORK_TIMEOUT))
5087}
5088
5089/// Apply the runtime's safe whole-operation deadline without weakening caller bounds.
5090fn bounded_index_work_control(control: &IndexWorkControl) -> IndexWorkControl {
5091    control.with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT)
5092}
5093
5094/// Source file queued for symbol parsing.
5095#[derive(Clone, Debug)]
5096pub(crate) struct SymbolParseJob {
5097    /// Repository-relative file path.
5098    pub(crate) path: String,
5099    /// Native absolute file path.
5100    native_path: PathBuf,
5101    /// Content hash captured by the staged filesystem scan.
5102    expected_content_hash: String,
5103    /// Detected language name.
5104    language: Option<String>,
5105    /// Existing node summary fallback.
5106    fallback_summary: Option<String>,
5107    /// Whether a generated purpose suggestion should be written or refreshed.
5108    purpose_needs_suggestion: bool,
5109}
5110
5111/// Successful parser output waiting for sequential DB persistence.
5112#[derive(Debug)]
5113pub(crate) struct SymbolParseSuccess {
5114    /// Repository-relative file path.
5115    pub(crate) path: String,
5116    /// Extracted symbol graph.
5117    graph: SymbolGraph,
5118    /// File-level parser kept independent from fact-level parser provenance.
5119    source_parser: ParserKind,
5120    /// Observed one-line source summary.
5121    summary: String,
5122    /// Whether the existing parser worker derived the summary through the structural adapter.
5123    summary_is_structural: bool,
5124    /// Optional generated purpose suggestion.
5125    purpose_suggestion: Option<String>,
5126}
5127
5128/// Outcome from one parser worker.
5129#[derive(Debug)]
5130pub(crate) enum SymbolParseOutcome {
5131    /// Source parsed successfully.
5132    Parsed(SymbolParseSuccess),
5133    /// File was skipped because it was not UTF-8 source text.
5134    BinaryOrNonUtf8 {
5135        /// Repository-relative file path.
5136        path: String,
5137    },
5138    /// Source bytes changed after the staged filesystem scan.
5139    SourceChanged {
5140        /// Repository-relative file path.
5141        path: String,
5142    },
5143    /// Source read failed.
5144    Io {
5145        /// Native path that failed to read.
5146        path: PathBuf,
5147        /// Source IO error.
5148        source: io::Error,
5149    },
5150    /// Cooperative parsing work was canceled or reached its deadline.
5151    IndexWork(IndexWorkFailure),
5152}
5153
5154/// Failure from one cancellation-aware bounded source read.
5155#[derive(Debug)]
5156enum SourceReadFailure {
5157    /// The source file could not be opened or read.
5158    Io(io::Error),
5159    /// The shared indexing operation was canceled or reached its deadline.
5160    IndexWork(IndexWorkFailure),
5161    /// The source grew beyond the caller's admitted byte count.
5162    LimitExceeded {
5163        /// First observed byte count beyond the limit.
5164        observed: u64,
5165    },
5166}
5167
5168/// Read at most one admitted source-byte budget while checking cooperative stop state.
5169fn read_source_bytes_controlled(
5170    path: &Path,
5171    max_bytes: u64,
5172    stage: IndexWorkStage,
5173    control: &IndexWorkControl,
5174) -> Result<Vec<u8>, SourceReadFailure> {
5175    control.check(stage).map_err(SourceReadFailure::IndexWork)?;
5176    let mut file = fs::File::open(path).map_err(SourceReadFailure::Io)?;
5177    let mut bytes = Vec::new();
5178    let mut buffer = [0_u8; CONTROLLED_SOURCE_READ_BUFFER_BYTES];
5179    loop {
5180        control.check(stage).map_err(SourceReadFailure::IndexWork)?;
5181        let count = file.read(&mut buffer).map_err(SourceReadFailure::Io)?;
5182        if count == 0 {
5183            break;
5184        }
5185        let observed = u64::try_from(bytes.len())
5186            .unwrap_or(u64::MAX)
5187            .saturating_add(count as u64);
5188        if observed > max_bytes {
5189            return Err(SourceReadFailure::LimitExceeded { observed });
5190        }
5191        bytes.extend_from_slice(&buffer[..count]);
5192    }
5193    control.check(stage).map_err(SourceReadFailure::IndexWork)?;
5194    Ok(bytes)
5195}
5196
5197/// Build selected symbol graphs under explicit aggregate publication limits.
5198#[cfg(test)]
5199fn build_symbols_for_paths_with_limits(
5200    store: &mut AtlasStore,
5201    root: &Path,
5202    options: &SymbolBuildOptions,
5203    previous_hashes: Option<&HashMap<String, String>>,
5204    target_paths: Option<&HashSet<String>>,
5205    control: &IndexWorkControl,
5206    limits: SymbolPublicationLimits,
5207) -> Result<SymbolBuildReport, CliError> {
5208    let nodes = if let Some(paths) = target_paths {
5209        let mut sorted_paths = paths.iter().cloned().collect::<Vec<_>>();
5210        sorted_paths.sort();
5211        store
5212            .load_nodes_by_paths(&sorted_paths)?
5213            .into_iter()
5214            .map(|indexed| indexed.node)
5215            .collect::<Vec<_>>()
5216    } else {
5217        store
5218            .load_nodes()?
5219            .into_iter()
5220            .map(|indexed| indexed.node)
5221            .collect::<Vec<_>>()
5222    };
5223    #[cfg(feature = "optional-parser-supervisor")]
5224    let optional_parser_selection =
5225        OptionalParserPackLifecycle::new(root, None)?.derive_project_selection()?;
5226    let staged = stage_symbols_for_nodes_with_limits(
5227        store,
5228        root,
5229        #[cfg(feature = "optional-parser-supervisor")]
5230        &optional_parser_selection,
5231        &nodes,
5232        options,
5233        previous_hashes,
5234        target_paths,
5235        &HashSet::new(),
5236        control,
5237        limits,
5238    )?;
5239    apply_symbol_build_stage(store, &staged, control)?;
5240    Ok(staged.report)
5241}
5242
5243/// Build selected symbol mutations without acquiring the `SQLite` writer.
5244#[allow(clippy::too_many_arguments)]
5245fn stage_symbols_for_nodes_with_limits(
5246    store: &AtlasStore,
5247    root: &Path,
5248    #[cfg(feature = "optional-parser-supervisor")]
5249    optional_parser_selection: &OptionalParserPackProjectSelection,
5250    nodes: &[Node],
5251    options: &SymbolBuildOptions,
5252    previous_hashes: Option<&HashMap<String, String>>,
5253    target_paths: Option<&HashSet<String>>,
5254    protected_purpose_paths: &HashSet<String>,
5255    control: &IndexWorkControl,
5256    limits: SymbolPublicationLimits,
5257) -> Result<SymbolBuildStage, CliError> {
5258    control.check(IndexWorkStage::SymbolParsing)?;
5259    #[cfg(feature = "optional-parser-supervisor")]
5260    let admit_optional_languages = optional_parser_selection.selection_key().is_some();
5261    #[cfg(not(feature = "optional-parser-supervisor"))]
5262    let admit_optional_languages = false;
5263    let root = root.canonicalize().map_err(|source| CliError::Io {
5264        path: root.to_path_buf(),
5265        source,
5266    })?;
5267    let considered_paths = nodes
5268        .iter()
5269        .filter(|node| node.kind == NodeKind::File)
5270        .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
5271        .map(|node| node.path.clone())
5272        .collect::<Vec<_>>();
5273    let previously_parsed_paths = store.source_parse_metadata_paths_for_paths(&considered_paths)?;
5274    let mut candidate_paths = nodes
5275        .iter()
5276        .filter(|node| node.kind == NodeKind::File)
5277        .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
5278        .filter(|node| {
5279            is_symbol_candidate_for_admission(
5280                &node.path,
5281                node.language.as_deref(),
5282                admit_optional_languages,
5283            )
5284        })
5285        .map(|node| node.path.clone())
5286        .collect::<Vec<_>>();
5287    candidate_paths.sort();
5288    let existing_nodes = store
5289        .load_nodes_by_paths(&candidate_paths)?
5290        .into_iter()
5291        .map(|indexed| (indexed.node.path.clone(), indexed))
5292        .collect::<HashMap<_, _>>();
5293    let symbol_counts = store.symbol_counts_for_paths(&candidate_paths)?;
5294    let mut report = SymbolBuildReport {
5295        candidates: 0,
5296        parsed: 0,
5297        unchanged: 0,
5298        too_large: 0,
5299        binary_or_non_utf8: 0,
5300        timed_out: 0,
5301        max_workers: options.reported_workers(),
5302        timeout_seconds: options.timeout_seconds,
5303        symbols: 0,
5304        relations: 0,
5305        summaries: 0,
5306        purpose_suggestions: 0,
5307    };
5308    let mut jobs = Vec::new();
5309    let mut changes = Vec::new();
5310    let mut output_bytes = 0_u64;
5311    for node in nodes
5312        .iter()
5313        .filter(|node| node.kind == NodeKind::File)
5314        .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
5315        .filter(|node| {
5316            !is_symbol_candidate_for_admission(
5317                &node.path,
5318                node.language.as_deref(),
5319                admit_optional_languages,
5320            ) && previously_parsed_paths.contains(&node.path)
5321        })
5322    {
5323        output_bytes = checked_symbol_publication_usage(
5324            output_bytes,
5325            node.path.len() as u64 + node.language.as_ref().map_or(0, String::len) as u64,
5326            limits.output_bytes,
5327            IndexWorkResource::OutputBytes,
5328        )?;
5329        changes.push(SymbolProjectionChange::Clear {
5330            path: node.path.clone(),
5331            language: node.language.clone(),
5332        });
5333    }
5334    for node in nodes
5335        .iter()
5336        .filter(|node| node.kind == NodeKind::File)
5337        .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
5338        .filter(|node| {
5339            is_symbol_candidate_for_admission(
5340                &node.path,
5341                node.language.as_deref(),
5342                admit_optional_languages,
5343            )
5344        })
5345    {
5346        control.check(IndexWorkStage::SymbolParsing)?;
5347        report.candidates += 1;
5348        if node.size_bytes.is_some_and(|size| size > options.max_bytes) {
5349            output_bytes = checked_symbol_publication_usage(
5350                output_bytes,
5351                node.path.len() as u64 + node.language.as_ref().map_or(0, String::len) as u64,
5352                limits.output_bytes,
5353                IndexWorkResource::OutputBytes,
5354            )?;
5355            changes.push(SymbolProjectionChange::Clear {
5356                path: node.path.clone(),
5357                language: node.language.clone(),
5358            });
5359            report.too_large += 1;
5360            continue;
5361        }
5362        let symbol_count = symbol_counts.get(&node.path).copied().unwrap_or_default();
5363        if node.content_hash.as_ref().is_some_and(|hash| {
5364            previous_hashes.and_then(|hashes| hashes.get(&node.path)) == Some(hash)
5365        }) {
5366            let has_source_index =
5367                symbol_count > 0 || store.load_source_parse_metadata(&node.path)?.is_some();
5368            if has_source_index {
5369                report.unchanged += 1;
5370                continue;
5371            }
5372        }
5373        let existing = existing_nodes.get(&node.path);
5374        jobs.push(SymbolParseJob {
5375            path: node.path.clone(),
5376            native_path: root.join(repo_path_to_native(&node.path)),
5377            expected_content_hash: node
5378                .content_hash
5379                .clone()
5380                .ok_or_else(|| source_changed_during_derivation(&root, &node.path))?,
5381            language: node.language.clone(),
5382            fallback_summary: existing.and_then(|indexed| indexed.summary.clone()),
5383            purpose_needs_suggestion: !protected_purpose_paths.contains(&node.path)
5384                && existing.is_none_or(|indexed| {
5385                    matches!(
5386                        indexed.purpose.status,
5387                        PurposeStatus::Missing | PurposeStatus::Suggested
5388                    )
5389                }),
5390        });
5391        if jobs.len() > MAX_SYMBOL_PARSE_JOBS {
5392            return Err(IndexWorkFailure::resource_limit(
5393                IndexWorkStage::SymbolParsing,
5394                IndexWorkResource::SymbolJobs,
5395                MAX_SYMBOL_PARSE_JOBS as u64,
5396                jobs.len() as u64,
5397            )
5398            .into());
5399        }
5400    }
5401    report.max_workers = worker_count_for_work(jobs.len(), report.max_workers);
5402    if !jobs.is_empty() {
5403        let pool = ThreadPoolBuilder::new()
5404            .num_threads(report.max_workers)
5405            .build()
5406            .map_err(|source| {
5407                CliError::InvalidInput(format!("symbol worker pool failed: {source}"))
5408            })?;
5409        #[cfg(feature = "optional-parser-supervisor")]
5410        let outcomes = optional_parser_runtime::parse_symbol_jobs_controlled(
5411            &root,
5412            optional_parser_selection,
5413            &pool,
5414            &jobs,
5415            options,
5416            control,
5417        )?;
5418        #[cfg(not(feature = "optional-parser-supervisor"))]
5419        let outcomes = parse_symbol_job_batches_controlled(&pool, &jobs, options, control)?;
5420        for outcome in outcomes {
5421            match outcome {
5422                SymbolParseOutcome::Parsed(parsed) => {
5423                    let next_symbols = checked_symbol_publication_usage(
5424                        report.symbols as u64,
5425                        parsed.graph.symbols.len() as u64,
5426                        limits.symbol_rows,
5427                        IndexWorkResource::SymbolRows,
5428                    )?;
5429                    let next_relations = checked_symbol_publication_usage(
5430                        report.relations as u64,
5431                        parsed.graph.relations.len() as u64,
5432                        limits.relation_rows,
5433                        IndexWorkResource::RelationRows,
5434                    )?;
5435                    let next_output_bytes = checked_symbol_publication_usage(
5436                        output_bytes,
5437                        symbol_parse_output_bytes(&parsed),
5438                        limits.output_bytes,
5439                        IndexWorkResource::OutputBytes,
5440                    )?;
5441                    report.summaries += 1;
5442                    if parsed.purpose_suggestion.is_some() {
5443                        report.purpose_suggestions += 1;
5444                    }
5445                    report.parsed += 1;
5446                    report.symbols = next_symbols as usize;
5447                    report.relations = next_relations as usize;
5448                    output_bytes = next_output_bytes;
5449                    changes.push(SymbolProjectionChange::Parsed(parsed));
5450                }
5451                SymbolParseOutcome::BinaryOrNonUtf8 { path } => {
5452                    let language = nodes
5453                        .iter()
5454                        .find(|node| node.path == path)
5455                        .and_then(|node| node.language.clone());
5456                    output_bytes = checked_symbol_publication_usage(
5457                        output_bytes,
5458                        path.len() as u64 + language.as_ref().map_or(0, String::len) as u64,
5459                        limits.output_bytes,
5460                        IndexWorkResource::OutputBytes,
5461                    )?;
5462                    changes.push(SymbolProjectionChange::Clear { path, language });
5463                    report.binary_or_non_utf8 += 1;
5464                }
5465                SymbolParseOutcome::SourceChanged { path } => {
5466                    return Err(source_changed_during_derivation(&root, &path));
5467                }
5468                SymbolParseOutcome::Io { path, source } => {
5469                    return Err(CliError::Io { path, source });
5470                }
5471                SymbolParseOutcome::IndexWork(failure) => return Err(failure.into()),
5472            }
5473        }
5474    }
5475    control.check(IndexWorkStage::SymbolParsing)?;
5476    Ok(SymbolBuildStage {
5477        report,
5478        changes,
5479        retained_bytes: output_bytes,
5480    })
5481}
5482
5483/// Parse all built-in symbol jobs in bounded Rayon batches.
5484#[cfg(not(feature = "optional-parser-supervisor"))]
5485fn parse_symbol_job_batches_controlled(
5486    pool: &rayon::ThreadPool,
5487    jobs: &[SymbolParseJob],
5488    options: &SymbolBuildOptions,
5489    control: &IndexWorkControl,
5490) -> Result<Vec<SymbolParseOutcome>, CliError> {
5491    let mut outcomes = Vec::with_capacity(jobs.len());
5492    for batch in jobs.chunks(SYMBOL_PARSE_BATCH_SIZE) {
5493        control.check(IndexWorkStage::SymbolParsing)?;
5494        outcomes.extend(parse_symbol_jobs_controlled(pool, batch, options, control)?);
5495    }
5496    Ok(outcomes)
5497}
5498
5499/// Apply prepared symbol mutations inside the parent publication transaction.
5500fn apply_symbol_build_stage(
5501    store: &mut AtlasStore,
5502    staged: &SymbolBuildStage,
5503    control: &IndexWorkControl,
5504) -> Result<(), CliError> {
5505    for change in &staged.changes {
5506        control.check(IndexWorkStage::Publication)?;
5507        match change {
5508            SymbolProjectionChange::Parsed(parsed) => {
5509                store.set_node_summary(&parsed.path, &parsed.summary)?;
5510                if let Some(suggestion) = parsed.purpose_suggestion.as_deref() {
5511                    store.set_suggested_purpose(&parsed.path, suggestion)?;
5512                }
5513                let mut metadata = SourceParseMetadata::from_graph(&parsed.graph);
5514                metadata.parser = parsed.source_parser;
5515                store.replace_symbol_graph_with_metadata(&parsed.graph, &metadata)?;
5516            }
5517            SymbolProjectionChange::Clear { path, language } => {
5518                clear_skipped_symbol_index(store, path, language.as_deref())?;
5519            }
5520        }
5521    }
5522    control.check(IndexWorkStage::Publication)?;
5523    Ok(())
5524}
5525
5526/// Parse one bounded symbol batch under the shared work boundary.
5527#[cfg(not(feature = "optional-parser-supervisor"))]
5528fn parse_symbol_jobs_controlled(
5529    pool: &rayon::ThreadPool,
5530    jobs: &[SymbolParseJob],
5531    options: &SymbolBuildOptions,
5532    control: &IndexWorkControl,
5533) -> Result<Vec<SymbolParseOutcome>, CliError> {
5534    control.check(IndexWorkStage::SymbolParsing)?;
5535    Ok(pool.install(|| {
5536        jobs.par_iter()
5537            .map(|job| parse_symbol_job_controlled(job, options, control))
5538            .collect::<Vec<_>>()
5539    }))
5540}
5541
5542/// Admit one aggregate symbol-publication resource before persistence.
5543fn checked_symbol_publication_usage(
5544    current: u64,
5545    added: u64,
5546    limit: u64,
5547    resource: IndexWorkResource,
5548) -> Result<u64, CliError> {
5549    let observed = current.saturating_add(added);
5550    if observed > limit {
5551        return Err(IndexWorkFailure::resource_limit(
5552            IndexWorkStage::SymbolParsing,
5553            resource,
5554            limit,
5555            observed,
5556        )
5557        .into());
5558    }
5559    Ok(observed)
5560}
5561
5562/// Count retained string bytes in one parser output without serializing a second copy.
5563fn symbol_parse_output_bytes(parsed: &SymbolParseSuccess) -> u64 {
5564    let graph = &parsed.graph;
5565    let mut bytes = graph.path.len() as u64
5566        + graph.language.as_ref().map_or(0, String::len) as u64
5567        + parsed.summary.len() as u64
5568        + parsed.purpose_suggestion.as_ref().map_or(0, String::len) as u64;
5569    for symbol in &graph.symbols {
5570        bytes = bytes.saturating_add(
5571            symbol.path.len() as u64
5572                + symbol.language.as_ref().map_or(0, String::len) as u64
5573                + symbol.name.len() as u64
5574                + symbol.signature.len() as u64
5575                + symbol.documentation.as_ref().map_or(0, String::len) as u64
5576                + symbol.parent.as_ref().map_or(0, String::len) as u64
5577                + symbol.detail.as_ref().map_or(0, String::len) as u64,
5578        );
5579    }
5580    for relation in &graph.relations {
5581        bytes = bytes.saturating_add(
5582            relation.path.len() as u64
5583                + relation.source_name.len() as u64
5584                + relation.target_name.len() as u64
5585                + relation.context.len() as u64,
5586        );
5587    }
5588    bytes
5589}
5590
5591/// Parse one source file into a symbol graph.
5592#[cfg(test)]
5593pub(crate) fn parse_symbol_job(
5594    job: &SymbolParseJob,
5595    options: &SymbolBuildOptions,
5596    started_at: Instant,
5597) -> SymbolParseOutcome {
5598    let control = options
5599        .timeout
5600        .and_then(|timeout| started_at.checked_add(timeout))
5601        .map_or_else(
5602            || IndexWorkControl::new(IndexCancellation::new(), None),
5603            |deadline| IndexWorkControl::with_deadline(IndexCancellation::new(), deadline),
5604        );
5605    parse_symbol_job_controlled(job, options, &control)
5606}
5607
5608/// Parse one source file while observing cancellation before and during parsing.
5609fn parse_symbol_job_controlled(
5610    job: &SymbolParseJob,
5611    options: &SymbolBuildOptions,
5612    control: &IndexWorkControl,
5613) -> SymbolParseOutcome {
5614    if options.is_timed_out(control.started_at()) {
5615        return SymbolParseOutcome::IndexWork(IndexWorkFailure::DeadlineExceeded {
5616            stage: IndexWorkStage::SymbolParsing,
5617        });
5618    }
5619    if let Err(failure) = control.check(IndexWorkStage::SymbolParsing) {
5620        return SymbolParseOutcome::IndexWork(failure);
5621    }
5622    let content = match admit_symbol_job_source(job, options, control) {
5623        Ok(content) => content,
5624        Err(outcome) => return *outcome,
5625    };
5626    parse_admitted_symbol_job(job, &content, None, options, control)
5627}
5628
5629/// Read, bound, hash-check, and decode one source exactly once for symbol staging.
5630fn admit_symbol_job_source(
5631    job: &SymbolParseJob,
5632    options: &SymbolBuildOptions,
5633    control: &IndexWorkControl,
5634) -> Result<String, Box<SymbolParseOutcome>> {
5635    let bytes = match read_source_bytes_controlled(
5636        &job.native_path,
5637        options.max_bytes,
5638        IndexWorkStage::SymbolParsing,
5639        control,
5640    ) {
5641        Ok(bytes) => bytes,
5642        Err(SourceReadFailure::Io(source)) => {
5643            return Err(Box::new(SymbolParseOutcome::Io {
5644                path: job.native_path.clone(),
5645                source,
5646            }));
5647        }
5648        Err(SourceReadFailure::IndexWork(failure)) => {
5649            return Err(Box::new(SymbolParseOutcome::IndexWork(failure)));
5650        }
5651        Err(SourceReadFailure::LimitExceeded { observed }) => {
5652            return Err(Box::new(SymbolParseOutcome::IndexWork(
5653                IndexWorkFailure::resource_limit(
5654                    IndexWorkStage::SymbolParsing,
5655                    IndexWorkResource::SourceBytes,
5656                    options.max_bytes,
5657                    observed,
5658                ),
5659            )));
5660        }
5661    };
5662    if let Err(failure) = control.check(IndexWorkStage::SymbolParsing) {
5663        return Err(Box::new(SymbolParseOutcome::IndexWork(failure)));
5664    }
5665    if blake3::hash(&bytes).to_hex().as_str() != job.expected_content_hash {
5666        return Err(Box::new(SymbolParseOutcome::SourceChanged {
5667            path: job.path.clone(),
5668        }));
5669    }
5670    let Ok(content) = String::from_utf8(bytes) else {
5671        return Err(Box::new(SymbolParseOutcome::BinaryOrNonUtf8 {
5672            path: job.path.clone(),
5673        }));
5674    };
5675    Ok(content)
5676}
5677
5678/// Extract conservative facts from admitted source and retain independent source provenance.
5679fn parse_admitted_symbol_job(
5680    job: &SymbolParseJob,
5681    content: &str,
5682    source_parser: Option<ParserKind>,
5683    options: &SymbolBuildOptions,
5684    control: &IndexWorkControl,
5685) -> SymbolParseOutcome {
5686    if options.is_timed_out(control.started_at()) {
5687        return SymbolParseOutcome::IndexWork(IndexWorkFailure::DeadlineExceeded {
5688            stage: IndexWorkStage::SymbolParsing,
5689        });
5690    }
5691    let graph =
5692        match extract_symbol_graph_controlled(&job.path, job.language.as_deref(), content, control)
5693        {
5694            Ok(graph) => graph,
5695            Err(failure) => return SymbolParseOutcome::IndexWork(failure),
5696        };
5697    let source_parser = source_parser.unwrap_or(graph.parser);
5698    let structural_summary = graph
5699        .symbols
5700        .is_empty()
5701        .then(|| structural_summary_for_path(&job.path, job.language.as_deref(), content));
5702    let structural_summary = structural_summary.flatten();
5703    let summary_is_structural = structural_summary.is_some();
5704    let summary = structural_summary
5705        .unwrap_or_else(|| summarize_symbol_graph(&graph, job.fallback_summary.as_deref()));
5706    let purpose_suggestion = job
5707        .purpose_needs_suggestion
5708        .then(|| suggest_file_purpose(&job.path, &summary));
5709    SymbolParseOutcome::Parsed(SymbolParseSuccess {
5710        path: job.path.clone(),
5711        graph,
5712        source_parser,
5713        summary,
5714        summary_is_structural,
5715        purpose_suggestion,
5716    })
5717}
5718
5719/// Return an empty symbol build report.
5720pub(crate) fn empty_symbol_build_report() -> SymbolBuildReport {
5721    SymbolBuildReport {
5722        candidates: 0,
5723        parsed: 0,
5724        unchanged: 0,
5725        too_large: 0,
5726        binary_or_non_utf8: 0,
5727        timed_out: 0,
5728        max_workers: 0,
5729        timeout_seconds: None,
5730        symbols: 0,
5731        relations: 0,
5732        summaries: 0,
5733        purpose_suggestions: 0,
5734    }
5735}
5736
5737/// Return an empty text-index report for a no-op refresh.
5738fn empty_text_index_report(options: TextIndexOptions) -> TextIndexReport {
5739    TextIndexReport {
5740        candidates: 0,
5741        indexed: 0,
5742        binary_or_non_utf8: 0,
5743        too_large: 0,
5744        skipped: 0,
5745        max_bytes: options.max_bytes,
5746        bytes: 0,
5747    }
5748}
5749
5750/// Create a deterministic one-line content summary from extracted symbols.
5751pub(crate) fn summarize_symbol_graph(graph: &SymbolGraph, fallback: Option<&str>) -> String {
5752    if graph.symbols.is_empty() {
5753        if let Some(fallback) = fallback.filter(|summary| !is_scanner_fallback_summary(summary)) {
5754            return fallback.to_string();
5755        }
5756        let language = observed_language_label(graph.language.as_deref());
5757        return format!("{language} source file with no declarations found.");
5758    }
5759    let language = observed_language_label(graph.language.as_deref());
5760    let primary_names = primary_symbol_names(graph, 4);
5761    let primary_kinds = primary_symbol_kinds(graph);
5762    let imports = relation_targets(graph, RelationKind::Imports, 2);
5763    let dependencies = relation_targets(graph, RelationKind::DependsOn, 3);
5764    if !dependencies.is_empty() {
5765        let subject = observed_manifest_subject(&language);
5766        return format!(
5767            "{subject} declaring {} and depending on {}.",
5768            primary_names.join(", "),
5769            dependencies.join(", ")
5770        );
5771    }
5772    if !imports.is_empty() {
5773        return format!(
5774            "{language} source defining {} {} with imports {}.",
5775            primary_kinds,
5776            primary_names.join(", "),
5777            imports.join(", ")
5778        );
5779    }
5780    format!(
5781        "{language} source defining {} {}.",
5782        primary_kinds,
5783        primary_names.join(", ")
5784    )
5785}
5786
5787/// Return a readable language label for agent-facing content summaries.
5788fn observed_language_label(language: Option<&str>) -> String {
5789    match language.unwrap_or("source") {
5790        "cargo-manifest" => "cargo manifest".to_string(),
5791        "cargo-lock" => "cargo lock".to_string(),
5792        "rust-build-script" => "rust build script".to_string(),
5793        "objective-c" => "Objective-C".to_string(),
5794        "csharp" => "C#".to_string(),
5795        "cpp" => "C++".to_string(),
5796        other => other.replace('-', " "),
5797    }
5798}
5799
5800/// Return the subject phrase for manifest-style content summaries.
5801fn observed_manifest_subject(language: &str) -> String {
5802    if language.contains("manifest") {
5803        language.to_string()
5804    } else {
5805        format!("{language} manifest")
5806    }
5807}
5808
5809/// Return a compact phrase describing the most important symbol kinds.
5810pub(crate) fn primary_symbol_kinds(graph: &SymbolGraph) -> String {
5811    let mut function_like = 0_usize;
5812    let mut type_like = 0_usize;
5813    let mut manifest_like = 0_usize;
5814    let mut value_like = 0_usize;
5815    for symbol in &graph.symbols {
5816        match symbol.kind {
5817            SymbolKind::Function | SymbolKind::Method => function_like += 1,
5818            SymbolKind::Class
5819            | SymbolKind::Struct
5820            | SymbolKind::Enum
5821            | SymbolKind::Trait
5822            | SymbolKind::Interface
5823            | SymbolKind::Type => type_like += 1,
5824            SymbolKind::Package | SymbolKind::Workspace | SymbolKind::Dependency => {
5825                manifest_like += 1;
5826            }
5827            SymbolKind::Value => value_like += 1,
5828            SymbolKind::Module | SymbolKind::Import | SymbolKind::Unknown => {}
5829        }
5830    }
5831    if manifest_like > 0 && function_like == 0 && type_like == 0 {
5832        return "manifest entries".to_string();
5833    }
5834    if value_like > 0 && function_like == 0 && type_like == 0 {
5835        return value_only_symbol_kind_label(graph, value_like);
5836    }
5837    match (type_like, function_like) {
5838        (0, 0) => "symbols".to_string(),
5839        (0, 1) => "function".to_string(),
5840        (0, _) => "functions".to_string(),
5841        (1, 0) => "type".to_string(),
5842        (_, 0) => "types".to_string(),
5843        (1, 1) => "type and function".to_string(),
5844        (1, _) => "type and functions".to_string(),
5845        (_, 1) => "types and function".to_string(),
5846        (_, _) => "types and functions".to_string(),
5847    }
5848}
5849
5850/// Return the right value-only summary noun for the indexed language.
5851pub(crate) fn value_only_symbol_kind_label(graph: &SymbolGraph, count: usize) -> String {
5852    let language = graph.language.as_deref().unwrap_or_default();
5853    let binding_language = matches!(
5854        language,
5855        "javascript" | "typescript" | "tsx" | "vue" | "svelte"
5856    ) || graph
5857        .symbols
5858        .iter()
5859        .any(|symbol| symbol.detail.as_deref() == Some("fallback-composition-binding"));
5860    let singular = if binding_language { "binding" } else { "value" };
5861    let plural = if binding_language {
5862        "bindings"
5863    } else {
5864        "values"
5865    };
5866    if count == 1 {
5867        singular.to_string()
5868    } else {
5869        plural.to_string()
5870    }
5871}
5872
5873/// Return stable names for the most important declaration symbols.
5874pub(crate) fn primary_symbol_names(graph: &SymbolGraph, limit: usize) -> Vec<String> {
5875    let has_primary_definitions = graph.symbols.iter().any(|symbol| {
5876        matches!(
5877            symbol.kind,
5878            SymbolKind::Function
5879                | SymbolKind::Method
5880                | SymbolKind::Class
5881                | SymbolKind::Struct
5882                | SymbolKind::Enum
5883                | SymbolKind::Trait
5884                | SymbolKind::Interface
5885                | SymbolKind::Type
5886        )
5887    });
5888    let mut names = graph
5889        .symbols
5890        .iter()
5891        .filter(|symbol| {
5892            if has_primary_definitions && symbol.kind == SymbolKind::Value {
5893                return false;
5894            }
5895            !matches!(
5896                symbol.kind,
5897                SymbolKind::Import
5898                    | SymbolKind::Dependency
5899                    | SymbolKind::Module
5900                    | SymbolKind::Unknown
5901            )
5902        })
5903        .map(|symbol| symbol.name.clone())
5904        .collect::<Vec<_>>();
5905    if names.is_empty() {
5906        names = graph
5907            .symbols
5908            .iter()
5909            .map(|symbol| symbol.name.clone())
5910            .collect::<Vec<_>>();
5911    }
5912    names.sort();
5913    names.dedup();
5914    names.truncate(limit);
5915    if names.is_empty() {
5916        vec!["indexed symbols".to_string()]
5917    } else {
5918        names
5919    }
5920}
5921
5922/// Return relation targets for one relation kind.
5923pub(crate) fn relation_targets(
5924    graph: &SymbolGraph,
5925    kind: RelationKind,
5926    limit: usize,
5927) -> Vec<String> {
5928    let mut targets = graph
5929        .relations
5930        .iter()
5931        .filter(|relation| relation.kind == kind)
5932        .map(|relation| relation.target_name.clone())
5933        .collect::<Vec<_>>();
5934    targets.sort();
5935    targets.dedup();
5936    targets.truncate(limit);
5937    targets
5938}
5939
5940/// Create a generated file-purpose suggestion from a path and content summary.
5941pub(crate) fn suggest_file_purpose(path: &str, summary: &str) -> String {
5942    let subject = path_context_subject(path);
5943    if summary.contains("dataset manifest") {
5944        if let Some(datasets) = summary_between(summary, " including ", " and keys") {
5945            format!("Define the {subject} dataset manifest for {datasets}.")
5946        } else {
5947            format!("Define the {subject} dataset manifest.")
5948        }
5949    } else if let Some(workflow) = summary_between(summary, "yaml workflow ", " triggered") {
5950        format!("Define the {workflow} workflow.")
5951    } else if summary.contains("manifest") {
5952        if let Some(package) = summary_between(summary, " manifest for ", " with ") {
5953            format!("Define the {package} manifest.")
5954        } else {
5955            format!("Define the {subject} manifest.")
5956        }
5957    } else if let Some(title) = summary_between(summary, "document titled ", " with ") {
5958        format!("Document {title}.")
5959    } else if summary.contains("stylesheet") {
5960        format!("Style the {subject} stylesheet.")
5961    } else if summary.contains("config") {
5962        format!("Configure the {subject}.")
5963    } else if is_gradle_build_script(path) {
5964        if let Some(declarations) = summary_primary_declarations(summary) {
5965            format!("Define Gradle build tasks around {declarations}.")
5966        } else {
5967            "Configure the Gradle build.".to_string()
5968        }
5969    } else if let Some(declarations) = summary_primary_declarations(summary) {
5970        format!("Implement the {subject} source around {declarations}.")
5971    } else if summary.contains("source") {
5972        format!("Implement the {subject} source.")
5973    } else {
5974        format!("Implement the {subject}.")
5975    }
5976}
5977
5978/// Return whether a path is a Gradle build script rather than ordinary Kotlin/Groovy source.
5979fn is_gradle_build_script(path: &str) -> bool {
5980    let normalized = path.replace('\\', "/");
5981    normalized.ends_with("build.gradle") || normalized.ends_with("build.gradle.kts")
5982}
5983
5984/// Return a path-aware subject phrase for a generated purpose suggestion.
5985fn path_context_subject(path: &str) -> String {
5986    let normalized = path.replace('\\', "/");
5987    let mut segments = normalized
5988        .split('/')
5989        .filter(|segment| !segment.is_empty() && *segment != ".")
5990        .collect::<Vec<_>>();
5991    let Some(file_name) = segments.pop() else {
5992        return "path".to_string();
5993    };
5994    let stem = file_name
5995        .rsplit_once('.')
5996        .map_or(file_name, |(stem, _)| stem);
5997    let stem_words = path_segment_words(stem);
5998    let parent_words = segments
5999        .iter()
6000        .rev()
6001        .find(|segment| !is_generic_context_segment(segment))
6002        .map(|segment| path_segment_words(segment));
6003    match parent_words {
6004        Some(parent) if !parent.is_empty() && parent != stem_words => {
6005            format!("{parent} {stem_words}")
6006        }
6007        _ => stem_words,
6008    }
6009}
6010
6011/// Convert one path segment into readable lowercase words.
6012fn path_segment_words(segment: &str) -> String {
6013    let mut words = String::new();
6014    let mut previous_lowercase = false;
6015    for character in segment.chars() {
6016        if character == '-' || character == '_' || character == '.' {
6017            push_word_space(&mut words);
6018            previous_lowercase = false;
6019            continue;
6020        }
6021        if character.is_uppercase() && previous_lowercase {
6022            push_word_space(&mut words);
6023        }
6024        words.extend(character.to_lowercase());
6025        previous_lowercase = character.is_lowercase() || character.is_ascii_digit();
6026    }
6027    let words = words.trim();
6028    if words.is_empty() {
6029        "path".to_string()
6030    } else {
6031        words.to_string()
6032    }
6033}
6034
6035/// Append one word separator when the phrase already has content.
6036fn push_word_space(words: &mut String) {
6037    if !words.ends_with(' ') && !words.is_empty() {
6038        words.push(' ');
6039    }
6040}
6041
6042/// Return whether a path segment is too generic to add useful purpose context.
6043fn is_generic_context_segment(segment: &str) -> bool {
6044    matches!(
6045        segment.to_ascii_lowercase().as_str(),
6046        "src"
6047            | "source"
6048            | "sources"
6049            | "app"
6050            | "apps"
6051            | "lib"
6052            | "libs"
6053            | "crate"
6054            | "crates"
6055            | "package"
6056            | "packages"
6057            | "test"
6058            | "tests"
6059            | "spec"
6060            | "specs"
6061            | "fixture"
6062            | "fixtures"
6063            | "example"
6064            | "examples"
6065            | "script"
6066            | "scripts"
6067    )
6068}
6069
6070/// Extract primary declaration names from a deterministic content summary.
6071fn summary_primary_declarations(summary: &str) -> Option<String> {
6072    let after_marker = summary
6073        .split_once(" source defining ")
6074        .map(|(_, value)| value)
6075        .or_else(|| summary.split_once(" declaring ").map(|(_, value)| value))?;
6076    let declaration_clause = trim_summary_clause(after_marker);
6077    let names = strip_declaration_kind_prefix(declaration_clause)
6078        .split(',')
6079        .map(str::trim)
6080        .filter(|name| !name.is_empty())
6081        .take(3)
6082        .map(ToOwned::to_owned)
6083        .collect::<Vec<_>>();
6084    if names.is_empty() {
6085        None
6086    } else {
6087        Some(join_human_names(&names))
6088    }
6089}
6090
6091/// Trim trailing summary details from a declaration clause.
6092fn trim_summary_clause(value: &str) -> &str {
6093    value
6094        .split(" with imports ")
6095        .next()
6096        .unwrap_or(value)
6097        .split(" and depending on ")
6098        .next()
6099        .unwrap_or(value)
6100        .trim_end_matches('.')
6101        .trim()
6102}
6103
6104/// Remove the deterministic symbol-kind phrase before the primary names.
6105fn strip_declaration_kind_prefix(value: &str) -> &str {
6106    const PREFIXES: &[&str] = &[
6107        "types and functions ",
6108        "type and functions ",
6109        "types and function ",
6110        "type and function ",
6111        "manifest entries ",
6112        "functions ",
6113        "function ",
6114        "types ",
6115        "type ",
6116        "bindings ",
6117        "binding ",
6118        "values ",
6119        "value ",
6120        "symbols ",
6121    ];
6122    PREFIXES
6123        .iter()
6124        .find_map(|prefix| value.strip_prefix(prefix))
6125        .unwrap_or(value)
6126}
6127
6128/// Join declaration names as a compact human phrase.
6129fn join_human_names(names: &[String]) -> String {
6130    match names {
6131        [] => String::new(),
6132        [one] => one.clone(),
6133        [first, second] => format!("{first} and {second}"),
6134        [first, second, third, ..] => format!("{first}, {second}, and {third}"),
6135    }
6136}
6137
6138/// Return a non-empty substring between two markers.
6139fn summary_between<'a>(summary: &'a str, start: &str, end: &str) -> Option<&'a str> {
6140    let after_start = summary.split_once(start)?.1;
6141    let value = after_start.split_once(end)?.0.trim();
6142    (!value.is_empty()).then_some(value)
6143}
6144
6145/// Return whether a language should be parsed for symbols.
6146pub(crate) fn is_symbol_candidate(path: &str, language: Option<&str>) -> bool {
6147    let Some(language) = language else {
6148        return path.ends_with("Cargo.toml")
6149            || path.ends_with("Cargo.lock")
6150            || Path::new(path)
6151                .extension()
6152                .and_then(|extension| extension.to_str())
6153                .is_some_and(|extension| {
6154                    ["vue", "ps1", "psm1", "psd1"]
6155                        .iter()
6156                        .any(|expected| extension.eq_ignore_ascii_case(expected))
6157                });
6158    };
6159    language_capability(language)
6160        .is_none_or(|capability| capability.symbol_parser != SymbolParserOwner::Unavailable)
6161}
6162
6163/// Apply the project-selected optional-language boundary to symbol work admission.
6164fn is_symbol_candidate_for_admission(
6165    path: &str,
6166    language: Option<&str>,
6167    admit_optional_languages: bool,
6168) -> bool {
6169    if !admit_optional_languages
6170        && language
6171            .and_then(language_capability)
6172            .is_some_and(|capability| capability.optional_pack.is_some())
6173    {
6174        return false;
6175    }
6176    is_symbol_candidate(path, language)
6177}
6178
6179/// Clear stale symbol output while preserving structural summaries when present.
6180fn clear_skipped_symbol_index(
6181    store: &AtlasStore,
6182    path: &str,
6183    language: Option<&str>,
6184) -> Result<(), CliError> {
6185    if is_structural_summary_candidate(path, language) {
6186        store.clear_symbol_graph_for_path(path)?;
6187    } else {
6188        store.clear_source_index_for_path(path)?;
6189    }
6190    Ok(())
6191}
6192
6193/// Normalize and validate a user-supplied path as a repository-relative file key.
6194pub(crate) fn validated_file_key(file: &Path) -> Result<String, CliError> {
6195    validated_repo_file_key(file).map_err(|source| CliError::InvalidInput(source.to_string()))
6196}
6197
6198/// Normalize a folder filter into the repository path convention.
6199pub(crate) fn normalized_folder_filter(folder: &str) -> Result<String, CliError> {
6200    let trimmed = folder.trim().trim_end_matches(['/', '\\']);
6201    if trimmed.is_empty() || trimmed == "." {
6202        return Ok(".".to_string());
6203    }
6204    validated_file_key(Path::new(trimmed)).map_err(|_error| {
6205        CliError::InvalidInput(format!(
6206            "folder filter {folder:?} must be a project-relative path"
6207        ))
6208    })
6209}
6210
6211/// Validate that a path belongs to the indexed project file set.
6212pub(crate) fn validated_indexed_file_key(
6213    store: &AtlasStore,
6214    file: &Path,
6215) -> Result<String, CliError> {
6216    let file_key = validated_file_key(file)?;
6217    let indexed = store
6218        .load_node_by_path(&file_key)?
6219        .ok_or_else(|| CliError::InvalidInput(format!("file {file_key:?} is not indexed")))?;
6220    if indexed.node.kind != NodeKind::File {
6221        return Err(CliError::InvalidInput(format!(
6222            "path {file_key:?} is not an indexed file"
6223        )));
6224    }
6225    Ok(file_key)
6226}
6227
6228/// Load the project root recorded by the latest scan.
6229pub(crate) fn indexed_project_root(store: &AtlasStore) -> Result<PathBuf, CliError> {
6230    store.project_root()?.map(PathBuf::from).ok_or_else(|| {
6231        CliError::InvalidInput(
6232            "indexed project root is missing; run projectatlas scan <project-root> first"
6233                .to_string(),
6234        )
6235    })
6236}
6237
6238/// Build an absolute native path for a previously validated indexed file key.
6239pub(crate) fn indexed_native_path(store: &AtlasStore, file_key: &str) -> Result<PathBuf, CliError> {
6240    Ok(indexed_project_root(store)?.join(repo_path_to_native(file_key)))
6241}
6242
6243/// Read content for a previously validated indexed file key.
6244pub(crate) fn read_indexed_file_content(
6245    store: &AtlasStore,
6246    file_key: &str,
6247) -> Result<String, CliError> {
6248    let native = indexed_native_path(store, file_key)?;
6249    let indexed = store.load_node_by_path(file_key)?.ok_or_else(|| {
6250        CliError::InvalidInput(format!("indexed file {file_key:?} was not found"))
6251    })?;
6252    let project_root = normalize_native_path_display(indexed_project_root(store)?);
6253    let metadata = match fs::metadata(&native) {
6254        Ok(metadata) => metadata,
6255        Err(source) if source.kind() == io::ErrorKind::NotFound => {
6256            return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
6257                project_root,
6258                status: IndexReadStatus::RefreshRequired,
6259                reason: IndexRefreshReason::PathsChanged,
6260                scope: IndexRefreshScope::Full,
6261                changed: 1,
6262                added: 0,
6263                removed: 1,
6264                modified: 0,
6265                sample_paths: vec![file_key.to_string()],
6266            })));
6267        }
6268        Err(source) => {
6269            return Err(CliError::VerificationIncomplete(Box::new(
6270                IndexVerificationIncomplete {
6271                    project_root,
6272                    status: IndexReadStatus::VerificationIncomplete,
6273                    reason: IndexVerificationReason::SourceInspectionFailed,
6274                    scope: IndexRefreshScope::Full,
6275                    message: format!("failed to read '{}': {source}", native.display()),
6276                },
6277            )));
6278        }
6279    };
6280    if indexed
6281        .node
6282        .size_bytes
6283        .is_some_and(|indexed_bytes| indexed_bytes != metadata.len())
6284    {
6285        return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
6286            project_root,
6287            status: IndexReadStatus::RefreshRequired,
6288            reason: IndexRefreshReason::SourceChanged,
6289            scope: IndexRefreshScope::Full,
6290            changed: 1,
6291            added: 0,
6292            removed: 0,
6293            modified: 1,
6294            sample_paths: vec![file_key.to_string()],
6295        })));
6296    }
6297    if metadata.len() > MAX_INDEXED_NAVIGATION_SOURCE_BYTES {
6298        return Err(CliError::VerificationIncomplete(Box::new(
6299            IndexVerificationIncomplete {
6300                project_root,
6301                status: IndexReadStatus::VerificationIncomplete,
6302                reason: IndexVerificationReason::SourceTooLarge,
6303                scope: IndexRefreshScope::Full,
6304                message: format!(
6305                    "indexed file {file_key:?} contains {} bytes; bounded navigation reads admit at most {MAX_INDEXED_NAVIGATION_SOURCE_BYTES} bytes",
6306                    metadata.len()
6307                ),
6308            },
6309        )));
6310    }
6311    let file = fs::File::open(&native).map_err(|source| {
6312        CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
6313            project_root: project_root.clone(),
6314            status: IndexReadStatus::VerificationIncomplete,
6315            reason: IndexVerificationReason::SourceInspectionFailed,
6316            scope: IndexRefreshScope::Full,
6317            message: format!("failed to open '{}': {source}", native.display()),
6318        }))
6319    })?;
6320    let mut bytes = Vec::with_capacity(metadata.len() as usize);
6321    file.take(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1)
6322        .read_to_end(&mut bytes)
6323        .map_err(|source| {
6324            CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
6325                project_root: project_root.clone(),
6326                status: IndexReadStatus::VerificationIncomplete,
6327                reason: IndexVerificationReason::SourceInspectionFailed,
6328                scope: IndexRefreshScope::Full,
6329                message: format!("failed to read '{}': {source}", native.display()),
6330            }))
6331        })?;
6332    if bytes.len() as u64 != metadata.len()
6333        || bytes.len() as u64 > MAX_INDEXED_NAVIGATION_SOURCE_BYTES
6334    {
6335        return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
6336            project_root,
6337            status: IndexReadStatus::RefreshRequired,
6338            reason: IndexRefreshReason::SourceChanged,
6339            scope: IndexRefreshScope::Full,
6340            changed: 1,
6341            added: 0,
6342            removed: 0,
6343            modified: 1,
6344            sample_paths: vec![file_key.to_string()],
6345        })));
6346    }
6347    let current_hash = blake3::hash(&bytes).to_hex().to_string();
6348    if indexed.node.content_hash.as_deref() != Some(current_hash.as_str()) {
6349        return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
6350            project_root,
6351            status: IndexReadStatus::RefreshRequired,
6352            reason: IndexRefreshReason::SourceChanged,
6353            scope: IndexRefreshScope::Full,
6354            changed: 1,
6355            added: 0,
6356            removed: 0,
6357            modified: 1,
6358            sample_paths: vec![file_key.to_string()],
6359        })));
6360    }
6361    String::from_utf8(bytes).map_err(|source| {
6362        CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
6363            project_root,
6364            status: IndexReadStatus::VerificationIncomplete,
6365            reason: IndexVerificationReason::SourceInspectionFailed,
6366            scope: IndexRefreshScope::Full,
6367            message: format!("indexed file {file_key:?} is not valid UTF-8: {source}"),
6368        }))
6369    })
6370}
6371
6372/// Run the watcher refresh loop.
6373pub(crate) fn run_watch_loop(
6374    store: &mut AtlasStore,
6375    plan: &ScanRuntimePlan,
6376    once: bool,
6377    poll_seconds: u64,
6378    max_cycles: usize,
6379    symbol_options: &SymbolBuildOptions,
6380) -> Result<WatchReport, CliError> {
6381    if once {
6382        return run_single_watch_refresh(store, plan, symbol_options);
6383    }
6384    run_watch_with_polling_fallback(
6385        store,
6386        plan,
6387        poll_seconds,
6388        max_cycles,
6389        symbol_options,
6390        |store| run_notify_watch_loop(store, plan, poll_seconds, max_cycles, symbol_options),
6391    )
6392}
6393
6394/// Run an event-backed watcher and preserve current changes through polling fallback.
6395fn run_watch_with_polling_fallback<F>(
6396    store: &mut AtlasStore,
6397    plan: &ScanRuntimePlan,
6398    poll_seconds: u64,
6399    max_cycles: usize,
6400    symbol_options: &SymbolBuildOptions,
6401    run_notify: F,
6402) -> Result<WatchReport, CliError>
6403where
6404    F: FnOnce(&mut AtlasStore) -> Result<WatchReport, CliError>,
6405{
6406    match run_notify(store) {
6407        Ok(report) => Ok(report),
6408        Err(error @ CliError::RefreshRequired(_)) => Err(error),
6409        Err(error) => run_polling_watch_loop(
6410            store,
6411            plan,
6412            poll_seconds,
6413            max_cycles,
6414            symbol_options,
6415            Some(error.to_string()),
6416        ),
6417    }
6418}
6419
6420/// Run one deterministic watcher refresh and exit.
6421pub(crate) fn run_single_watch_refresh(
6422    store: &mut AtlasStore,
6423    plan: &ScanRuntimePlan,
6424    symbol_options: &SymbolBuildOptions,
6425) -> Result<WatchReport, CliError> {
6426    let control = index_work_control(symbol_options);
6427    run_single_watch_refresh_controlled(store, plan, symbol_options, &control)
6428}
6429
6430/// Run one watcher refresh under one cancellation and publication boundary.
6431pub(crate) fn run_single_watch_refresh_controlled(
6432    store: &mut AtlasStore,
6433    plan: &ScanRuntimePlan,
6434    symbol_options: &SymbolBuildOptions,
6435    control: &IndexWorkControl,
6436) -> Result<WatchReport, CliError> {
6437    let bounded_control = bounded_index_work_control(control);
6438    let control = &bounded_control;
6439    control.check(IndexWorkStage::RepositoryTraversal)?;
6440    let current_plan = plan.reload_controlled(control)?;
6441    let last_refresh = refresh_index_controlled(store, &current_plan, symbol_options, control)?;
6442    Ok(WatchReport {
6443        mode: WATCH_MODE_ONCE.to_string(),
6444        cycles: 1,
6445        once: true,
6446        fallback_reason: None,
6447        text_index: last_refresh.text_index,
6448        structural_summaries: last_refresh.structural_summaries,
6449        last_symbols: last_refresh.symbols,
6450    })
6451}
6452
6453/// Run an event-backed watcher loop with `notify`.
6454pub(crate) fn run_notify_watch_loop(
6455    store: &mut AtlasStore,
6456    plan: &ScanRuntimePlan,
6457    poll_seconds: u64,
6458    max_cycles: usize,
6459    symbol_options: &SymbolBuildOptions,
6460) -> Result<WatchReport, CliError> {
6461    let mut current_plan = plan.reload()?;
6462    let watch_root = current_plan
6463        .root
6464        .canonicalize()
6465        .map_err(|source| CliError::Io {
6466            path: current_plan.root.clone(),
6467            source,
6468        })?;
6469    let (sender, receiver) = mpsc::sync_channel(WATCH_EVENT_QUEUE_CAPACITY);
6470    let continuity_lost = Arc::new(AtomicBool::new(false));
6471    let callback_continuity_lost = Arc::clone(&continuity_lost);
6472    let mut watcher = RecommendedWatcher::new(
6473        move |result: notify::Result<Event>| {
6474            match sender.try_send(result) {
6475                Ok(()) => {}
6476                Err(TrySendError::Full(_result)) => {
6477                    callback_continuity_lost.store(true, Ordering::Release);
6478                }
6479                Err(TrySendError::Disconnected(_result)) => {
6480                    // Receiver shutdown means the command is exiting.
6481                }
6482            }
6483        },
6484        Config::default(),
6485    )
6486    .map_err(|source| CliError::Watcher(source.to_string()))?;
6487    watcher
6488        .watch(&watch_root, RecursiveMode::Recursive)
6489        .map_err(|source| CliError::Watcher(source.to_string()))?;
6490    let debounce = Duration::from_secs(poll_seconds.max(1));
6491    let mut cycles = 0;
6492    let mut last_refresh = refresh_index(store, &current_plan, symbol_options)?;
6493    cycles += 1;
6494    while max_cycles == 0 || cycles < max_cycles {
6495        let changes = wait_for_index_event_with_continuity(
6496            &receiver,
6497            &watch_root,
6498            debounce,
6499            &current_plan.scan_options,
6500            &continuity_lost,
6501        )?;
6502        if changes.has_changes() {
6503            current_plan = plan.reload()?;
6504            last_refresh =
6505                refresh_index_for_changes(store, &current_plan, &changes, symbol_options)?;
6506            cycles += 1;
6507        }
6508    }
6509    Ok(WatchReport {
6510        mode: WATCH_MODE_NOTIFY.to_string(),
6511        cycles,
6512        once: false,
6513        fallback_reason: None,
6514        text_index: last_refresh.text_index,
6515        structural_summaries: last_refresh.structural_summaries,
6516        last_symbols: last_refresh.symbols,
6517    })
6518}
6519
6520/// Wait for one bounded event batch and preserve local queue-overflow uncertainty.
6521fn wait_for_index_event_with_continuity(
6522    receiver: &mpsc::Receiver<notify::Result<Event>>,
6523    root: &Path,
6524    debounce: Duration,
6525    scan_options: &ScanOptions,
6526    continuity_lost: &AtomicBool,
6527) -> Result<WatchChangeSet, CliError> {
6528    let mut changes = notify_result_changes(
6529        root,
6530        scan_options,
6531        receiver.recv().map_err(|source| {
6532            CliError::Watcher(format!("watch event channel disconnected: {source}"))
6533        })?,
6534    )?;
6535    loop {
6536        match receiver.recv_timeout(debounce) {
6537            Ok(result) => {
6538                changes.merge(notify_result_changes(root, scan_options, result)?);
6539            }
6540            Err(RecvTimeoutError::Timeout) => break,
6541            Err(RecvTimeoutError::Disconnected) => {
6542                return Err(CliError::Watcher(
6543                    "watch event channel disconnected".to_string(),
6544                ));
6545            }
6546        }
6547    }
6548    if continuity_lost.swap(false, Ordering::AcqRel) {
6549        changes.requires_full_scan = true;
6550    }
6551    Ok(changes)
6552}
6553
6554/// Convert a `notify` result into index-relevant changes.
6555pub(crate) fn notify_result_changes(
6556    root: &Path,
6557    scan_options: &ScanOptions,
6558    result: notify::Result<Event>,
6559) -> Result<WatchChangeSet, CliError> {
6560    let event = result.map_err(|source| CliError::Watcher(source.to_string()))?;
6561    Ok(notify_event_changes(root, scan_options, &event))
6562}
6563
6564/// Convert a `notify` event into index-relevant changes.
6565pub(crate) fn notify_event_changes(
6566    root: &Path,
6567    scan_options: &ScanOptions,
6568    event: &Event,
6569) -> WatchChangeSet {
6570    if !event_kind_affects_index(event.kind) {
6571        return WatchChangeSet::default();
6572    }
6573    let mut changes = WatchChangeSet {
6574        requires_full_scan: event.need_rescan(),
6575        paths: HashSet::new(),
6576    };
6577    for path in &event.paths {
6578        let candidate = absolute_watch_path(root, path);
6579        if watch_path_requires_full_scan(root, &candidate) {
6580            changes.requires_full_scan = true;
6581            changes.paths.insert(candidate);
6582            continue;
6583        }
6584        let Some(index_path) = normalized_watch_index_path(root, path, scan_options) else {
6585            continue;
6586        };
6587        if matches!(
6588            event.kind,
6589            EventKind::Modify(notify::event::ModifyKind::Name(_))
6590        ) || watch_path_requires_full_scan(root, &index_path)
6591        {
6592            changes.requires_full_scan = true;
6593        }
6594        changes.paths.insert(index_path);
6595    }
6596    changes
6597}
6598
6599/// Return whether a `notify` event kind can change indexed content.
6600pub(crate) fn event_kind_affects_index(kind: EventKind) -> bool {
6601    !matches!(kind, EventKind::Access(_))
6602}
6603
6604/// Return whether a native event path belongs to indexed repository content.
6605#[cfg(test)]
6606pub(crate) fn watch_path_affects_index(
6607    root: &Path,
6608    path: &Path,
6609    scan_options: &ScanOptions,
6610) -> bool {
6611    normalized_watch_index_path(root, path, scan_options).is_some()
6612}
6613
6614/// Return one repository-contained native path after watcher normalization and policy checks.
6615fn normalized_watch_index_path(
6616    root: &Path,
6617    path: &Path,
6618    scan_options: &ScanOptions,
6619) -> Option<PathBuf> {
6620    let candidate = absolute_watch_path(root, path);
6621    let relative = safe_watch_relative_path(root, &candidate)?;
6622    if relative == "." {
6623        return Some(root.to_path_buf());
6624    }
6625    let policy_path = if candidate.strip_prefix(root).is_ok() {
6626        candidate.clone()
6627    } else {
6628        match candidate.try_exists() {
6629            Ok(true) => candidate.clone(),
6630            Ok(false) => root.join(repo_path_to_native(&relative)),
6631            Err(_) => return None,
6632        }
6633    };
6634    // Unknown ignore state should not admit a path into the incremental index.
6635    let Ok(gitignore_ignored) = gitignore_excludes_path(root, &policy_path) else {
6636        return None;
6637    };
6638    if gitignore_ignored {
6639        return None;
6640    }
6641    if relative.split('/').any(|component| component == ".purpose")
6642        || scan_options.excludes_relative_path(&relative)
6643    {
6644        return None;
6645    }
6646    Some(candidate)
6647}
6648
6649/// Return a safe normalized repository path for a watcher event.
6650fn safe_watch_relative_path(root: &Path, candidate: &Path) -> Option<String> {
6651    let relative = normalize_repo_path(root, candidate)
6652        .ok()
6653        .or_else(|| native_display_relative_path(root, candidate))?;
6654    valid_watch_relative_path(relative)
6655}
6656
6657/// Reconcile equivalent native paths when Windows extended prefixes differ.
6658fn native_display_relative_path(root: &Path, candidate: &Path) -> Option<String> {
6659    let root = normalize_native_path_display_str(root.to_str()?);
6660    let candidate = normalize_native_path_display_str(candidate.to_str()?);
6661    let root = if root == "/" {
6662        root.as_str()
6663    } else {
6664        root.trim_end_matches('/')
6665    };
6666    if candidate == root || cfg!(windows) && candidate.eq_ignore_ascii_case(root) {
6667        return Some(".".to_string());
6668    }
6669    let prefix = if root == "/" {
6670        "/".to_string()
6671    } else {
6672        format!("{root}/")
6673    };
6674    if let Some(relative) = candidate.strip_prefix(&prefix) {
6675        return Some(relative.to_string());
6676    }
6677    #[cfg(windows)]
6678    {
6679        let prefix_candidate = candidate.get(..prefix.len())?;
6680        if prefix_candidate.eq_ignore_ascii_case(&prefix) {
6681            return candidate.get(prefix.len()..).map(ToOwned::to_owned);
6682        }
6683    }
6684    None
6685}
6686
6687/// Reject empty, current-directory, and parent traversal path components.
6688fn valid_watch_relative_path(relative: String) -> Option<String> {
6689    if relative == "." {
6690        return Some(relative);
6691    }
6692    if relative
6693        .split('/')
6694        .any(|component| component.is_empty() || component == "." || component == "..")
6695    {
6696        return None;
6697    }
6698    Some(relative)
6699}
6700
6701/// Return an absolute path for a watcher event path.
6702pub(crate) fn absolute_watch_path(root: &Path, path: &Path) -> PathBuf {
6703    if path.is_absolute() {
6704        path.to_path_buf()
6705    } else {
6706        root.join(path)
6707    }
6708}
6709
6710/// Return whether a path event requires a full scan for correctness.
6711pub(crate) fn watch_path_requires_full_scan(root: &Path, path: &Path) -> bool {
6712    let Some(relative) = safe_watch_relative_path(root, path) else {
6713        return false;
6714    };
6715    if relative == "." {
6716        return true;
6717    }
6718    path.is_dir()
6719        || matches!(relative.rsplit('/').next(), Some(".gitignore" | ".ignore"))
6720        || relative == ".git"
6721        || relative.ends_with("/.git")
6722        || relative == ".git/info/exclude"
6723        || relative.ends_with("/.git/info/exclude")
6724        || matches!(
6725            relative.rsplit('/').next(),
6726            Some("tsconfig.json" | "jsconfig.json")
6727        )
6728        || index_policy_path(relative.as_str())
6729}
6730
6731/// Return whether one repository-relative path owns derived-index policy.
6732fn index_policy_path(relative: &str) -> bool {
6733    CORE_INDEX_POLICY_PATHS.contains(&relative)
6734        || cfg!(feature = "optional-parser-supervisor") && {
6735            #[cfg(feature = "optional-parser-supervisor")]
6736            {
6737                relative == OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH
6738            }
6739            #[cfg(not(feature = "optional-parser-supervisor"))]
6740            {
6741                false
6742            }
6743        }
6744}
6745
6746/// Run the portable polling watcher fallback loop.
6747pub(crate) fn run_polling_watch_loop(
6748    store: &mut AtlasStore,
6749    plan: &ScanRuntimePlan,
6750    poll_seconds: u64,
6751    max_cycles: usize,
6752    symbol_options: &SymbolBuildOptions,
6753    fallback_reason: Option<String>,
6754) -> Result<WatchReport, CliError> {
6755    let mut cycles = 0;
6756    let mut current_plan = plan.reload()?;
6757    let mut last_refresh = refresh_index(store, &current_plan, symbol_options)?;
6758    cycles += 1;
6759    while max_cycles == 0 || cycles < max_cycles {
6760        thread::sleep(Duration::from_secs(poll_seconds.max(1)));
6761        current_plan = plan.reload()?;
6762        last_refresh = refresh_index(store, &current_plan, symbol_options)?;
6763        cycles += 1;
6764    }
6765    Ok(WatchReport {
6766        mode: WATCH_MODE_POLLING.to_string(),
6767        cycles,
6768        once: false,
6769        fallback_reason,
6770        text_index: last_refresh.text_index,
6771        structural_summaries: last_refresh.structural_summaries,
6772        last_symbols: last_refresh.symbols,
6773    })
6774}
6775
6776/// Combined refresh output for watcher and one-shot refresh paths.
6777pub(crate) struct IndexRefreshReport {
6778    /// Persisted text search index refresh report.
6779    pub(crate) text_index: TextIndexReport,
6780    /// Structural summary refresh report.
6781    pub(crate) structural_summaries: StructuralSummaryReport,
6782    /// Deep symbol graph refresh report.
6783    symbols: SymbolBuildReport,
6784}
6785
6786/// Refresh filesystem and symbol state.
6787pub(crate) fn refresh_index(
6788    store: &mut AtlasStore,
6789    plan: &ScanRuntimePlan,
6790    symbol_options: &SymbolBuildOptions,
6791) -> Result<IndexRefreshReport, CliError> {
6792    let control = index_work_control(symbol_options);
6793    refresh_index_controlled(store, plan, symbol_options, &control)
6794}
6795
6796/// Refresh every derived projection under one cancellation boundary.
6797pub(crate) fn refresh_index_controlled(
6798    store: &mut AtlasStore,
6799    plan: &ScanRuntimePlan,
6800    symbol_options: &SymbolBuildOptions,
6801    control: &IndexWorkControl,
6802) -> Result<IndexRefreshReport, CliError> {
6803    let bounded_control = bounded_index_work_control(control);
6804    let control = &bounded_control;
6805    let reuse_unchanged_symbols = publication_contract_matches(store, plan)?;
6806    if reuse_unchanged_symbols
6807        && detect_index_freshness_controlled(store, plan, ScanLimits::default(), control)?
6808            .delta
6809            .is_none()
6810    {
6811        graph_projection::cleanup_abandoned_repository_graph_staging(store, &plan.root, control)?;
6812        return Ok(empty_index_refresh_report(plan.text_options));
6813    }
6814    let batch = stage_full_index_publication(
6815        store,
6816        plan,
6817        symbol_options,
6818        reuse_unchanged_symbols,
6819        false,
6820        control,
6821    )?;
6822    revalidate_staged_publication_inputs_controlled(
6823        plan,
6824        batch.nodes.expected_nodes(),
6825        None,
6826        control,
6827    )?;
6828    if staged_full_refresh_is_unchanged(store, &batch)? {
6829        return Ok(empty_index_refresh_report(plan.text_options));
6830    }
6831    let outcome = publish_index_batch(store, batch, control)?;
6832    Ok(IndexRefreshReport {
6833        text_index: outcome.text_index,
6834        structural_summaries: outcome.structural_summaries,
6835        symbols: outcome.symbols,
6836    })
6837}
6838
6839/// Return whether one fully staged watcher refresh matches complete durable state.
6840fn staged_full_refresh_is_unchanged(
6841    store: &AtlasStore,
6842    batch: &IndexPublicationBatch,
6843) -> Result<bool, CliError> {
6844    let NodePublicationBatch::Full { nodes: expected } = &batch.nodes else {
6845        return Ok(false);
6846    };
6847    if batch.purpose_import.is_some() {
6848        return Ok(false);
6849    }
6850    let Some(publication) = store.index_publication()? else {
6851        return Ok(false);
6852    };
6853    if publication.state != IndexPublicationState::Complete
6854        || publication.generation != batch.base_generation
6855        || publication.contract_fingerprint.as_deref() != Some(batch.contract_fingerprint.as_str())
6856    {
6857        return Ok(false);
6858    }
6859    let current = store
6860        .load_nodes()?
6861        .into_iter()
6862        .map(|indexed| indexed.node)
6863        .collect::<Vec<_>>();
6864    Ok(current.len() == expected.len()
6865        && current
6866            .iter()
6867            .zip(expected)
6868            .all(|(current, expected)| same_indexed_source(current, expected)))
6869}
6870
6871/// Build the stable report for a verified watcher no-op.
6872fn empty_index_refresh_report(text_options: TextIndexOptions) -> IndexRefreshReport {
6873    IndexRefreshReport {
6874        text_index: empty_text_index_report(text_options),
6875        structural_summaries: StructuralSummaryReport::default(),
6876        symbols: empty_symbol_build_report(),
6877    }
6878}
6879
6880/// Refresh filesystem and symbol state for a debounced event batch.
6881pub(crate) fn refresh_index_for_changes(
6882    store: &mut AtlasStore,
6883    plan: &ScanRuntimePlan,
6884    changes: &WatchChangeSet,
6885    symbol_options: &SymbolBuildOptions,
6886) -> Result<IndexRefreshReport, CliError> {
6887    let control = index_work_control(symbol_options);
6888    refresh_index_for_changes_controlled(store, plan, changes, symbol_options, &control)
6889}
6890
6891/// Refresh one watcher batch under one cancellation and publication boundary.
6892pub(crate) fn refresh_index_for_changes_controlled(
6893    store: &mut AtlasStore,
6894    plan: &ScanRuntimePlan,
6895    changes: &WatchChangeSet,
6896    symbol_options: &SymbolBuildOptions,
6897    control: &IndexWorkControl,
6898) -> Result<IndexRefreshReport, CliError> {
6899    let bounded_control = bounded_index_work_control(control);
6900    let control = &bounded_control;
6901    control.check(IndexWorkStage::RepositoryTraversal)?;
6902    if changes.requires_full_scan || !publication_contract_matches(store, plan)? {
6903        return refresh_index_controlled(store, plan, symbol_options, control);
6904    }
6905    if changes.paths.len() > MAX_INCREMENTAL_CHANGED_PATHS {
6906        return Err(IndexWorkFailure::resource_limit(
6907            IndexWorkStage::RepositoryTraversal,
6908            IndexWorkResource::Entries,
6909            MAX_INCREMENTAL_CHANGED_PATHS as u64,
6910            changes.paths.len() as u64,
6911        )
6912        .into());
6913    }
6914    let root = &plan.root;
6915    let base_generation = publication_base_generation(store)?;
6916    let baseline_nodes = store
6917        .load_nodes()?
6918        .into_iter()
6919        .map(|indexed| indexed.node)
6920        .collect::<Vec<_>>();
6921    let baseline_by_path = baseline_nodes
6922        .iter()
6923        .map(|node| (node.path.clone(), node))
6924        .collect::<HashMap<_, _>>();
6925    let mut nodes = Vec::new();
6926    let mut absent_paths = Vec::new();
6927    let mut source_bytes = 0_u64;
6928    let scan_policy = RootScanPolicy::discover(root, &plan.scan_options, control)
6929        .map_err(|source| source_inspection_error(root, source))?;
6930    for path in sorted_watch_paths(&changes.paths) {
6931        control.check(IndexWorkStage::RepositoryTraversal)?;
6932        match path.try_exists() {
6933            Ok(true) => {
6934                let remaining_source_bytes =
6935                    MAX_INCREMENTAL_SOURCE_BYTES.saturating_sub(source_bytes);
6936                if let Some(node) = scan_path_with_policy_controlled(
6937                    &scan_policy,
6938                    &path,
6939                    ScanLimits::new(1, remaining_source_bytes, 1),
6940                    control,
6941                )
6942                .map_err(|source| source_inspection_error(root, source))?
6943                {
6944                    source_bytes = source_bytes
6945                        .checked_add(node.size_bytes.unwrap_or_default())
6946                        .ok_or_else(|| {
6947                            IndexWorkFailure::resource_limit(
6948                                IndexWorkStage::SourceMetadata,
6949                                IndexWorkResource::SourceBytes,
6950                                MAX_INCREMENTAL_SOURCE_BYTES,
6951                                u64::MAX,
6952                            )
6953                        })?;
6954                    if source_bytes > MAX_INCREMENTAL_SOURCE_BYTES {
6955                        return Err(IndexWorkFailure::resource_limit(
6956                            IndexWorkStage::SourceMetadata,
6957                            IndexWorkResource::SourceBytes,
6958                            MAX_INCREMENTAL_SOURCE_BYTES,
6959                            source_bytes,
6960                        )
6961                        .into());
6962                    }
6963                    nodes.push(node);
6964                } else if let Some(path_key) = normalized_deleted_path(root, &path)? {
6965                    absent_paths.push(path_key);
6966                }
6967            }
6968            Ok(false) => {
6969                if let Some(path_key) = normalized_deleted_path(root, &path)? {
6970                    absent_paths.push(path_key);
6971                }
6972            }
6973            Err(source) => {
6974                return Err(CliError::VerificationIncomplete(Box::new(
6975                    IndexVerificationIncomplete {
6976                        project_root: normalize_native_path_display(root),
6977                        status: IndexReadStatus::VerificationIncomplete,
6978                        reason: IndexVerificationReason::SourceInspectionFailed,
6979                        scope: IndexRefreshScope::Full,
6980                        message: format!("failed to inspect '{}': {source}", path.display()),
6981                    },
6982                )));
6983            }
6984        }
6985    }
6986    absent_paths.sort();
6987    absent_paths.dedup();
6988    let candidate_paths = nodes
6989        .iter()
6990        .map(|node| node.path.clone())
6991        .chain(absent_paths.iter().cloned())
6992        .collect::<HashSet<_>>();
6993    let mut sorted_candidate_paths = candidate_paths.into_iter().collect::<Vec<_>>();
6994    sorted_candidate_paths.sort();
6995    let existing_nodes = sorted_candidate_paths
6996        .iter()
6997        .filter_map(|path| {
6998            baseline_by_path
6999                .get(path)
7000                .map(|node| (path.clone(), (*node).clone()))
7001        })
7002        .collect::<HashMap<_, _>>();
7003    if absent_paths.iter().any(|path| {
7004        existing_nodes
7005            .get(path)
7006            .is_some_and(|node| node.kind == NodeKind::Folder)
7007    }) {
7008        return refresh_index_controlled(store, plan, symbol_options, control);
7009    }
7010    graph_projection::cleanup_abandoned_repository_graph_staging(store, root, control)?;
7011    let direct_graph_paths = nodes
7012        .iter()
7013        .filter(|node| node.kind == NodeKind::File)
7014        .map(|node| node.path.clone())
7015        .chain(absent_paths.iter().cloned())
7016        .collect::<BTreeSet<_>>();
7017    nodes.retain(|node| {
7018        existing_nodes
7019            .get(&node.path)
7020            .is_none_or(|indexed| !same_indexed_source(node, indexed))
7021    });
7022    absent_paths.retain(|path| existing_nodes.contains_key(path));
7023    if nodes.is_empty() && absent_paths.is_empty() {
7024        revalidate_staged_publication_inputs_controlled(plan, &baseline_nodes, None, control)?;
7025        return Ok(empty_index_refresh_report(plan.text_options));
7026    }
7027    drop(existing_nodes);
7028    drop(baseline_by_path);
7029    let changed_paths = nodes
7030        .iter()
7031        .map(|node| node.path.clone())
7032        .chain(absent_paths.iter().cloned())
7033        .collect::<HashSet<_>>();
7034    let previous_hashes = indexed_file_hashes_for_paths(store, &changed_paths)?;
7035    let mut text_paths = changed_paths.iter().cloned().collect::<Vec<_>>();
7036    text_paths.sort();
7037    let text =
7038        stage_text_index_for_changed_paths_controlled(root, &nodes, plan.text_options, control)?;
7039    let protected_purpose_paths = protected_purpose_paths(&nodes, None);
7040    let target_paths = nodes
7041        .iter()
7042        .filter(|node| node.kind == NodeKind::File)
7043        .map(|node| node.path.clone())
7044        .collect::<HashSet<_>>();
7045    let expected_nodes = expected_nodes_after_incremental(baseline_nodes, &nodes, &absent_paths);
7046    let contract_fingerprint = plan.publication_contract_fingerprint();
7047    let retained_before_symbols = staged_publication_identity_bytes(root, &contract_fingerprint)
7048        .saturating_add(staged_string_bytes(&text_paths))
7049        .saturating_add(staged_string_bytes(&absent_paths))
7050        .saturating_add(staged_node_bytes(&expected_nodes))
7051        .saturating_add(staged_node_bytes(&nodes))
7052        .saturating_add(staged_text_bytes(&text));
7053    let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
7054    let symbols = stage_symbols_for_nodes_with_limits(
7055        store,
7056        root,
7057        #[cfg(feature = "optional-parser-supervisor")]
7058        &plan.optional_parser_selection,
7059        &nodes,
7060        symbol_options,
7061        Some(&previous_hashes),
7062        Some(&target_paths),
7063        &protected_purpose_paths,
7064        control,
7065        symbol_limits,
7066    )?;
7067    let graph = graph_projection::stage_incremental_repository_graph(
7068        store,
7069        root,
7070        base_generation,
7071        &expected_nodes,
7072        &direct_graph_paths.into_iter().collect::<Vec<_>>(),
7073        &symbols,
7074        control,
7075    )?;
7076    let structural_summaries = stage_structural_summaries_for_nodes_controlled(
7077        store,
7078        &nodes,
7079        &text.rows,
7080        Some(&symbols),
7081        &protected_purpose_paths,
7082        symbol_options.effective_workers(),
7083        control,
7084    )?;
7085    enforce_publication_staging_budget(
7086        retained_before_symbols
7087            .saturating_add(symbols.retained_bytes)
7088            .saturating_add(graph.retained_bytes())
7089            .saturating_add(structural_summaries.retained_bytes),
7090    )?;
7091    let batch = IndexPublicationBatch {
7092        base_generation,
7093        contract_fingerprint,
7094        root: root.clone(),
7095        nodes: NodePublicationBatch::Incremental {
7096            nodes,
7097            absent_paths,
7098            expected_nodes,
7099        },
7100        purpose_import: None,
7101        text_paths,
7102        text,
7103        symbols,
7104        graph,
7105        structural_summaries,
7106    };
7107    revalidate_staged_publication_inputs_controlled(
7108        plan,
7109        batch.nodes.expected_nodes(),
7110        None,
7111        control,
7112    )?;
7113    let outcome = publish_index_batch(store, batch, control)?;
7114    Ok(IndexRefreshReport {
7115        text_index: outcome.text_index,
7116        structural_summaries: outcome.structural_summaries,
7117        symbols: outcome.symbols,
7118    })
7119}
7120
7121/// Seed built-in purposes for reserved `ProjectAtlas` metadata nodes when needed.
7122pub(crate) fn seed_builtin_projectatlas_purposes(
7123    store: &AtlasStore,
7124    nodes: &[Node],
7125) -> Result<(), CliError> {
7126    let indexed_paths = nodes
7127        .iter()
7128        .map(|node| node.path.as_str())
7129        .collect::<HashSet<_>>();
7130    for (path, purpose) in BUILTIN_PROJECTATLAS_PURPOSES {
7131        if !indexed_paths.contains(path) {
7132            continue;
7133        }
7134        let Some(indexed) = store.load_node_by_path(path)? else {
7135            continue;
7136        };
7137        if !matches!(
7138            indexed.purpose.status,
7139            PurposeStatus::Approved | PurposeStatus::Stale
7140        ) {
7141            store.set_purpose(path, purpose, PurposeSource::Imported)?;
7142        }
7143    }
7144    Ok(())
7145}
7146
7147/// Refresh structural summaries while observing the operation work boundary.
7148#[cfg(test)]
7149pub(crate) fn refresh_structural_summaries_for_nodes(
7150    store: &mut AtlasStore,
7151    nodes: &[Node],
7152    text_rows: &[TextIndexRow],
7153) -> Result<StructuralSummaryReport, CliError> {
7154    let control = standalone_index_work_control();
7155    refresh_structural_summaries_for_nodes_controlled(store, nodes, text_rows, &control)
7156}
7157
7158/// Refresh structural summaries while observing the operation work boundary.
7159#[cfg(test)]
7160fn refresh_structural_summaries_for_nodes_controlled(
7161    store: &mut AtlasStore,
7162    nodes: &[Node],
7163    text_rows: &[TextIndexRow],
7164    control: &IndexWorkControl,
7165) -> Result<StructuralSummaryReport, CliError> {
7166    let staged = stage_structural_summaries_for_nodes_controlled(
7167        store,
7168        nodes,
7169        text_rows,
7170        None,
7171        &HashSet::new(),
7172        2,
7173        control,
7174    )?;
7175    apply_structural_summary_stage(store, &staged, control)?;
7176    Ok(staged.report)
7177}
7178
7179/// Derive structural summary mutations without acquiring the `SQLite` writer.
7180fn stage_structural_summaries_for_nodes_controlled(
7181    store: &AtlasStore,
7182    nodes: &[Node],
7183    text_rows: &[TextIndexRow],
7184    symbols: Option<&SymbolBuildStage>,
7185    protected_purpose_paths: &HashSet<String>,
7186    max_workers: usize,
7187    control: &IndexWorkControl,
7188) -> Result<StructuralSummaryStage, CliError> {
7189    control.check(IndexWorkStage::TextIndex)?;
7190    let candidates = nodes
7191        .iter()
7192        .filter(|node| node.kind == NodeKind::File)
7193        .filter(|node| is_structural_summary_candidate(&node.path, node.language.as_deref()))
7194        .collect::<Vec<_>>();
7195    if candidates.is_empty() {
7196        return Ok(StructuralSummaryStage {
7197            report: StructuralSummaryReport::default(),
7198            changes: Vec::new(),
7199            retained_bytes: 0,
7200        });
7201    }
7202    let paths = candidates
7203        .iter()
7204        .map(|node| node.path.clone())
7205        .collect::<Vec<_>>();
7206    let indexed_nodes = store
7207        .load_nodes_by_paths(&paths)?
7208        .into_iter()
7209        .map(|indexed| (indexed.node.path.clone(), indexed))
7210        .collect::<HashMap<_, _>>();
7211    let symbol_counts = store.symbol_counts_for_paths(&paths)?;
7212    let text_by_path = text_rows
7213        .iter()
7214        .filter_map(|row| row.text.as_ref().map(|text| (text.path.as_str(), text)))
7215        .collect::<HashMap<_, _>>();
7216    let reason_by_path = text_rows
7217        .iter()
7218        .map(|row| (row.path.as_str(), row.reason))
7219        .collect::<HashMap<_, _>>();
7220    let mut staged_symbol_counts = HashMap::new();
7221    let mut staged_symbol_summaries = HashMap::new();
7222    let mut staged_structural_summaries = HashMap::new();
7223    if let Some(symbols) = symbols {
7224        for change in &symbols.changes {
7225            match change {
7226                SymbolProjectionChange::Parsed(parsed) => {
7227                    staged_symbol_counts.insert(parsed.path.as_str(), parsed.graph.symbols.len());
7228                    staged_symbol_summaries.insert(parsed.path.as_str(), parsed.summary.as_str());
7229                    if parsed.summary_is_structural {
7230                        staged_structural_summaries
7231                            .insert(parsed.path.as_str(), parsed.purpose_suggestion.is_some());
7232                    }
7233                }
7234                SymbolProjectionChange::Clear { path, .. } => {
7235                    staged_symbol_counts.insert(path.as_str(), 0);
7236                }
7237            }
7238        }
7239    }
7240    let mut report = StructuralSummaryReport {
7241        candidates: paths.len(),
7242        ..StructuralSummaryReport::default()
7243    };
7244    let worker_count = worker_count_for_work(candidates.len(), max_workers);
7245    let pool = ThreadPoolBuilder::new()
7246        .num_threads(worker_count)
7247        .build()
7248        .map_err(|source| {
7249            CliError::InvalidInput(format!("structural summary worker pool failed: {source}"))
7250        })?;
7251    let derivations = pool.install(|| {
7252        candidates
7253            .par_iter()
7254            .map(|node| -> Result<StructuralSummaryDerivation, CliError> {
7255                control.check(IndexWorkStage::TextIndex)?;
7256                let existing = indexed_nodes.get(&node.path);
7257                if reason_by_path.get(node.path.as_str()) == Some(&TextIndexSkipReason::TooLarge)
7258                    || node
7259                        .size_bytes
7260                        .is_some_and(|size_bytes| size_bytes > MAX_SYMBOL_FILE_BYTES)
7261                {
7262                    return Ok(StructuralSummaryDerivation {
7263                        change: Some(StructuralSummaryChange::Clear {
7264                            path: node.path.clone(),
7265                        }),
7266                        cleared: 1,
7267                        too_large: 1,
7268                        retained_bytes: node.path.len() as u64,
7269                        ..StructuralSummaryDerivation::default()
7270                    });
7271                }
7272                let Some(text) = text_by_path.get(node.path.as_str()) else {
7273                    return Ok(StructuralSummaryDerivation {
7274                        change: Some(StructuralSummaryChange::Clear {
7275                            path: node.path.clone(),
7276                        }),
7277                        cleared: 1,
7278                        binary_or_non_utf8: usize::from(
7279                            reason_by_path.get(node.path.as_str())
7280                                == Some(&TextIndexSkipReason::BinaryOrNonUtf8),
7281                        ),
7282                        retained_bytes: node.path.len() as u64,
7283                        ..StructuralSummaryDerivation::default()
7284                    });
7285                };
7286                if let Some(purpose_suggested) = staged_structural_summaries.get(node.path.as_str())
7287                {
7288                    return Ok(StructuralSummaryDerivation {
7289                        summarized: 1,
7290                        purpose_suggestions: usize::from(*purpose_suggested),
7291                        ..StructuralSummaryDerivation::default()
7292                    });
7293                }
7294                let symbol_count = staged_symbol_counts
7295                    .get(node.path.as_str())
7296                    .copied()
7297                    .or_else(|| symbol_counts.get(node.path.as_str()).copied())
7298                    .unwrap_or_default();
7299                let effective_summary = staged_symbol_summaries
7300                    .get(node.path.as_str())
7301                    .copied()
7302                    .or_else(|| existing.and_then(|indexed| indexed.summary.as_deref()));
7303                if symbol_count > 0
7304                    && effective_summary.is_some_and(|summary| {
7305                        !summary.trim().is_empty() && !is_scanner_fallback_summary(summary)
7306                    })
7307                {
7308                    return Ok(StructuralSummaryDerivation::default());
7309                }
7310                let Some(summary) = structural_summary_for_path(
7311                    &node.path,
7312                    node.language.as_deref(),
7313                    &text.content,
7314                ) else {
7315                    return Ok(StructuralSummaryDerivation {
7316                        change: Some(StructuralSummaryChange::Clear {
7317                            path: node.path.clone(),
7318                        }),
7319                        cleared: 1,
7320                        retained_bytes: node.path.len() as u64,
7321                        ..StructuralSummaryDerivation::default()
7322                    });
7323                };
7324                let purpose_needs_suggestion = !protected_purpose_paths.contains(&node.path)
7325                    && existing.is_none_or(|indexed| {
7326                        matches!(
7327                            indexed.purpose.status,
7328                            PurposeStatus::Missing | PurposeStatus::Suggested
7329                        )
7330                    });
7331                let purpose_suggestion =
7332                    purpose_needs_suggestion.then(|| suggest_file_purpose(&node.path, &summary));
7333                let purpose_suggestions = usize::from(purpose_suggestion.is_some());
7334                let retained_bytes = (node.path.len() as u64)
7335                    .saturating_add(summary.len() as u64)
7336                    .saturating_add(
7337                        purpose_suggestion
7338                            .as_ref()
7339                            .map_or(0, |suggestion| suggestion.len() as u64),
7340                    );
7341                control.check(IndexWorkStage::TextIndex)?;
7342                Ok(StructuralSummaryDerivation {
7343                    change: Some(StructuralSummaryChange::Set {
7344                        path: node.path.clone(),
7345                        summary,
7346                        purpose_suggestion,
7347                    }),
7348                    summarized: 1,
7349                    purpose_suggestions,
7350                    retained_bytes,
7351                    ..StructuralSummaryDerivation::default()
7352                })
7353            })
7354            .collect::<Result<Vec<_>, CliError>>()
7355    })?;
7356    let mut changes = Vec::new();
7357    let mut retained_bytes = 0_u64;
7358    for derivation in derivations {
7359        report.summarized += derivation.summarized;
7360        report.cleared += derivation.cleared;
7361        report.too_large += derivation.too_large;
7362        report.binary_or_non_utf8 += derivation.binary_or_non_utf8;
7363        report.purpose_suggestions += derivation.purpose_suggestions;
7364        retained_bytes = retained_bytes.saturating_add(derivation.retained_bytes);
7365        if let Some(change) = derivation.change {
7366            changes.push(change);
7367        }
7368    }
7369    Ok(StructuralSummaryStage {
7370        report,
7371        changes,
7372        retained_bytes,
7373    })
7374}
7375
7376/// Apply prepared structural summaries inside the parent publication transaction.
7377fn apply_structural_summary_stage(
7378    store: &mut AtlasStore,
7379    staged: &StructuralSummaryStage,
7380    control: &IndexWorkControl,
7381) -> Result<(), CliError> {
7382    for change in &staged.changes {
7383        control.check(IndexWorkStage::Publication)?;
7384        match change {
7385            StructuralSummaryChange::Set {
7386                path,
7387                summary,
7388                purpose_suggestion,
7389            } => {
7390                store.set_node_summary(path, summary)?;
7391                if let Some(suggestion) = purpose_suggestion.as_deref() {
7392                    store.set_suggested_purpose(path, suggestion)?;
7393                }
7394            }
7395            StructuralSummaryChange::Clear { path } => store.clear_node_summary(path)?,
7396        }
7397    }
7398    control.check(IndexWorkStage::Publication)?;
7399    Ok(())
7400}
7401
7402/// Refresh the persisted text index for every scanned file node.
7403#[cfg(test)]
7404pub(crate) fn refresh_text_index_for_nodes(
7405    store: &mut AtlasStore,
7406    root: &Path,
7407    nodes: &[Node],
7408    options: TextIndexOptions,
7409) -> Result<TextIndexReport, CliError> {
7410    let control = standalone_index_work_control();
7411    Ok(
7412        refresh_text_index_for_nodes_with_rows_controlled(store, root, nodes, options, &control)?
7413            .report,
7414    )
7415}
7416
7417/// Refresh all text rows under one cancellation and staging-byte boundary.
7418#[cfg(test)]
7419pub(crate) fn refresh_text_index_for_nodes_with_rows(
7420    store: &mut AtlasStore,
7421    root: &Path,
7422    nodes: &[Node],
7423    options: TextIndexOptions,
7424) -> Result<TextIndexRefresh, CliError> {
7425    let control = standalone_index_work_control();
7426    refresh_text_index_for_nodes_with_rows_controlled(store, root, nodes, options, &control)
7427}
7428
7429/// Refresh all text rows under one cancellation and staging-byte boundary.
7430#[cfg(test)]
7431fn refresh_text_index_for_nodes_with_rows_controlled(
7432    store: &mut AtlasStore,
7433    root: &Path,
7434    nodes: &[Node],
7435    options: TextIndexOptions,
7436    control: &IndexWorkControl,
7437) -> Result<TextIndexRefresh, CliError> {
7438    let file_paths = nodes
7439        .iter()
7440        .filter(|node| node.kind == NodeKind::File)
7441        .map(|node| node.path.clone())
7442        .collect::<Vec<_>>();
7443    refresh_text_index_for_changed_paths_with_rows_controlled(
7444        store,
7445        root,
7446        &file_paths,
7447        nodes,
7448        options,
7449        control,
7450    )
7451}
7452
7453/// Refresh selected text rows under one cancellation and staging-byte boundary.
7454#[cfg(test)]
7455fn refresh_text_index_for_changed_paths_with_rows_controlled(
7456    store: &mut AtlasStore,
7457    root: &Path,
7458    considered_paths: &[String],
7459    nodes: &[Node],
7460    options: TextIndexOptions,
7461    control: &IndexWorkControl,
7462) -> Result<TextIndexRefresh, CliError> {
7463    let staged = stage_text_index_for_changed_paths_controlled(root, nodes, options, control)?;
7464    apply_text_index_stage(store, considered_paths, &staged, control)?;
7465    Ok(staged)
7466}
7467
7468/// Build selected persisted-text rows without acquiring the `SQLite` writer.
7469fn stage_text_index_for_changed_paths_controlled(
7470    root: &Path,
7471    nodes: &[Node],
7472    options: TextIndexOptions,
7473    control: &IndexWorkControl,
7474) -> Result<TextIndexRefresh, CliError> {
7475    control.check(IndexWorkStage::TextIndex)?;
7476    let text_rows = indexed_file_texts_for_nodes_controlled(root, nodes, options, control)?;
7477    let indexed = text_rows.iter().filter(|row| row.text.is_some()).count();
7478    let indexed_bytes = text_rows
7479        .iter()
7480        .filter_map(|row| row.text.as_ref())
7481        .map(|text| text.byte_count)
7482        .fold(0usize, usize::saturating_add);
7483    let file_candidates = nodes
7484        .iter()
7485        .filter(|node| node.kind == NodeKind::File)
7486        .count();
7487    let binary_or_non_utf8 = text_rows
7488        .iter()
7489        .filter(|row| row.reason == TextIndexSkipReason::BinaryOrNonUtf8)
7490        .count();
7491    let too_large = text_rows
7492        .iter()
7493        .filter(|row| row.reason == TextIndexSkipReason::TooLarge)
7494        .count();
7495    let report = TextIndexReport {
7496        candidates: file_candidates,
7497        indexed,
7498        binary_or_non_utf8,
7499        too_large,
7500        skipped: file_candidates.saturating_sub(indexed),
7501        max_bytes: options.max_bytes,
7502        bytes: indexed_bytes,
7503    };
7504    control.check(IndexWorkStage::TextIndex)?;
7505    Ok(TextIndexRefresh {
7506        report,
7507        rows: text_rows,
7508    })
7509}
7510
7511/// Apply prepared persisted-text rows inside the parent publication transaction.
7512fn apply_text_index_stage(
7513    store: &mut AtlasStore,
7514    considered_paths: &[String],
7515    staged: &TextIndexRefresh,
7516    control: &IndexWorkControl,
7517) -> Result<(), CliError> {
7518    let text_by_path = staged
7519        .rows
7520        .iter()
7521        .filter_map(|row| row.text.as_ref().map(|text| (text.path.as_str(), text)))
7522        .collect::<HashMap<_, _>>();
7523    for paths in considered_paths.chunks(PUBLICATION_TEXT_BATCH_SIZE) {
7524        control.check(IndexWorkStage::Publication)?;
7525        store.replace_file_texts_for_paths(
7526            paths,
7527            paths
7528                .iter()
7529                .filter_map(|path| text_by_path.get(path.as_str()).copied()),
7530        )?;
7531    }
7532    control.check(IndexWorkStage::Publication)?;
7533    Ok(())
7534}
7535
7536/// Build indexed text rows for UTF-8 scanned files with size caps.
7537#[cfg(test)]
7538pub(crate) fn indexed_file_texts_for_nodes(
7539    root: &Path,
7540    nodes: &[Node],
7541    options: TextIndexOptions,
7542) -> Result<Vec<TextIndexRow>, CliError> {
7543    let control = standalone_index_work_control();
7544    indexed_file_texts_for_nodes_controlled(root, nodes, options, &control)
7545}
7546
7547/// Build bounded UTF-8 text rows while observing cancellation between files.
7548fn indexed_file_texts_for_nodes_controlled(
7549    root: &Path,
7550    nodes: &[Node],
7551    options: TextIndexOptions,
7552    control: &IndexWorkControl,
7553) -> Result<Vec<TextIndexRow>, CliError> {
7554    indexed_file_texts_for_nodes_with_limit(root, nodes, options, MAX_STAGED_TEXT_BYTES, control)
7555}
7556
7557/// Build UTF-8 text rows under an explicit aggregate staging-byte limit.
7558fn indexed_file_texts_for_nodes_with_limit(
7559    root: &Path,
7560    nodes: &[Node],
7561    options: TextIndexOptions,
7562    max_staged_bytes: u64,
7563    control: &IndexWorkControl,
7564) -> Result<Vec<TextIndexRow>, CliError> {
7565    let mut rows = Vec::new();
7566    let mut staged_bytes = 0_u64;
7567    for node in nodes.iter().filter(|node| node.kind == NodeKind::File) {
7568        control.check(IndexWorkStage::TextIndex)?;
7569        if node
7570            .size_bytes
7571            .is_some_and(|size_bytes| size_bytes > options.max_bytes)
7572        {
7573            rows.push(TextIndexRow {
7574                path: node.path.clone(),
7575                text: None,
7576                reason: TextIndexSkipReason::TooLarge,
7577            });
7578            continue;
7579        }
7580        let remaining_staged_bytes = max_staged_bytes.saturating_sub(staged_bytes);
7581        if node
7582            .size_bytes
7583            .is_some_and(|size_bytes| size_bytes > remaining_staged_bytes)
7584        {
7585            return Err(IndexWorkFailure::resource_limit(
7586                IndexWorkStage::TextIndex,
7587                IndexWorkResource::TextBytes,
7588                max_staged_bytes,
7589                staged_bytes.saturating_add(node.size_bytes.unwrap_or_default()),
7590            )
7591            .into());
7592        }
7593        let native_path = root.join(repo_path_to_native(&node.path));
7594        let read_limit = options.max_bytes.min(remaining_staged_bytes);
7595        let aggregate_limit_is_narrower = remaining_staged_bytes <= options.max_bytes;
7596        let bytes = match read_source_bytes_controlled(
7597            &native_path,
7598            read_limit,
7599            IndexWorkStage::TextIndex,
7600            control,
7601        ) {
7602            Ok(bytes) => bytes,
7603            Err(SourceReadFailure::Io(source)) => {
7604                return Err(CliError::Io {
7605                    path: native_path,
7606                    source,
7607                });
7608            }
7609            Err(SourceReadFailure::IndexWork(failure)) => return Err(failure.into()),
7610            Err(SourceReadFailure::LimitExceeded { observed }) if aggregate_limit_is_narrower => {
7611                return Err(IndexWorkFailure::resource_limit(
7612                    IndexWorkStage::TextIndex,
7613                    IndexWorkResource::TextBytes,
7614                    max_staged_bytes,
7615                    staged_bytes.saturating_add(observed),
7616                )
7617                .into());
7618            }
7619            Err(SourceReadFailure::LimitExceeded { .. }) => {
7620                return Err(source_changed_during_derivation(root, &node.path));
7621            }
7622        };
7623        control.check(IndexWorkStage::TextIndex)?;
7624        let current_hash = blake3::hash(&bytes).to_hex().to_string();
7625        if node.content_hash.as_deref() != Some(current_hash.as_str()) {
7626            return Err(source_changed_during_derivation(root, &node.path));
7627        }
7628        let Ok(content) = String::from_utf8(bytes) else {
7629            rows.push(TextIndexRow {
7630                path: node.path.clone(),
7631                text: None,
7632                reason: TextIndexSkipReason::BinaryOrNonUtf8,
7633            });
7634            continue;
7635        };
7636        staged_bytes = staged_bytes.saturating_add(content.len() as u64);
7637        rows.push(TextIndexRow {
7638            path: node.path.clone(),
7639            reason: TextIndexSkipReason::Indexed,
7640            text: Some(IndexedFileText {
7641                path: node.path.clone(),
7642                content_hash: node.content_hash.clone(),
7643                byte_count: content.len(),
7644                line_count: content.lines().count(),
7645                content,
7646            }),
7647        });
7648    }
7649    Ok(rows)
7650}
7651
7652/// Load indexed file hashes for incremental refresh comparison.
7653pub(crate) fn indexed_file_hashes(store: &AtlasStore) -> Result<HashMap<String, String>, CliError> {
7654    Ok(store
7655        .load_nodes()?
7656        .into_iter()
7657        .filter(|node| node.node.kind == NodeKind::File)
7658        .filter_map(|node| node.node.content_hash.map(|hash| (node.node.path, hash)))
7659        .collect::<HashMap<_, _>>())
7660}
7661
7662/// Load indexed file hashes for selected repository paths.
7663pub(crate) fn indexed_file_hashes_for_paths(
7664    store: &AtlasStore,
7665    paths: &HashSet<String>,
7666) -> Result<HashMap<String, String>, CliError> {
7667    let mut sorted_paths = paths.iter().cloned().collect::<Vec<_>>();
7668    sorted_paths.sort();
7669    Ok(store
7670        .load_nodes_by_paths(&sorted_paths)?
7671        .into_iter()
7672        .filter(|node| node.node.kind == NodeKind::File)
7673        .filter_map(|node| node.node.content_hash.map(|hash| (node.node.path, hash)))
7674        .collect::<HashMap<_, _>>())
7675}
7676
7677/// Return event paths in deterministic order.
7678pub(crate) fn sorted_watch_paths(paths: &HashSet<PathBuf>) -> Vec<PathBuf> {
7679    let mut paths = paths.iter().cloned().collect::<Vec<_>>();
7680    paths.sort();
7681    paths
7682}
7683
7684/// Normalize a deleted path if it belongs to the watched repository.
7685pub(crate) fn normalized_deleted_path(
7686    root: &Path,
7687    path: &Path,
7688) -> Result<Option<String>, CliError> {
7689    match normalize_repo_path(root, path) {
7690        Ok(path) => Ok(valid_watch_relative_path(path)),
7691        Err(projectatlas_core::CoreError::PathOutsideRoot { .. }) => {
7692            Ok(native_display_relative_path(root, path).and_then(valid_watch_relative_path))
7693        }
7694        Err(source) => Err(CliError::InvalidInput(source.to_string())),
7695    }
7696}
7697
7698/// Inspect and optionally remove legacy `.purpose` files.
7699pub(crate) fn strip_legacy_purpose(
7700    root: &Path,
7701    config_path: Option<&Path>,
7702    apply: bool,
7703    dry_run: bool,
7704    strip_source_headers: bool,
7705) -> Result<LegacyPurposeReport, CliError> {
7706    let root = root.canonicalize().map_err(|source| CliError::Io {
7707        path: root.to_path_buf(),
7708        source,
7709    })?;
7710    let scan_options = scan_options_for_root(config_path, &root)?;
7711    let nodes = scan_repo(&root, &scan_options)?;
7712    let effective_dry_run = dry_run || !apply;
7713    let purpose_files = indexed_purpose_files(&root, &nodes);
7714    let mut removed = 0;
7715    if !effective_dry_run {
7716        for path in &purpose_files {
7717            let native = root.join(repo_path_to_native(path));
7718            fs::remove_file(&native).map_err(|source| CliError::Io {
7719                path: native,
7720                source,
7721            })?;
7722            removed += 1;
7723        }
7724    }
7725    let source_header_candidates = if strip_source_headers {
7726        purpose_header_candidates(&root, &nodes)?
7727    } else {
7728        Vec::new()
7729    };
7730    Ok(LegacyPurposeReport {
7731        applied: !effective_dry_run,
7732        purpose_files_found: purpose_files.len(),
7733        purpose_files_removed: removed,
7734        source_header_candidates,
7735        purpose_files,
7736    })
7737}
7738
7739/// Collect `.purpose` files only from folders included in the normal index.
7740pub(crate) fn indexed_purpose_files(root: &Path, nodes: &[Node]) -> Vec<String> {
7741    let mut purpose_files = Vec::new();
7742    for node in nodes.iter().filter(|node| node.kind == NodeKind::Folder) {
7743        let relative = if node.path == "." {
7744            ".purpose".to_string()
7745        } else {
7746            format!("{}/.purpose", node.path)
7747        };
7748        let native = root.join(repo_path_to_native(&relative));
7749        if native.exists() {
7750            purpose_files.push(relative);
7751        }
7752    }
7753    purpose_files.sort();
7754    purpose_files
7755}
7756
7757/// Return source files that appear to start with legacy Purpose headers.
7758pub(crate) fn purpose_header_candidates(
7759    root: &Path,
7760    nodes: &[Node],
7761) -> Result<Vec<String>, CliError> {
7762    let mut candidates = Vec::new();
7763    for node in nodes
7764        .iter()
7765        .filter(|node| node.kind == NodeKind::File)
7766        .filter(|node| is_symbol_candidate(&node.path, node.language.as_deref()))
7767    {
7768        let path = root.join(repo_path_to_native(&node.path));
7769        let content = fs::read_to_string(&path).map_err(|source| CliError::Io { path, source })?;
7770        if content
7771            .lines()
7772            .take(3)
7773            .any(|line| line.trim_start().contains("Purpose:"))
7774        {
7775            candidates.push(node.path.clone());
7776        }
7777    }
7778    Ok(candidates)
7779}
7780
7781#[cfg(test)]
7782mod tests {
7783    use super::*;
7784    use projectatlas_core::graph::{GraphRelationKind, RelationResolution, RepositoryNodePath};
7785    use projectatlas_db::RepositoryGraphRelationQuery;
7786    use std::error::Error;
7787    use std::fmt::Debug;
7788
7789    #[test]
7790    fn worker_pools_respect_work_cardinality_and_runtime_ceiling() {
7791        for (work_items, max_workers, expected) in [
7792            (0, 16, 0),
7793            (1, 16, 1),
7794            (8, 16, 8),
7795            (64, 16, 16),
7796            (64, usize::MAX, INDEX_WORKER_SAFE_CEILING),
7797            (8, 0, 1),
7798        ] {
7799            assert_eq!(
7800                worker_count_for_work(work_items, max_workers),
7801                expected,
7802                "work_items={work_items}, max_workers={max_workers}"
7803            );
7804        }
7805    }
7806
7807    #[test]
7808    fn settings_publication_identity_rejects_mixed_snapshots() {
7809        let fingerprint = "a".repeat(64);
7810        let diagnostic = DatabasePublicationReport {
7811            state: IndexPublicationState::Complete,
7812            contract_fingerprint: Some(fingerprint.clone()),
7813            contract_fingerprint_state: DatabasePublicationContractState::Valid,
7814            generation: IndexGeneration::new(4),
7815        };
7816        let matching = IndexPublication {
7817            state: IndexPublicationState::Complete,
7818            contract_fingerprint: Some(fingerprint),
7819            generation: IndexGeneration::new(4),
7820        };
7821        assert!(settings_publication_matches(
7822            Some(&diagnostic),
7823            Some(&matching)
7824        ));
7825
7826        let next_generation = IndexPublication {
7827            generation: IndexGeneration::new(5),
7828            ..matching.clone()
7829        };
7830        assert!(!settings_publication_matches(
7831            Some(&diagnostic),
7832            Some(&next_generation)
7833        ));
7834
7835        let invalid = DatabasePublicationReport {
7836            contract_fingerprint: None,
7837            contract_fingerprint_state: DatabasePublicationContractState::Invalid,
7838            ..diagnostic
7839        };
7840        assert!(!settings_publication_matches(
7841            Some(&invalid),
7842            Some(&matching)
7843        ));
7844    }
7845
7846    #[test]
7847    fn supplied_language_controls_symbol_candidate_owner() {
7848        for path in ["Cargo.toml", "src/App.vue", "scripts/Get-Atlas.ps1"] {
7849            assert!(!is_symbol_candidate(path, Some("toon")), "{path}");
7850        }
7851        assert!(is_symbol_candidate("data/report.toon", Some("rust")));
7852        assert!(is_symbol_candidate(
7853            "data/report.toon",
7854            Some("cargo-manifest")
7855        ));
7856        assert!(is_symbol_candidate("data/report.toon", Some("vue")));
7857        assert!(is_symbol_candidate("data/report.toon", Some("powershell")));
7858        assert!(!is_symbol_candidate("data/report.toon", Some("toon")));
7859    }
7860
7861    #[test]
7862    fn optional_catalog_symbol_work_requires_effective_admission() {
7863        assert!(is_symbol_candidate("scripts/report.awk", Some("awk")));
7864        assert!(!is_symbol_candidate_for_admission(
7865            "scripts/report.awk",
7866            Some("awk"),
7867            false,
7868        ));
7869        assert!(is_symbol_candidate_for_admission(
7870            "scripts/report.awk",
7871            Some("awk"),
7872            true,
7873        ));
7874        assert!(is_symbol_candidate_for_admission(
7875            "src/lib.rs",
7876            Some("rust"),
7877            false,
7878        ));
7879    }
7880
7881    #[test]
7882    fn missing_language_preserves_specialized_symbol_candidate_inference() {
7883        for path in [
7884            "Cargo.toml",
7885            "Cargo.lock",
7886            "src/App.vue",
7887            "src/App.VUE",
7888            "scripts/Get-Atlas.ps1",
7889            "scripts/Atlas.psm1",
7890            "scripts/Atlas.psd1",
7891        ] {
7892            assert!(is_symbol_candidate(path, None), "{path}");
7893        }
7894        assert!(!is_symbol_candidate("data/report.toon", None));
7895    }
7896
7897    #[test]
7898    fn rejected_database_location_does_not_create_or_change_database() -> Result<(), Box<dyn Error>>
7899    {
7900        let temp = tempfile::tempdir()?;
7901        let root = temp.path().join("repository");
7902        fs::create_dir_all(&root)?;
7903
7904        for uncertain in [false, true] {
7905            let database_parent = temp
7906                .path()
7907                .join(if uncertain {
7908                    "uncertain"
7909                } else {
7910                    "unsupported"
7911                })
7912                .join("nested");
7913            let database = database_parent.join("projectatlas.db");
7914            let rejected_path = database.clone();
7915            let result = open_atlas_store_for_project_with_location_validator(
7916                &database,
7917                &root,
7918                move |_path| {
7919                    if uncertain {
7920                        Err(projectatlas_db::DbError::DatabaseFilesystemUncertain {
7921                            path: rejected_path,
7922                            mount_point: None,
7923                            filesystem_type: None,
7924                            reason: "injected filesystem uncertainty".to_string(),
7925                        })
7926                    } else {
7927                        Err(projectatlas_db::DbError::DatabaseFilesystemUnsupported {
7928                            path: rejected_path,
7929                            mount_point: None,
7930                            filesystem_type: Some("nfs".to_string()),
7931                        })
7932                    }
7933                },
7934            );
7935            let rejected = matches!(
7936                result,
7937                Err(CliError::Db(
7938                    projectatlas_db::DbError::DatabaseFilesystemUnsupported { .. }
7939                        | projectatlas_db::DbError::DatabaseFilesystemUncertain { .. }
7940                ))
7941            );
7942            require_eq(&rejected, &true, "typed location rejection")?;
7943            require_eq(
7944                &database_parent.exists(),
7945                &false,
7946                "rejected database parent absence",
7947            )?;
7948            require_eq(&database.exists(), &false, "rejected database absence")?;
7949
7950            let existing_database = temp.path().join(if uncertain {
7951                "uncertain-existing.db"
7952            } else {
7953                "unsupported-existing.db"
7954            });
7955            let original_bytes = b"existing database bytes stay untouched";
7956            fs::write(&existing_database, original_bytes)?;
7957            let rejected_path = existing_database.clone();
7958            let existing_result = open_atlas_store_for_project_with_location_validator(
7959                &existing_database,
7960                &root,
7961                move |_path| {
7962                    if uncertain {
7963                        Err(projectatlas_db::DbError::DatabaseFilesystemUncertain {
7964                            path: rejected_path,
7965                            mount_point: None,
7966                            filesystem_type: None,
7967                            reason: "injected filesystem uncertainty".to_string(),
7968                        })
7969                    } else {
7970                        Err(projectatlas_db::DbError::DatabaseFilesystemUnsupported {
7971                            path: rejected_path,
7972                            mount_point: None,
7973                            filesystem_type: Some("nfs".to_string()),
7974                        })
7975                    }
7976                },
7977            );
7978            require_eq(
7979                &matches!(
7980                    existing_result,
7981                    Err(CliError::Db(
7982                        projectatlas_db::DbError::DatabaseFilesystemUnsupported { .. }
7983                            | projectatlas_db::DbError::DatabaseFilesystemUncertain { .. }
7984                    ))
7985                ),
7986                &true,
7987                "typed existing location rejection",
7988            )?;
7989            require_eq(
7990                &fs::read(&existing_database)?,
7991                &original_bytes.to_vec(),
7992                "rejected existing database bytes",
7993            )?;
7994        }
7995        Ok(())
7996    }
7997
7998    #[test]
7999    fn operation_deadline_starts_with_default_or_shorter_explicit_timeout() {
8000        for (timeout_seconds, expected) in [
8001            (None, DEFAULT_INDEX_WORK_TIMEOUT),
8002            (Some(1), Duration::from_secs(1)),
8003            (
8004                Some(DEFAULT_INDEX_WORK_TIMEOUT.as_secs() + 1),
8005                DEFAULT_INDEX_WORK_TIMEOUT,
8006            ),
8007        ] {
8008            let options = SymbolBuildOptions::new(1_024, Some(1), timeout_seconds);
8009            let control = index_work_control(&options);
8010            assert_eq!(
8011                control
8012                    .deadline()
8013                    .map(|deadline| deadline.duration_since(control.started_at())),
8014                Some(expected)
8015            );
8016        }
8017    }
8018
8019    #[test]
8020    fn normal_read_refresh_delta_enforces_path_and_byte_budgets() -> Result<(), Box<dyn Error>> {
8021        let temp = tempfile::tempdir()?;
8022        let node = |path: String, size_bytes: u64| Node {
8023            path,
8024            kind: NodeKind::File,
8025            parent_path: None,
8026            extension: Some(".rs".to_string()),
8027            language: Some("rust".to_string()),
8028            size_bytes: Some(size_bytes),
8029            mtime_ns: Some(1),
8030            content_hash: Some("current-content".to_string()),
8031        };
8032        let path_bounded_nodes = (0..=NORMAL_READ_REFRESH_MAX_PATHS)
8033            .map(|index| node(format!("src/file_{index}.rs"), 1))
8034            .collect::<Vec<_>>();
8035        let path_delta = source_node_delta(temp.path(), &path_bounded_nodes, &[])
8036            .ok_or_else(|| io::Error::other("path-bounded freshness delta was missing"))?;
8037        require_eq(
8038            &path_delta.report.scope,
8039            &IndexRefreshScope::Full,
8040            "path-bounded refresh scope",
8041        )?;
8042        require_eq(
8043            &path_delta.report.changed,
8044            &(NORMAL_READ_REFRESH_MAX_PATHS + 1),
8045            "path-bounded change count",
8046        )?;
8047        require_eq(
8048            &path_delta.report.sample_paths.len(),
8049            &INDEX_FRESHNESS_SAMPLE_LIMIT,
8050            "bounded freshness sample",
8051        )?;
8052
8053        let byte_bounded_nodes = vec![node(
8054            "src/large.rs".to_string(),
8055            NORMAL_READ_REFRESH_MAX_BYTES + 1,
8056        )];
8057        let byte_delta = source_node_delta(temp.path(), &byte_bounded_nodes, &[])
8058            .ok_or_else(|| io::Error::other("byte-bounded freshness delta was missing"))?;
8059        require_eq(
8060            &byte_delta.report.scope,
8061            &IndexRefreshScope::Full,
8062            "byte-bounded refresh scope",
8063        )?;
8064        Ok(())
8065    }
8066
8067    #[test]
8068    fn controlled_freshness_plan_uses_the_callers_cancellation() -> Result<(), Box<dyn Error>> {
8069        let temp = tempfile::tempdir()?;
8070        let root = temp.path().join("repo");
8071        fs::create_dir(&root)?;
8072        fs::write(root.join("lib.rs"), "pub fn indexed() {}\n")?;
8073        let db_path = root.join(".projectatlas").join("projectatlas.db");
8074        let plan = ScanRuntimePlan::for_path(None, &root, None)?;
8075        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
8076        let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
8077        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
8078        drop(store);
8079
8080        let invalid_config = root.join("invalid-config.toml");
8081        fs::write(&invalid_config, "[invalid")?;
8082        let control = IndexWorkControl::new(IndexCancellation::new(), None);
8083        control.cancel();
8084        let result = open_fresh_atlas_store_for_project_controlled(
8085            &db_path,
8086            &root,
8087            Some(&invalid_config),
8088            &control,
8089        );
8090        if !matches!(
8091            result,
8092            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
8093                stage: IndexWorkStage::Publication,
8094            }))
8095        ) {
8096            return Err(io::Error::other(
8097                "controlled freshness parsed policy outside the caller cancellation boundary",
8098            )
8099            .into());
8100        }
8101        Ok(())
8102    }
8103
8104    #[test]
8105    fn automatic_read_refresh_returns_typed_state_when_writer_is_unavailable()
8106    -> Result<(), Box<dyn Error>> {
8107        let temp = tempfile::tempdir()?;
8108        let root = temp.path().join("repo");
8109        fs::create_dir(&root)?;
8110        let source_path = root.join("lib.rs");
8111        let indexed_source = "pub fn indexed() {}\n";
8112        let current_source = "pub fn current() {}\n";
8113        fs::write(&source_path, indexed_source)?;
8114        let db_path = root.join(".projectatlas").join("projectatlas.db");
8115        let plan = ScanRuntimePlan::for_path(None, &root, None)?;
8116        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
8117        let mut initial_store = open_atlas_store_for_project(&db_path, &plan.root)?;
8118        run_scan_pipeline(&mut initial_store, &plan, &symbol_options)?;
8119        let initial_generation = initial_store
8120            .index_publication()?
8121            .ok_or_else(|| io::Error::other("initial publication missing"))?
8122            .generation;
8123        drop(initial_store);
8124
8125        fs::write(&source_path, current_source)?;
8126        let mut blocking_writer = open_atlas_store_for_project(&db_path, &plan.root)?;
8127        let publication =
8128            blocking_writer.begin_index_publication(&plan.publication_contract_fingerprint())?;
8129        let refresh = open_fresh_atlas_store_for_project(&db_path, &plan.root, None);
8130        let Err(CliError::RefreshRequired(report)) = refresh else {
8131            return Err(io::Error::other(
8132                "contended automatic refresh did not return typed refresh_required",
8133            )
8134            .into());
8135        };
8136        require_eq(
8137            &report.scope,
8138            &IndexRefreshScope::Incremental,
8139            "contended automatic refresh scope",
8140        )?;
8141        require_eq(
8142            &report.reason,
8143            &IndexRefreshReason::SourceChanged,
8144            "contended automatic refresh reason",
8145        )?;
8146
8147        let last_valid = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
8148        require_eq(
8149            &last_valid
8150                .index_publication()?
8151                .ok_or_else(|| io::Error::other("last-valid publication missing"))?
8152                .generation,
8153            &initial_generation,
8154            "last-valid generation during contention",
8155        )?;
8156        require_eq(
8157            &last_valid
8158                .load_file_text("lib.rs")?
8159                .ok_or_else(|| io::Error::other("last-valid source text missing"))?
8160                .content,
8161            &indexed_source.to_string(),
8162            "last-valid source during contention",
8163        )?;
8164        drop(last_valid);
8165        drop(publication);
8166
8167        let repaired = open_fresh_atlas_store_for_project(&db_path, &plan.root, None)?;
8168        require_eq(
8169            &repaired
8170                .index_publication()?
8171                .ok_or_else(|| io::Error::other("repaired publication missing"))?
8172                .generation,
8173            &initial_generation
8174                .checked_next()
8175                .ok_or_else(|| io::Error::other("test generation overflow"))?,
8176            "repaired generation after contention",
8177        )?;
8178        require_eq(
8179            &repaired
8180                .load_file_text("lib.rs")?
8181                .ok_or_else(|| io::Error::other("repaired source text missing"))?
8182                .content,
8183            &current_source.to_string(),
8184            "current source after contention",
8185        )?;
8186        Ok(())
8187    }
8188
8189    #[test]
8190    fn derived_source_readers_reject_bytes_outside_staged_hash() -> Result<(), Box<dyn Error>> {
8191        let temp = tempfile::tempdir()?;
8192        let path = temp.path().join("lib.rs");
8193        let staged_source = "fn first() {}\n";
8194        let changed_source = "fn other() {}\n";
8195        fs::write(&path, staged_source)?;
8196        let expected_content_hash = blake3::hash(staged_source.as_bytes()).to_hex().to_string();
8197        let node = Node {
8198            path: "lib.rs".to_string(),
8199            kind: NodeKind::File,
8200            parent_path: None,
8201            extension: Some(".rs".to_string()),
8202            language: Some("rust".to_string()),
8203            size_bytes: Some(staged_source.len() as u64),
8204            mtime_ns: Some(1),
8205            content_hash: Some(expected_content_hash.clone()),
8206        };
8207        fs::write(&path, changed_source)?;
8208
8209        let text_result = indexed_file_texts_for_nodes(
8210            temp.path(),
8211            std::slice::from_ref(&node),
8212            TextIndexOptions::new(1_024),
8213        );
8214        let Err(CliError::RefreshRequired(details)) = text_result else {
8215            return Err(io::Error::other("text derivation accepted changed source bytes").into());
8216        };
8217        require_eq(
8218            &details.reason,
8219            &IndexRefreshReason::SourceChanged,
8220            "text source-change reason",
8221        )?;
8222        require_eq(
8223            &details.sample_paths,
8224            &vec!["lib.rs".to_string()],
8225            "text source-change paths",
8226        )?;
8227
8228        let symbol_outcome = parse_symbol_job(
8229            &SymbolParseJob {
8230                path: node.path,
8231                native_path: path,
8232                expected_content_hash,
8233                language: node.language,
8234                fallback_summary: None,
8235                purpose_needs_suggestion: false,
8236            },
8237            &SymbolBuildOptions::new(1_024, Some(1), None),
8238            Instant::now(),
8239        );
8240        if !matches!(
8241            symbol_outcome,
8242            SymbolParseOutcome::SourceChanged { path } if path == "lib.rs"
8243        ) {
8244            return Err(io::Error::other("symbol derivation accepted changed source bytes").into());
8245        }
8246
8247        let bounded_path = temp.path().join("bounded.txt");
8248        fs::write(&bounded_path, "four")?;
8249        let bounded_node = Node {
8250            path: "bounded.txt".to_string(),
8251            kind: NodeKind::File,
8252            parent_path: None,
8253            extension: Some(".txt".to_string()),
8254            language: Some("text".to_string()),
8255            size_bytes: Some(4),
8256            mtime_ns: Some(1),
8257            content_hash: Some(blake3::hash(b"four").to_hex().to_string()),
8258        };
8259        let bounded_control = standalone_index_work_control();
8260        let bounded_result = indexed_file_texts_for_nodes_with_limit(
8261            temp.path(),
8262            &[bounded_node],
8263            TextIndexOptions::new(1_024),
8264            3,
8265            &bounded_control,
8266        );
8267        if !matches!(
8268            bounded_result,
8269            Err(CliError::IndexWork(
8270                IndexWorkFailure::ResourceLimitExceeded {
8271                    stage: IndexWorkStage::TextIndex,
8272                    resource: IndexWorkResource::TextBytes,
8273                    limit: 3,
8274                    observed: 4,
8275                }
8276            ))
8277        ) {
8278            return Err(io::Error::other("text staging accepted bytes beyond its limit").into());
8279        }
8280
8281        let bounded_symbol = parse_symbol_job(
8282            &SymbolParseJob {
8283                path: "bounded.txt".to_string(),
8284                native_path: bounded_path,
8285                expected_content_hash: blake3::hash(b"four").to_hex().to_string(),
8286                language: Some("text".to_string()),
8287                fallback_summary: None,
8288                purpose_needs_suggestion: false,
8289            },
8290            &SymbolBuildOptions::new(3, Some(1), None),
8291            Instant::now(),
8292        );
8293        if !matches!(
8294            bounded_symbol,
8295            SymbolParseOutcome::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
8296                stage: IndexWorkStage::SymbolParsing,
8297                resource: IndexWorkResource::SourceBytes,
8298                limit: 3,
8299                observed: 4,
8300            })
8301        ) {
8302            return Err(io::Error::other("symbol read accepted bytes beyond its limit").into());
8303        }
8304        Ok(())
8305    }
8306
8307    #[test]
8308    fn parser_workers_reuse_structural_summaries_without_touching_approved_purpose()
8309    -> Result<(), Box<dyn Error>> {
8310        let temp = tempfile::tempdir()?;
8311        let path = temp.path().join("package.json");
8312        let content =
8313            r#"{"name":"demo","scripts":{"test":"vitest"},"dependencies":{"react":"1.0.0"}}"#;
8314        fs::write(&path, content)?;
8315        let node = Node {
8316            path: "package.json".to_string(),
8317            kind: NodeKind::File,
8318            parent_path: None,
8319            extension: Some(".json".to_string()),
8320            language: Some("json".to_string()),
8321            size_bytes: Some(content.len() as u64),
8322            mtime_ns: Some(1),
8323            content_hash: Some(blake3::hash(content.as_bytes()).to_hex().to_string()),
8324        };
8325        let mut store = AtlasStore::in_memory()?;
8326        store.replace_scan(std::slice::from_ref(&node))?;
8327        store.set_purpose(
8328            "package.json",
8329            "Own the JavaScript package manifest.",
8330            PurposeSource::Agent,
8331        )?;
8332        let text = refresh_text_index_for_nodes_with_rows(
8333            &mut store,
8334            temp.path(),
8335            std::slice::from_ref(&node),
8336            TextIndexOptions::new(1_024),
8337        )?;
8338        let SymbolParseOutcome::Parsed(parsed) = parse_symbol_job(
8339            &SymbolParseJob {
8340                path: node.path.clone(),
8341                native_path: path,
8342                expected_content_hash: node
8343                    .content_hash
8344                    .clone()
8345                    .ok_or_else(|| io::Error::other("fixture hash missing"))?,
8346                language: node.language.clone(),
8347                fallback_summary: None,
8348                purpose_needs_suggestion: false,
8349            },
8350            &SymbolBuildOptions::new(1_024, Some(1), None),
8351            Instant::now(),
8352        ) else {
8353            return Err(io::Error::other("package manifest did not parse").into());
8354        };
8355        require_eq(
8356            &parsed.summary_is_structural,
8357            &true,
8358            "parser-owned structural summary",
8359        )?;
8360        require_eq(
8361            &parsed.summary.as_str(),
8362            &"package manifest for demo with scripts test and 1 dependencies.",
8363            "structural content summary",
8364        )?;
8365        require_eq(
8366            &parsed.purpose_suggestion.is_none(),
8367            &true,
8368            "approved-purpose suggestion suppression",
8369        )?;
8370        let retained_bytes = symbol_parse_output_bytes(&parsed);
8371        let symbols = SymbolBuildStage {
8372            report: empty_symbol_build_report(),
8373            changes: vec![SymbolProjectionChange::Parsed(parsed)],
8374            retained_bytes,
8375        };
8376        let protected_purpose_paths = HashSet::from(["package.json".to_string()]);
8377        let control = standalone_index_work_control();
8378        let structural = stage_structural_summaries_for_nodes_controlled(
8379            &store,
8380            std::slice::from_ref(&node),
8381            &text.rows,
8382            Some(&symbols),
8383            &protected_purpose_paths,
8384            1,
8385            &control,
8386        )?;
8387        require_eq(
8388            &structural.report.summarized,
8389            &1,
8390            "structural summary report",
8391        )?;
8392        require_eq(
8393            &structural.report.purpose_suggestions,
8394            &0,
8395            "structural purpose-suggestion report",
8396        )?;
8397        require_eq(
8398            &structural.changes.is_empty(),
8399            &true,
8400            "duplicate structural mutations",
8401        )?;
8402
8403        apply_symbol_build_stage(&mut store, &symbols, &control)?;
8404        apply_structural_summary_stage(&mut store, &structural, &control)?;
8405        let indexed = store
8406            .load_node_by_path("package.json")?
8407            .ok_or_else(|| io::Error::other("indexed package manifest missing"))?;
8408        require_eq(
8409            &indexed.summary.as_deref(),
8410            &Some("package manifest for demo with scripts test and 1 dependencies."),
8411            "persisted structural content summary",
8412        )?;
8413        require_eq(
8414            &indexed.purpose.purpose.as_deref(),
8415            &Some("Own the JavaScript package manifest."),
8416            "approved purpose text",
8417        )?;
8418        require_eq(
8419            &indexed.purpose.status,
8420            &PurposeStatus::Approved,
8421            "approved purpose status",
8422        )?;
8423        Ok(())
8424    }
8425
8426    #[test]
8427    fn symbol_build_clamps_file_bytes_and_bounds_all_published_output() -> Result<(), Box<dyn Error>>
8428    {
8429        let temp = tempfile::tempdir()?;
8430        fs::write(
8431            temp.path().join("lib.rs"),
8432            "pub fn first() { second(); }\nfn second() {}\n",
8433        )?;
8434        let nodes = scan_repo(temp.path(), &ScanOptions::default())?;
8435        let mut store = AtlasStore::in_memory()?;
8436        store.replace_scan(&nodes)?;
8437        let options = SymbolBuildOptions::new(u64::MAX, Some(INDEX_WORKER_SAFE_CEILING), None);
8438        require_eq(
8439            &options.max_bytes,
8440            &MAX_SYMBOL_FILE_BYTES,
8441            "effective CLI/MCP symbol file limit",
8442        )?;
8443        let control = standalone_index_work_control();
8444        for resource in [
8445            IndexWorkResource::SymbolRows,
8446            IndexWorkResource::RelationRows,
8447            IndexWorkResource::OutputBytes,
8448        ] {
8449            if !matches!(
8450                checked_symbol_publication_usage(1, 1, 1, resource),
8451                Err(CliError::IndexWork(
8452                    IndexWorkFailure::ResourceLimitExceeded { observed: 2, .. }
8453                ))
8454            ) {
8455                return Err(io::Error::other(format!(
8456                    "symbol publication did not accumulate {resource}"
8457                ))
8458                .into());
8459            }
8460        }
8461        let cases = [
8462            (
8463                SymbolPublicationLimits {
8464                    symbol_rows: 0,
8465                    relation_rows: u64::MAX,
8466                    output_bytes: u64::MAX,
8467                },
8468                IndexWorkResource::SymbolRows,
8469            ),
8470            (
8471                SymbolPublicationLimits {
8472                    symbol_rows: u64::MAX,
8473                    relation_rows: 0,
8474                    output_bytes: u64::MAX,
8475                },
8476                IndexWorkResource::RelationRows,
8477            ),
8478            (
8479                SymbolPublicationLimits {
8480                    symbol_rows: u64::MAX,
8481                    relation_rows: u64::MAX,
8482                    output_bytes: 0,
8483                },
8484                IndexWorkResource::OutputBytes,
8485            ),
8486        ];
8487        for (limits, expected_resource) in cases {
8488            let result = build_symbols_for_paths_with_limits(
8489                &mut store,
8490                temp.path(),
8491                &options,
8492                None,
8493                None,
8494                &control,
8495                limits,
8496            );
8497            if !matches!(
8498                result,
8499                Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
8500                    stage: IndexWorkStage::SymbolParsing,
8501                    resource,
8502                    ..
8503                })) if resource == expected_resource
8504            ) {
8505                return Err(io::Error::other(format!(
8506                    "symbol publication did not enforce {expected_resource}"
8507                ))
8508                .into());
8509            }
8510            require_eq(
8511                &store.symbol_count_for_path("lib.rs")?,
8512                &0,
8513                "no over-limit symbol output persisted",
8514            )?;
8515        }
8516
8517        let mut oversized_node = nodes
8518            .iter()
8519            .find(|node| node.path == "lib.rs")
8520            .cloned()
8521            .ok_or_else(|| io::Error::other("oversized symbol fixture node missing"))?;
8522        oversized_node.size_bytes = Some(4);
8523        let clear_bytes = oversized_node.path.len() as u64
8524            + oversized_node.language.as_ref().map_or(0, String::len) as u64;
8525        let clear_options = SymbolBuildOptions::new(3, Some(1), None);
8526        let clear_limits = SymbolPublicationLimits {
8527            symbol_rows: u64::MAX,
8528            relation_rows: u64::MAX,
8529            output_bytes: clear_bytes.saturating_sub(1),
8530        };
8531        let clear_result = stage_symbols_for_nodes_with_limits(
8532            &store,
8533            temp.path(),
8534            #[cfg(feature = "optional-parser-supervisor")]
8535            &OptionalParserPackProjectSelection::Inactive,
8536            std::slice::from_ref(&oversized_node),
8537            &clear_options,
8538            None,
8539            None,
8540            &HashSet::new(),
8541            &control,
8542            clear_limits,
8543        );
8544        if !matches!(
8545            clear_result,
8546            Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
8547                stage: IndexWorkStage::SymbolParsing,
8548                resource: IndexWorkResource::OutputBytes,
8549                limit,
8550                observed,
8551            })) if limit == clear_bytes.saturating_sub(1) && observed == clear_bytes
8552        ) {
8553            return Err(
8554                io::Error::other("symbol clear output bypassed its retained-byte limit").into(),
8555            );
8556        }
8557        let clear_stage = stage_symbols_for_nodes_with_limits(
8558            &store,
8559            temp.path(),
8560            #[cfg(feature = "optional-parser-supervisor")]
8561            &OptionalParserPackProjectSelection::Inactive,
8562            std::slice::from_ref(&oversized_node),
8563            &clear_options,
8564            None,
8565            None,
8566            &HashSet::new(),
8567            &control,
8568            SymbolPublicationLimits {
8569                output_bytes: clear_bytes,
8570                ..SymbolPublicationLimits::STANDARD
8571            },
8572        )?;
8573        require_eq(
8574            &clear_stage.retained_bytes,
8575            &clear_bytes,
8576            "retained symbol clear bytes",
8577        )?;
8578        if !matches!(
8579            clear_stage.changes.as_slice(),
8580            [SymbolProjectionChange::Clear { path, language }]
8581                if path == "lib.rs" && language.as_deref() == Some("rust")
8582        ) {
8583            return Err(
8584                io::Error::other("oversized symbol output did not retain one clear").into(),
8585            );
8586        }
8587
8588        let report = build_symbols_for_paths_with_limits(
8589            &mut store,
8590            temp.path(),
8591            &options,
8592            None,
8593            None,
8594            &control,
8595            SymbolPublicationLimits::STANDARD,
8596        )?;
8597        require_eq(&report.parsed, &1, "compatible bounded symbol build")?;
8598        require_eq(&report.max_workers, &1, "single-job symbol worker count")?;
8599        if report.symbols == 0 || report.relations == 0 {
8600            return Err(io::Error::other("bounded symbol build omitted parser output").into());
8601        }
8602        Ok(())
8603    }
8604
8605    #[test]
8606    fn purpose_import_skips_non_utf8_source_headers_but_keeps_authored_inputs_strict()
8607    -> Result<(), Box<dyn Error>> {
8608        let temp = tempfile::tempdir()?;
8609        let config_path = init_config_path(temp.path(), None);
8610        init_project_with_config(temp.path(), Some(&config_path))?;
8611        let config = load_atlas_config(Some(&config_path))?;
8612        fs::write(
8613            temp.path().join("binary.txt"),
8614            b"// Purpose: Must not be imported from binary source.\n\xff",
8615        )?;
8616        let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
8617        let nodes = scan_repo(&plan.root, &plan.scan_options)?;
8618        if !nodes.iter().any(|node| node.path == "binary.txt") {
8619            return Err(io::Error::other("binary source fixture was not scanned").into());
8620        }
8621        let snapshot =
8622            plan.purpose_import_snapshot_controlled(&nodes, &standalone_index_work_control())?;
8623        if snapshot
8624            .records
8625            .iter()
8626            .any(|record| record.path == "binary.txt")
8627        {
8628            return Err(io::Error::other("non-UTF-8 source header imported a purpose").into());
8629        }
8630
8631        fs::write(&config.map_path, [0xff])?;
8632        let strict =
8633            plan.purpose_import_snapshot_controlled(&nodes, &standalone_index_work_control());
8634        if !matches!(
8635            strict,
8636            Err(CliError::InvalidInput(message))
8637                if message.contains("purpose input is not valid UTF-8")
8638        ) {
8639            return Err(
8640                io::Error::other("non-UTF-8 authored purpose input was not rejected").into(),
8641            );
8642        }
8643        Ok(())
8644    }
8645
8646    #[test]
8647    fn purpose_import_inputs_observe_cancellation_limits_and_rollback() -> Result<(), Box<dyn Error>>
8648    {
8649        struct CancelAfterFirstRead {
8650            bytes: io::Cursor<Vec<u8>>,
8651            cancellation: IndexCancellation,
8652            reads: usize,
8653        }
8654
8655        impl Read for CancelAfterFirstRead {
8656            fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
8657                let read = self.bytes.read(buffer)?;
8658                self.reads += 1;
8659                if self.reads == 1 {
8660                    self.cancellation.cancel();
8661                }
8662                Ok(read)
8663            }
8664        }
8665
8666        let temp = tempfile::tempdir()?;
8667        let config_path = init_config_path(temp.path(), None);
8668        init_project_with_config(temp.path(), Some(&config_path))?;
8669        let fixture_config = load_atlas_config(Some(&config_path))?;
8670        fs::write(temp.path().join("lib.rs"), "fn imported() {}\n")?;
8671        fs::write(
8672            &fixture_config.map_path,
8673            "folders[1]:\n  .,Controlled imported repository purpose\n",
8674        )?;
8675        let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
8676        let nodes = scan_repo(&plan.root, &plan.scan_options)?;
8677        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
8678        let mut store = AtlasStore::in_memory()?;
8679        store.set_project_root(&plan.root)?;
8680        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
8681        let publication_before = store
8682            .index_publication()?
8683            .ok_or_else(|| io::Error::other("controlled import publication missing"))?;
8684        let root_before = store
8685            .load_node_by_path(".")?
8686            .ok_or_else(|| io::Error::other("controlled import root missing"))?;
8687        let text_before = store
8688            .load_file_text("lib.rs")?
8689            .ok_or_else(|| io::Error::other("controlled import text missing"))?;
8690        let symbol_count_before = store.symbol_count()?;
8691        let relation_count_before = store.symbol_relation_count()?;
8692
8693        let config_bytes = fs::metadata(&config_path)?.len();
8694        let map_bytes = fs::metadata(&fixture_config.map_path)?.len();
8695        let nonsource_bytes = fs::metadata(&fixture_config.nonsource_files_path)?.len();
8696        let source_bytes = fs::metadata(temp.path().join("lib.rs"))?.len();
8697        let staged_input_bytes = config_bytes
8698            .saturating_add(map_bytes)
8699            .saturating_add(nonsource_bytes)
8700            .saturating_add(source_bytes);
8701        let complete_operation_bytes = config_bytes
8702            .saturating_add(staged_input_bytes)
8703            .saturating_add(config_bytes)
8704            .saturating_add(staged_input_bytes);
8705        let largest_complete_input = config_bytes.max(map_bytes).max(nonsource_bytes);
8706        let aggregate_limits = PurposeImportLimits {
8707            total_bytes: complete_operation_bytes,
8708            complete_file_bytes: largest_complete_input,
8709            header_bytes: source_bytes,
8710            records: 100,
8711        };
8712        let aggregate_control = standalone_index_work_control();
8713        let aggregate_plan = ScanRuntimePlan::for_path_controlled_with_limits(
8714            None,
8715            temp.path(),
8716            None,
8717            &aggregate_control,
8718            aggregate_limits,
8719        )?;
8720        let aggregate_nodes = scan_repo(&aggregate_plan.root, &aggregate_plan.scan_options)?;
8721        let aggregate_snapshot = aggregate_plan.purpose_import_snapshot_controlled_with_limits(
8722            &aggregate_nodes,
8723            &aggregate_control,
8724            aggregate_limits,
8725        )?;
8726        revalidate_index_publication_inputs_controlled_with_limits(
8727            &store,
8728            &aggregate_plan,
8729            Some(&aggregate_snapshot.fingerprint),
8730            &aggregate_control,
8731            aggregate_limits,
8732        )?;
8733
8734        let cumulative_limit = complete_operation_bytes.saturating_sub(1);
8735        let cumulative_limits = PurposeImportLimits {
8736            total_bytes: cumulative_limit,
8737            ..aggregate_limits
8738        };
8739        let cumulative_control = standalone_index_work_control();
8740        let cumulative_plan = ScanRuntimePlan::for_path_controlled_with_limits(
8741            None,
8742            temp.path(),
8743            None,
8744            &cumulative_control,
8745            cumulative_limits,
8746        )?;
8747        let cumulative_nodes = scan_repo(&cumulative_plan.root, &cumulative_plan.scan_options)?;
8748        let cumulative_snapshot = cumulative_plan.purpose_import_snapshot_controlled_with_limits(
8749            &cumulative_nodes,
8750            &cumulative_control,
8751            cumulative_limits,
8752        )?;
8753        let cumulative_result = revalidate_index_publication_inputs_controlled_with_limits(
8754            &store,
8755            &cumulative_plan,
8756            Some(&cumulative_snapshot.fingerprint),
8757            &cumulative_control.with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT),
8758            cumulative_limits,
8759        );
8760        if !matches!(
8761            cumulative_result,
8762            Err(CliError::IndexWork(
8763                IndexWorkFailure::ResourceLimitExceeded {
8764                    stage: IndexWorkStage::Publication,
8765                    resource: IndexWorkResource::PurposeBytes,
8766                    limit,
8767                    observed,
8768                }
8769            )) if limit == cumulative_limit && observed > limit
8770        ) {
8771            return Err(io::Error::other(
8772                "plan, staging, and revalidation readers did not share one purpose-byte budget",
8773            )
8774            .into());
8775        }
8776
8777        let control = standalone_index_work_control();
8778        let staged_snapshot = plan.purpose_import_snapshot_controlled(&nodes, &control)?;
8779        let small_limits = PurposeImportLimits {
8780            total_bytes: 64,
8781            complete_file_bytes: 64,
8782            header_bytes: 64,
8783            records: 100,
8784        };
8785        let initial_limit_control = standalone_index_work_control();
8786        let initial_limited = plan.purpose_import_snapshot_controlled_with_limits(
8787            &nodes,
8788            &initial_limit_control,
8789            small_limits,
8790        );
8791        if !matches!(
8792            initial_limited,
8793            Err(CliError::IndexWork(
8794                IndexWorkFailure::ResourceLimitExceeded {
8795                    stage: IndexWorkStage::Publication,
8796                    resource: IndexWorkResource::PurposeBytes,
8797                    limit: 64,
8798                    observed: 65,
8799                }
8800            ))
8801        ) {
8802            return Err(io::Error::other(
8803                "initial purpose snapshot exceeded limits without a typed failure",
8804            )
8805            .into());
8806        }
8807        let initial_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
8808        initial_cancel.cancel();
8809        if !matches!(
8810            plan.purpose_import_snapshot_controlled(&nodes, &initial_cancel),
8811            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
8812                stage: IndexWorkStage::Publication,
8813            }))
8814        ) {
8815            return Err(io::Error::other(
8816                "initial purpose snapshot ignored operation cancellation",
8817            )
8818            .into());
8819        }
8820
8821        let contract_fingerprint = plan.publication_contract_fingerprint();
8822        let mut publication = store.begin_index_publication(&contract_fingerprint)?;
8823        publication.set_project_root(&plan.root)?;
8824        publication.replace_scan(&nodes)?;
8825        publication.set_purpose(".", "Uncommitted purpose input", PurposeSource::Imported)?;
8826        let publication_limit_control = standalone_index_work_control();
8827        let limited = revalidate_index_publication_inputs_controlled_with_limits(
8828            &publication,
8829            &plan,
8830            Some(&staged_snapshot.fingerprint),
8831            &publication_limit_control,
8832            small_limits,
8833        );
8834        if !matches!(
8835            limited,
8836            Err(CliError::IndexWork(
8837                IndexWorkFailure::ResourceLimitExceeded {
8838                    stage: IndexWorkStage::Publication,
8839                    resource: IndexWorkResource::PurposeBytes,
8840                    limit: 64,
8841                    observed: 65,
8842                }
8843            ))
8844        ) {
8845            return Err(io::Error::other(
8846                "publication purpose inputs exceeded limits without a typed failure",
8847            )
8848            .into());
8849        }
8850        drop(publication);
8851        require_eq(
8852            &store.index_publication()?,
8853            &Some(publication_before.clone()),
8854            "publication after bounded purpose-input rollback",
8855        )?;
8856        require_eq(
8857            &store.load_node_by_path(".")?,
8858            &Some(root_before.clone()),
8859            "authored purpose after bounded input rollback",
8860        )?;
8861        require_eq(
8862            &store.load_file_text("lib.rs")?,
8863            &Some(text_before.clone()),
8864            "indexed text after bounded input rollback",
8865        )?;
8866        require_eq(
8867            &store.symbol_count()?,
8868            &symbol_count_before,
8869            "symbols after bounded input rollback",
8870        )?;
8871        require_eq(
8872            &store.symbol_relation_count()?,
8873            &relation_count_before,
8874            "relations after bounded input rollback",
8875        )?;
8876
8877        let mut canceled_publication = store.begin_index_publication(&contract_fingerprint)?;
8878        canceled_publication.set_project_root(&plan.root)?;
8879        canceled_publication.replace_scan(&nodes)?;
8880        canceled_publication.set_purpose(
8881            ".",
8882            "Canceled uncommitted purpose input",
8883            PurposeSource::Imported,
8884        )?;
8885        let late_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
8886        late_cancel.cancel();
8887        let canceled = revalidate_index_publication_inputs_controlled(
8888            &canceled_publication,
8889            &plan,
8890            Some(&staged_snapshot.fingerprint),
8891            &late_cancel,
8892        );
8893        if !matches!(
8894            canceled,
8895            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
8896                stage: IndexWorkStage::Publication,
8897            }))
8898        ) {
8899            return Err(io::Error::other(
8900                "late purpose-input revalidation ignored operation cancellation",
8901            )
8902            .into());
8903        }
8904        drop(canceled_publication);
8905        require_eq(
8906            &store.index_publication()?,
8907            &Some(publication_before),
8908            "publication after canceled purpose-input rollback",
8909        )?;
8910        require_eq(
8911            &store.load_node_by_path(".")?,
8912            &Some(root_before),
8913            "authored purpose after canceled input rollback",
8914        )?;
8915        require_eq(
8916            &store.load_file_text("lib.rs")?,
8917            &Some(text_before),
8918            "indexed text after canceled input rollback",
8919        )?;
8920        require_eq(
8921            &store.symbol_count()?,
8922            &symbol_count_before,
8923            "symbols after canceled input rollback",
8924        )?;
8925        require_eq(
8926            &store.symbol_relation_count()?,
8927            &relation_count_before,
8928            "relations after canceled input rollback",
8929        )?;
8930
8931        let record_limited = plan.purpose_import_snapshot_controlled_with_limits(
8932            &nodes,
8933            &control,
8934            PurposeImportLimits {
8935                records: 0,
8936                ..PurposeImportLimits::default()
8937            },
8938        );
8939        if !matches!(
8940            record_limited,
8941            Err(CliError::IndexWork(
8942                IndexWorkFailure::ResourceLimitExceeded {
8943                    stage: IndexWorkStage::Publication,
8944                    resource: IndexWorkResource::PurposeRecords,
8945                    limit: 0,
8946                    observed,
8947                }
8948            )) if observed > 0
8949        ) {
8950            return Err(io::Error::other("purpose record limit was not enforced").into());
8951        }
8952
8953        let cancellation = IndexCancellation::new();
8954        let cancel_control = IndexWorkControl::new(cancellation.clone(), None);
8955        let mut reader =
8956            PurposeInputReader::new(&plan, &cancel_control, PurposeImportLimits::default());
8957        let mut input = CancelAfterFirstRead {
8958            bytes: io::Cursor::new(vec![b'x'; CONTROLLED_SOURCE_READ_BUFFER_BYTES * 2]),
8959            cancellation,
8960            reads: 0,
8961        };
8962        let canceled = reader.read_bytes(
8963            Path::new("controlled-purpose-input"),
8964            &mut input,
8965            u64::try_from(CONTROLLED_SOURCE_READ_BUFFER_BYTES * 2).unwrap_or(u64::MAX),
8966            true,
8967        );
8968        if !matches!(
8969            canceled,
8970            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
8971                stage: IndexWorkStage::Publication,
8972            }))
8973        ) {
8974            return Err(io::Error::other(
8975                "purpose input did not observe cancellation between chunks",
8976            )
8977            .into());
8978        }
8979        Ok(())
8980    }
8981
8982    #[test]
8983    fn publication_revalidation_rejects_source_policy_and_import_drift()
8984    -> Result<(), Box<dyn Error>> {
8985        let temp = tempfile::tempdir()?;
8986        let config_dir = temp.path().join(".projectatlas");
8987        fs::create_dir_all(&config_dir)?;
8988        let source_path = temp.path().join("lib.rs");
8989        let staged_source = "fn first() {}\n";
8990        fs::write(&source_path, staged_source)?;
8991        let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
8992        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
8993        let db_path = config_dir.join("projectatlas.db");
8994        let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
8995        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
8996        let control = standalone_index_work_control();
8997        let generation_before_contention = store
8998            .index_publication()?
8999            .ok_or_else(|| io::Error::other("initial publication missing"))?
9000            .generation;
9001        let contract_fingerprint = plan.publication_contract_fingerprint();
9002        let contended_batch =
9003            stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
9004        revalidate_staged_publication_inputs_controlled(
9005            &plan,
9006            contended_batch.nodes.expected_nodes(),
9007            None,
9008            &control,
9009        )?;
9010        let writer_blocker = rusqlite::Connection::open(&db_path)?;
9011        writer_blocker.execute_batch("BEGIN IMMEDIATE")?;
9012        let source_after_contention = "fn later() {}\n";
9013        let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
9014        let (publish_tx, publish_rx) = std::sync::mpsc::sync_channel(1);
9015        let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1);
9016        let publisher = std::thread::spawn(move || {
9017            if ready_tx.send(()).is_err() || publish_rx.recv().is_err() {
9018                return;
9019            }
9020            let publication_control = standalone_index_work_control();
9021            let result = publish_index_batch(&mut store, contended_batch, &publication_control);
9022            drop(result_tx.send((store, result)));
9023        });
9024        ready_rx
9025            .recv_timeout(Duration::from_secs(1))
9026            .map_err(|source| {
9027                io::Error::other(format!("publisher did not become ready: {source}"))
9028            })?;
9029        fs::write(&source_path, source_after_contention)?;
9030        publish_tx
9031            .send(())
9032            .map_err(|source| io::Error::other(format!("publisher stopped early: {source}")))?;
9033        let timely_result = result_rx.recv_timeout(Duration::from_millis(500));
9034        writer_blocker.execute_batch("ROLLBACK")?;
9035        let (returned_store, contention_result) = match timely_result {
9036            Ok(result) => result,
9037            Err(source) => {
9038                publisher
9039                    .join()
9040                    .map_err(|_panic| io::Error::other("publication thread panicked"))?;
9041                return Err(io::Error::other(format!(
9042                    "publication waited for a contending writer instead of failing fast: {source}"
9043                ))
9044                .into());
9045            }
9046        };
9047        publisher
9048            .join()
9049            .map_err(|_panic| io::Error::other("publication thread panicked"))?;
9050        store = returned_store;
9051        let Err(CliError::Db(contention)) = contention_result else {
9052            return Err(io::Error::other(
9053                "publication waited through contention and accepted stale staged source",
9054            )
9055            .into());
9056        };
9057        if !contention.is_write_unavailable() {
9058            return Err(io::Error::other(
9059                "publication contention did not return typed write-unavailable state",
9060            )
9061            .into());
9062        }
9063        require_eq(
9064            &store
9065                .index_publication()?
9066                .ok_or_else(|| io::Error::other("publication missing after contention"))?
9067                .generation,
9068            &generation_before_contention,
9069            "publication generation after contention",
9070        )?;
9071        require_eq(
9072            &store
9073                .load_file_text("lib.rs")?
9074                .ok_or_else(|| io::Error::other("indexed text missing after contention"))?
9075                .content,
9076            &staged_source.to_string(),
9077            "indexed text after contention",
9078        )?;
9079        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
9080        require_eq(
9081            &store
9082                .load_file_text("lib.rs")?
9083                .ok_or_else(|| io::Error::other("indexed text missing after retry"))?
9084                .content,
9085            &source_after_contention.to_string(),
9086            "restaged text after contention retry",
9087        )?;
9088        let initial_generation = store
9089            .index_publication()?
9090            .ok_or_else(|| io::Error::other("retried publication missing"))?
9091            .generation;
9092        require_eq(
9093            &initial_generation,
9094            &generation_before_contention
9095                .checked_next()
9096                .ok_or_else(|| io::Error::other("test generation overflowed"))?,
9097            "single generation advance after contention retry",
9098        )?;
9099        let mut competing_store = open_atlas_store_for_project(&db_path, &plan.root)?;
9100        let competing_publication = competing_store
9101            .begin_index_projection_refresh_from(&contract_fingerprint, initial_generation)?;
9102        competing_publication.set_node_summary("lib.rs", "winning projection")?;
9103        let generation_conflict_batch =
9104            stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
9105        competing_publication.complete()?;
9106        let winning_generation = initial_generation
9107            .checked_next()
9108            .ok_or_else(|| io::Error::other("test generation overflowed"))?;
9109        let conflict = publish_index_batch(&mut store, generation_conflict_batch, &control);
9110        if !matches!(
9111            conflict,
9112            Err(CliError::Db(
9113                projectatlas_db::DbError::PublicationBaseGenerationChanged {
9114                    expected,
9115                    found,
9116                }
9117            )) if expected == initial_generation && found == winning_generation
9118        ) {
9119            return Err(io::Error::other(
9120                "runtime publication did not reject a generation changed after preparation",
9121            )
9122            .into());
9123        }
9124        let winning_publication = store
9125            .index_publication()?
9126            .ok_or_else(|| io::Error::other("winning publication missing"))?;
9127        require_eq(
9128            &winning_publication.generation,
9129            &winning_generation,
9130            "winning publication generation",
9131        )?;
9132        require_eq(
9133            &winning_publication.state,
9134            &projectatlas_db::IndexPublicationState::Complete,
9135            "winning publication state",
9136        )?;
9137        require_eq(
9138            &store
9139                .load_node_by_path("lib.rs")?
9140                .and_then(|node| node.summary),
9141            &Some("winning projection".to_string()),
9142            "winning publication summary",
9143        )?;
9144        let staged_source_batch =
9145            stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
9146
9147        fs::write(&source_path, "fn other() {}\n")?;
9148        let source_result = revalidate_staged_publication_inputs_controlled(
9149            &plan,
9150            staged_source_batch.nodes.expected_nodes(),
9151            None,
9152            &control,
9153        );
9154        let Err(CliError::RefreshRequired(details)) = source_result else {
9155            return Err(io::Error::other("publication accepted changed source state").into());
9156        };
9157        require_eq(
9158            &details.reason,
9159            &IndexRefreshReason::SourceChanged,
9160            "publication source-change reason",
9161        )?;
9162        require_eq(
9163            &details.sample_paths,
9164            &vec!["lib.rs".to_string()],
9165            "publication source-change paths",
9166        )?;
9167
9168        fs::write(&source_path, staged_source)?;
9169        let staged_policy_batch =
9170            stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
9171        let config_path = config_dir.join("config.toml");
9172        fs::write(
9173            &config_path,
9174            r#"[project]
9175root = "."
9176map_path = ".projectatlas/projectatlas.toon"
9177nonsource_files_path = ".projectatlas/projectatlas-nonsource-files.toon"
9178
9179[scan]
9180source_extensions = [".rs"]
9181exclude_dir_names = [".git", ".projectatlas", "target"]
9182exclude_dir_suffixes = []
9183exclude_path_prefixes = []
9184non_source_path_prefixes = []
9185text_index_max_bytes = 7
9186
9187[purpose]
9188default_style = "line-comment"
9189line_comment_prefixes = ["//"]
9190
9191[purpose.styles_by_extension]
9192".rs" = "line-comment"
9193"#,
9194        )?;
9195        let policy_result = revalidate_staged_publication_inputs_controlled(
9196            &plan,
9197            staged_policy_batch.nodes.expected_nodes(),
9198            None,
9199            &control,
9200        );
9201        let Err(CliError::VerificationIncomplete(details)) = policy_result else {
9202            return Err(io::Error::other("publication accepted changed effective policy").into());
9203        };
9204        require_eq(
9205            &details.reason,
9206            &IndexVerificationReason::PublicationContractMismatch,
9207            "publication policy-change reason",
9208        )?;
9209
9210        let configured_plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
9211        let request_limited_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1))?;
9212        require_eq(
9213            &configured_plan.text_options.max_bytes,
9214            &7,
9215            "configured text-index limit",
9216        )?;
9217        require_eq(
9218            &request_limited_plan.text_options.max_bytes,
9219            &1,
9220            "request-scoped text-index limit",
9221        )?;
9222        require_eq(
9223            &configured_plan.publication_contract_fingerprint(),
9224            &request_limited_plan.publication_contract_fingerprint(),
9225            "request limit excluded from publication contract",
9226        )?;
9227        let reloaded_request_plan = request_limited_plan.reload()?;
9228        require_eq(
9229            &reloaded_request_plan.text_options.max_bytes,
9230            &1,
9231            "request limit retained across operation reload",
9232        )?;
9233        require_eq(
9234            &request_limited_plan.publication_contract_fingerprint(),
9235            &reloaded_request_plan.publication_contract_fingerprint(),
9236            "reloaded operation publication contract",
9237        )?;
9238
9239        let changed_config = fs::read_to_string(&config_path)?
9240            .replace("text_index_max_bytes = 7", "text_index_max_bytes = 8");
9241        fs::write(&config_path, changed_config)?;
9242        let configured_cap_changed_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1))?;
9243        if configured_plan.publication_contract_fingerprint()
9244            == configured_cap_changed_plan.publication_contract_fingerprint()
9245        {
9246            return Err(io::Error::other(
9247                "configured text-index limit did not change the publication contract",
9248            )
9249            .into());
9250        }
9251
9252        let import_repo = temp.path().join("import-repo");
9253        let import_atlas_dir = import_repo.join(".projectatlas");
9254        fs::create_dir_all(&import_atlas_dir)?;
9255        fs::write(import_repo.join("lib.rs"), "fn imported() {}\n")?;
9256        let import_map_path = import_atlas_dir.join("projectatlas.toon");
9257        fs::write(
9258            &import_map_path,
9259            "folders[1]:\n  .,Original imported repository purpose\n",
9260        )?;
9261        let external_config_path = temp.path().join("external-config.toml");
9262        fs::write(
9263            &external_config_path,
9264            r#"[project]
9265root = "import-repo"
9266map_path = ".projectatlas/projectatlas.toon"
9267nonsource_files_path = ".projectatlas/projectatlas-nonsource-files.toon"
9268"#,
9269        )?;
9270        let import_plan =
9271            ScanRuntimePlan::for_path(Some(&external_config_path), &import_repo, Some(1))?;
9272        let mut import_store = open_atlas_store_for_project(
9273            &import_atlas_dir.join("projectatlas.db"),
9274            &import_plan.root,
9275        )?;
9276        run_scan_pipeline(&mut import_store, &import_plan, &symbol_options)?;
9277        verify_index_freshness(&import_store, &import_repo, Some(&external_config_path))?;
9278        let normal_import_plan =
9279            ScanRuntimePlan::for_path(Some(&external_config_path), &import_repo, None)?;
9280        run_symbol_build_pipeline(
9281            &mut import_store,
9282            &normal_import_plan,
9283            &symbol_options,
9284            None,
9285        )?;
9286        verify_index_publication(&import_store, &normal_import_plan)?;
9287        let publication_before = import_store
9288            .index_publication()?
9289            .ok_or_else(|| io::Error::other("initial imported publication missing"))?;
9290        let root_before = import_store
9291            .load_node_by_path(".")?
9292            .ok_or_else(|| io::Error::other("imported root node missing"))?;
9293        if root_before.purpose.purpose.as_deref() != Some("Original imported repository purpose") {
9294            return Err(io::Error::other("legacy purpose fixture was not imported").into());
9295        }
9296
9297        let import_control = standalone_index_work_control();
9298        let staged_import_batch = stage_full_index_publication(
9299            &import_store,
9300            &import_plan,
9301            &symbol_options,
9302            true,
9303            true,
9304            &import_control,
9305        )?;
9306        let staged_purpose_import = staged_import_batch
9307            .purpose_import
9308            .as_ref()
9309            .ok_or_else(|| io::Error::other("staged purpose import missing"))?;
9310        if !staged_purpose_import
9311            .records
9312            .iter()
9313            .any(|record| record.summary == "Original imported repository purpose")
9314        {
9315            return Err(io::Error::other("legacy purpose fixture was not imported").into());
9316        }
9317        fs::write(
9318            &import_map_path,
9319            "folders[1]:\n  .,Changed imported repository purpose\n",
9320        )?;
9321        let import_result = revalidate_staged_publication_inputs_with_purpose_snapshot(
9322            &import_plan,
9323            staged_import_batch.nodes.expected_nodes(),
9324            Some(staged_purpose_import),
9325            &import_control,
9326        );
9327        let Err(CliError::VerificationIncomplete(details)) = import_result else {
9328            return Err(io::Error::other(
9329                "publication accepted changed legacy purpose import inputs",
9330            )
9331            .into());
9332        };
9333        require_eq(
9334            &details.reason,
9335            &IndexVerificationReason::PublicationContractMismatch,
9336            "publication import-change reason",
9337        )?;
9338        require_eq(
9339            &import_store.index_publication()?,
9340            &Some(publication_before),
9341            "publication after purpose-import rollback",
9342        )?;
9343        require_eq(
9344            &import_store.load_node_by_path(".")?,
9345            &Some(root_before),
9346            "authored purpose after purpose-import rollback",
9347        )?;
9348        Ok(())
9349    }
9350
9351    #[test]
9352    fn semantic_contract_revision_forces_full_projection_refresh() -> Result<(), Box<dyn Error>> {
9353        const PRE_MODULE_CALLBACK_DIGEST: &str =
9354            "487625adf2f9ec76f98034d4ef5667e707960b6b8afd280b213021cb64a0f10f";
9355        let temp = tempfile::tempdir()?;
9356        let atlas_dir = temp.path().join(".projectatlas");
9357        fs::create_dir_all(&atlas_dir)?;
9358        fs::create_dir_all(temp.path().join("src"))?;
9359        fs::write(
9360            temp.path().join("src/config.rs"),
9361            "pub fn load_timeout_millis() -> u64 { 250 }\n",
9362        )?;
9363        fs::write(
9364            temp.path().join("src/handler.rs"),
9365            "use crate::config;\npub fn health_response() { let _ = config::load_timeout_millis(); }\n",
9366        )?;
9367        fs::write(
9368            temp.path().join("src/router.rs"),
9369            "use crate::handler;\npub fn dispatch(path: &str) -> Option<()> { (path == \"/health\").then(handler::health_response) }\n",
9370        )?;
9371
9372        let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
9373        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
9374        let db_path = atlas_dir.join("projectatlas.db");
9375        let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
9376        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
9377        let current_fingerprint = plan.publication_contract_fingerprint();
9378        let legacy_fingerprint = index_derivation_fingerprint_with_semantic_digest(
9379            &plan.scan_options,
9380            text_index_options(plan.config.as_ref(), None),
9381            #[cfg(feature = "optional-parser-supervisor")]
9382            &plan.optional_parser_selection,
9383            PRE_MODULE_CALLBACK_DIGEST,
9384        );
9385        if legacy_fingerprint == current_fingerprint {
9386            return Err(io::Error::other(
9387                "semantic contract revision did not change the derivation fingerprint",
9388            )
9389            .into());
9390        }
9391
9392        let current_generation = store
9393            .index_publication()?
9394            .ok_or_else(|| io::Error::other("current publication missing"))?
9395            .generation;
9396        store
9397            .begin_index_publication_from(&legacy_fingerprint, current_generation)?
9398            .complete()?;
9399        if publication_contract_matches(&store, &plan)? {
9400            return Err(io::Error::other(
9401                "prior semantic contract unexpectedly matched the current plan",
9402            )
9403            .into());
9404        }
9405
9406        let stale_generation = store
9407            .index_publication()?
9408            .ok_or_else(|| io::Error::other("stale publication missing"))?
9409            .generation;
9410        let control = standalone_index_work_control();
9411        refresh_index_controlled(&mut store, &plan, &symbol_options, &control)?;
9412        let refreshed = store
9413            .index_publication()?
9414            .ok_or_else(|| io::Error::other("refreshed publication missing"))?;
9415        if refreshed.generation <= stale_generation
9416            || refreshed.contract_fingerprint.as_deref() != Some(current_fingerprint.as_str())
9417        {
9418            return Err(io::Error::other(
9419                "semantic contract mismatch did not force a current full publication",
9420            )
9421            .into());
9422        }
9423        let graphs = store.load_symbol_graphs_for_paths(&["src/router.rs".to_string()])?;
9424        if !graphs
9425            .iter()
9426            .flat_map(|graph| &graph.relations)
9427            .any(|relation| {
9428                relation.kind == projectatlas_core::symbols::RelationKind::Calls
9429                    && relation.target_name == "handler::health_response"
9430            })
9431        {
9432            return Err(io::Error::other(
9433                "semantic refresh did not publish the comparison-then callback edge",
9434            )
9435            .into());
9436        }
9437        Ok(())
9438    }
9439
9440    #[cfg(feature = "optional-parser-supervisor")]
9441    #[test]
9442    fn optional_parser_selection_changes_derivation_and_preserves_prior_generation_on_failure()
9443    -> Result<(), Box<dyn Error>> {
9444        let temp = tempfile::tempdir()?;
9445        let atlas_dir = temp.path().join(".projectatlas");
9446        fs::create_dir_all(&atlas_dir)?;
9447        let source_path = temp.path().join("main.awk");
9448        fs::write(&source_path, "BEGIN { print \"atlas\" }\n")?;
9449        let inactive_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
9450        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
9451        let db_path = atlas_dir.join("projectatlas.db");
9452        let mut store = open_atlas_store_for_project(&db_path, &inactive_plan.root)?;
9453        run_scan_pipeline(&mut store, &inactive_plan, &symbol_options)?;
9454        if inactive_plan.scan_options.admit_optional_languages {
9455            return Err(io::Error::other(
9456                "inactive optional pack admitted catalog languages into the scan policy",
9457            )
9458            .into());
9459        }
9460        let inactive_optional = store
9461            .load_node_by_path("main.awk")?
9462            .ok_or_else(|| io::Error::other("inactive optional source node missing"))?;
9463        if inactive_optional.node.language.is_some() {
9464            return Err(io::Error::other(
9465                "inactive optional extension received a catalog language assignment",
9466            )
9467            .into());
9468        }
9469        let before = store
9470            .index_publication()?
9471            .ok_or_else(|| io::Error::other("initial publication missing"))?;
9472
9473        let selection_path = temp.path().join(repo_path_to_native(
9474            OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH,
9475        ));
9476        fs::write(
9477            &selection_path,
9478            serde_json::to_vec(&json!({
9479                "schema_version": 1,
9480                "pack_id": "broad-parser",
9481                "selected": {
9482                    "projectatlas_version": OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION,
9483                    "artifact": "a".repeat(64),
9484                }
9485            }))?,
9486        )?;
9487        let selected_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
9488        if !selected_plan.scan_options.admit_optional_languages {
9489            return Err(io::Error::other(
9490                "selected optional pack did not enable catalog language admission",
9491            )
9492            .into());
9493        }
9494        if inactive_plan.publication_contract_fingerprint()
9495            == selected_plan.publication_contract_fingerprint()
9496        {
9497            return Err(io::Error::other(
9498                "optional parser selection did not change the derivation contract",
9499            )
9500            .into());
9501        }
9502        if publication_contract_matches(&store, &selected_plan)? {
9503            return Err(io::Error::other(
9504                "selected optional artifact unexpectedly matched the inactive publication",
9505            )
9506            .into());
9507        }
9508        if !watch_path_requires_full_scan(temp.path(), &selection_path) {
9509            return Err(io::Error::other(
9510                "optional parser selection event did not require a full refresh",
9511            )
9512            .into());
9513        }
9514
9515        let mut changes = WatchChangeSet::default();
9516        changes.paths.insert(source_path);
9517        let result =
9518            refresh_index_for_changes(&mut store, &selected_plan, &changes, &symbol_options);
9519        if !matches!(result, Err(CliError::ParserPack(_))) {
9520            return Err(io::Error::other(
9521                "missing selected artifact did not fail before publication",
9522            )
9523            .into());
9524        }
9525        require_eq(
9526            &store
9527                .index_publication()?
9528                .ok_or_else(|| io::Error::other("publication disappeared after failure"))?
9529                .generation,
9530            &before.generation,
9531            "generation after selected optional artifact failure",
9532        )?;
9533
9534        fs::remove_file(selection_path)?;
9535        let disabled_plan = selected_plan.reload()?;
9536        if disabled_plan.scan_options.admit_optional_languages {
9537            return Err(io::Error::other(
9538                "disabled optional pack retained catalog language admission",
9539            )
9540            .into());
9541        }
9542        require_eq(
9543            &disabled_plan.publication_contract_fingerprint(),
9544            &inactive_plan.publication_contract_fingerprint(),
9545            "disabled optional parser derivation contract",
9546        )?;
9547
9548        let stale_graph = SymbolGraph {
9549            path: "main.awk".to_string(),
9550            language: Some("awk".to_string()),
9551            parser: ParserKind::Fallback,
9552            symbols: Vec::new(),
9553            relations: Vec::new(),
9554        };
9555        let stale_metadata = SourceParseMetadata {
9556            path: stale_graph.path.clone(),
9557            language: stale_graph.language.clone(),
9558            parser: ParserKind::TreeSitter,
9559            symbol_count: 0,
9560            relation_count: 0,
9561        };
9562        store.replace_symbol_graph_with_metadata(&stale_graph, &stale_metadata)?;
9563        let selected_publication = store.begin_index_publication_from(
9564            &selected_plan.publication_contract_fingerprint(),
9565            before.generation,
9566        )?;
9567        selected_publication.complete()?;
9568
9569        refresh_index(&mut store, &disabled_plan, &symbol_options)?;
9570        require_eq(
9571            &store.load_source_parse_metadata("main.awk")?,
9572            &None,
9573            "disabled optional parser metadata",
9574        )?;
9575        if !publication_contract_matches(&store, &disabled_plan)? {
9576            return Err(io::Error::other(
9577                "disabled optional parser refresh did not publish its derivation contract",
9578            )
9579            .into());
9580        }
9581        Ok(())
9582    }
9583
9584    #[test]
9585    fn symbol_projection_refresh_republishes_normalized_graph_at_one_generation()
9586    -> Result<(), Box<dyn Error>> {
9587        let temp = tempfile::tempdir()?;
9588        let atlas_dir = temp.path().join(".projectatlas");
9589        fs::create_dir_all(&atlas_dir)?;
9590        fs::write(
9591            temp.path().join("lib.rs"),
9592            "pub fn caller() { target(); }\npub fn target() {}\n",
9593        )?;
9594        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
9595        let mut store =
9596            open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
9597        refresh_index(
9598            &mut store,
9599            &plan,
9600            &SymbolBuildOptions::new(1_024, Some(1), None),
9601        )?;
9602        let before = store
9603            .index_publication()?
9604            .ok_or_else(|| io::Error::other("initial publication missing"))?;
9605        let relation_query = RepositoryGraphRelationQuery::Family {
9606            relation: GraphRelationKind::Legacy(RelationKind::Calls),
9607        };
9608        require_eq(
9609            &store
9610                .repository_graph_relations(relation_query.clone(), 10)?
9611                .rows
9612                .len(),
9613            &1,
9614            "initial normalized call relation",
9615        )?;
9616
9617        run_symbol_build_pipeline(
9618            &mut store,
9619            &plan,
9620            &SymbolBuildOptions::new(1, Some(1), None),
9621            None,
9622        )?;
9623
9624        let after = store
9625            .index_publication()?
9626            .ok_or_else(|| io::Error::other("symbol publication missing"))?;
9627        require_eq(
9628            &after.generation,
9629            &before
9630                .generation
9631                .checked_next()
9632                .ok_or_else(|| io::Error::other("publication generation overflowed"))?,
9633            "symbol and graph publication generation",
9634        )?;
9635        require_eq(
9636            &store
9637                .repository_graph_relations(relation_query, 10)?
9638                .rows
9639                .len(),
9640            &0,
9641            "cleared symbol relation projection",
9642        )?;
9643        let project = store
9644            .project_instance_id()?
9645            .ok_or_else(|| io::Error::other("project identity missing"))?;
9646        let path = RepositoryNodePath::new(Path::new("lib.rs"))?;
9647        let entities = store.repository_graph_entities_by_path(project, &path, 10)?;
9648        require_eq(
9649            &entities
9650                .rows
9651                .iter()
9652                .all(|entity| entity.generation() == after.generation),
9653            &true,
9654            "symbol refresh normalized graph generation",
9655        )?;
9656        Ok(())
9657    }
9658
9659    #[test]
9660    fn unchanged_full_and_incremental_refreshes_do_not_advance_generation()
9661    -> Result<(), Box<dyn Error>> {
9662        let temp = tempfile::tempdir()?;
9663        let atlas_dir = temp.path().join(".projectatlas");
9664        fs::create_dir_all(&atlas_dir)?;
9665        let source_path = temp.path().join("lib.rs");
9666        fs::write(&source_path, "fn stable() {}\n")?;
9667        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
9668        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
9669        let mut store =
9670            open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
9671        refresh_index(&mut store, &plan, &symbol_options)?;
9672        let normal_read_plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
9673        verify_index_publication(&store, &normal_read_plan)?;
9674        let before = store
9675            .index_publication()?
9676            .ok_or_else(|| io::Error::other("initial publication missing"))?;
9677        let project = store
9678            .project_instance_id()?
9679            .ok_or_else(|| io::Error::other("project identity missing"))?;
9680        let abandoned_full_stage = atlas_dir.join("graph-stage-full-noop");
9681        fs::create_dir(&abandoned_full_stage)?;
9682        drop(AtlasStore::create_repository_graph_staging(
9683            &abandoned_full_stage.join("projectatlas.db"),
9684            &plan.root,
9685            project,
9686        )?);
9687        let full_report = refresh_index(&mut store, &plan, &symbol_options)?;
9688        let after_full = store
9689            .index_publication()?
9690            .ok_or_else(|| io::Error::other("publication missing after full no-op refresh"))?;
9691        require_eq(
9692            &after_full.generation,
9693            &before.generation,
9694            "full no-op publication generation",
9695        )?;
9696        require_eq(
9697            &full_report.text_index.candidates,
9698            &0,
9699            "full no-op text candidates",
9700        )?;
9701        require_eq(
9702            &full_report.structural_summaries.candidates,
9703            &0,
9704            "full no-op summary candidates",
9705        )?;
9706        require_eq(
9707            &full_report.symbols.candidates,
9708            &0,
9709            "full no-op symbol candidates",
9710        )?;
9711        require_eq(
9712            &full_report.symbols.max_workers,
9713            &0,
9714            "full no-op symbol workers",
9715        )?;
9716        require_eq(
9717            &abandoned_full_stage.exists(),
9718            &false,
9719            "full no-op abandoned graph stage",
9720        )?;
9721        let abandoned_incremental_stage = atlas_dir.join("graph-stage-incremental-noop");
9722        fs::create_dir(&abandoned_incremental_stage)?;
9723        drop(AtlasStore::create_repository_graph_staging(
9724            &abandoned_incremental_stage.join("projectatlas.db"),
9725            &plan.root,
9726            project,
9727        )?);
9728        let mut changes = WatchChangeSet::default();
9729        changes.paths.insert(source_path);
9730
9731        let report = refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
9732        let after = store
9733            .index_publication()?
9734            .ok_or_else(|| io::Error::other("publication missing after no-op refresh"))?;
9735
9736        require_eq(
9737            &after.generation,
9738            &before.generation,
9739            "no-op publication generation",
9740        )?;
9741        require_eq(&report.text_index.candidates, &0, "no-op text candidates")?;
9742        require_eq(
9743            &report.structural_summaries.candidates,
9744            &0,
9745            "no-op summary candidates",
9746        )?;
9747        require_eq(&report.symbols.candidates, &0, "no-op symbol candidates")?;
9748        require_eq(&report.symbols.max_workers, &0, "no-op symbol workers")?;
9749        require_eq(
9750            &abandoned_incremental_stage.exists(),
9751            &false,
9752            "incremental no-op abandoned graph stage",
9753        )?;
9754        Ok(())
9755    }
9756
9757    #[test]
9758    fn watcher_preserves_explicit_full_refresh_guidance() -> Result<(), Box<dyn Error>> {
9759        let temp = tempfile::tempdir()?;
9760        fs::write(temp.path().join("source.rs"), "pub fn source() {}\n")?;
9761        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
9762        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
9763        let database = temp.path().join(".projectatlas/projectatlas.db");
9764        let mut store = open_atlas_store_for_project(&database, &plan.root)?;
9765        refresh_index(&mut store, &plan, &symbol_options)?;
9766        let before = store.index_publication()?;
9767
9768        let error =
9769            run_watch_with_polling_fallback(&mut store, &plan, 0, 1, &symbol_options, |_| {
9770                Err(CliError::RefreshRequired(Box::new(
9771                    index_policy_refresh_required(&plan.root),
9772                )))
9773            })
9774            .err()
9775            .ok_or_else(|| {
9776                io::Error::other("full-refresh guidance was hidden by polling fallback")
9777            })?;
9778        let CliError::RefreshRequired(report) = error else {
9779            return Err(io::Error::other(format!(
9780                "unexpected watcher error after full-refresh guidance: {error:?}"
9781            ))
9782            .into());
9783        };
9784        require_eq(
9785            &report.scope,
9786            &IndexRefreshScope::Full,
9787            "watcher full-refresh scope",
9788        )?;
9789        require_eq(
9790            &store.index_publication()?,
9791            &before,
9792            "watcher generation after full-refresh guidance",
9793        )?;
9794        Ok(())
9795    }
9796
9797    #[test]
9798    fn canceled_watcher_batch_preserves_last_valid_and_retries_one_generation()
9799    -> Result<(), Box<dyn Error>> {
9800        let temp = tempfile::tempdir()?;
9801        let atlas_dir = temp.path().join(".projectatlas");
9802        fs::create_dir_all(&atlas_dir)?;
9803        let reserved_purpose_path = atlas_dir.join("projectatlas-nonsource-files.toon");
9804        fs::write(&reserved_purpose_path, "nonsource_files[]:\n")?;
9805        let changed_path = temp.path().join("changed.rs");
9806        let deleted_path = temp.path().join("deleted.rs");
9807        let deleted_dir = temp.path().join("deleted");
9808        let deleted_descendant = deleted_dir.join("descendant.rs");
9809        fs::create_dir(&deleted_dir)?;
9810        fs::write(&changed_path, "pub fn before() {}\n")?;
9811        fs::write(&deleted_path, "pub fn removed() {}\n")?;
9812        fs::write(&deleted_descendant, "pub fn descendant() {}\n")?;
9813        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
9814        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
9815        let db_path = atlas_dir.join("projectatlas.db");
9816        let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
9817        refresh_index(&mut store, &plan, &symbol_options)?;
9818        let before = store
9819            .index_publication()?
9820            .ok_or_else(|| io::Error::other("initial publication missing"))?;
9821        let before_node = store
9822            .load_node_by_path("changed.rs")?
9823            .ok_or_else(|| io::Error::other("initial changed node missing"))?;
9824        let reviewed_reserved_purpose = "Describe reviewed non-source atlas responsibilities.";
9825        store.set_purpose(
9826            ".projectatlas/projectatlas-nonsource-files.toon",
9827            reviewed_reserved_purpose,
9828            PurposeSource::Agent,
9829        )?;
9830        let old_reader = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
9831        require_eq(
9832            &old_reader
9833                .index_publication()?
9834                .as_ref()
9835                .map(|state| state.generation),
9836            &Some(before.generation),
9837            "old reader generation before staged publication",
9838        )?;
9839        let old_text = old_reader
9840            .load_file_text("changed.rs")?
9841            .ok_or_else(|| io::Error::other("old reader text missing"))?;
9842
9843        fs::write(&changed_path, "pub fn after() {}\n")?;
9844        fs::remove_file(&deleted_path)?;
9845        fs::remove_dir_all(&deleted_dir)?;
9846        fs::write(&reserved_purpose_path, "nonsource_files[]:\n\n")?;
9847        let preparation_control = standalone_index_work_control();
9848        let staged_batch = stage_full_index_publication(
9849            &store,
9850            &plan,
9851            &symbol_options,
9852            true,
9853            false,
9854            &preparation_control,
9855        )?;
9856        revalidate_staged_publication_inputs_controlled(
9857            &plan,
9858            staged_batch.nodes.expected_nodes(),
9859            None,
9860            &preparation_control,
9861        )?;
9862        let IndexPublicationBatch {
9863            base_generation,
9864            contract_fingerprint,
9865            root,
9866            nodes,
9867            purpose_import: _,
9868            text_paths,
9869            text,
9870            symbols: _,
9871            graph: _,
9872            structural_summaries: _,
9873        } = staged_batch;
9874        let mut staged =
9875            store.begin_index_publication_from(&contract_fingerprint, base_generation)?;
9876        staged.set_project_root(&root)?;
9877        let NodePublicationBatch::Full { nodes } = nodes else {
9878            return Err(io::Error::other("full staging returned an incremental batch").into());
9879        };
9880        staged.begin_scan_replacement()?;
9881        for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
9882            staged.upsert_scan_node_batch(batch)?;
9883        }
9884        staged.finish_scan_replacement()?;
9885        let late_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
9886        late_cancel.cancel();
9887        let late_result = apply_text_index_stage(&mut staged, &text_paths, &text, &late_cancel);
9888        if !matches!(
9889            late_result,
9890            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
9891                stage: IndexWorkStage::Publication,
9892            }))
9893        ) {
9894            return Err(io::Error::other("late cancellation did not stop publication").into());
9895        }
9896        drop(staged);
9897        require_eq(
9898            &store
9899                .index_publication()?
9900                .as_ref()
9901                .map(|state| state.generation),
9902            &Some(before.generation),
9903            "generation after canceled publication",
9904        )?;
9905        require_eq(
9906            &store.load_node_by_path("changed.rs")?,
9907            &Some(before_node),
9908            "last-valid node after canceled publication",
9909        )?;
9910        let mut changes = WatchChangeSet::default();
9911        changes.paths.insert(changed_path);
9912        changes.paths.insert(deleted_path);
9913        changes.paths.insert(deleted_dir);
9914        changes.paths.insert(deleted_descendant);
9915        changes.paths.insert(reserved_purpose_path);
9916        let fallback_report =
9917            run_watch_with_polling_fallback(&mut store, &plan, 0, 1, &symbol_options, |store| {
9918                let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
9919                canceled.cancel();
9920                let Err(error) = refresh_index_for_changes_controlled(
9921                    store,
9922                    &plan,
9923                    &changes,
9924                    &symbol_options,
9925                    &canceled,
9926                ) else {
9927                    return Err(CliError::InvalidInput(
9928                        "canceled watcher batch unexpectedly succeeded".to_string(),
9929                    ));
9930                };
9931                let generation = store.index_publication()?.map(|state| state.generation);
9932                if generation != Some(before.generation) {
9933                    return Err(CliError::InvalidInput(
9934                        "canceled watcher batch advanced publication before fallback".to_string(),
9935                    ));
9936                }
9937                Err(error)
9938            })?;
9939        require_eq(
9940            &fallback_report.mode.as_str(),
9941            &WATCH_MODE_POLLING,
9942            "canceled notify batch fallback mode",
9943        )?;
9944        if fallback_report
9945            .fallback_reason
9946            .as_deref()
9947            .is_none_or(|reason| !reason.contains("canceled"))
9948        {
9949            return Err(io::Error::other(
9950                "polling fallback did not retain the notify failure reason",
9951            )
9952            .into());
9953        }
9954
9955        let after = store
9956            .index_publication()?
9957            .ok_or_else(|| io::Error::other("incremental publication missing"))?;
9958        require_eq(
9959            &after.generation,
9960            &before
9961                .generation
9962                .checked_next()
9963                .ok_or_else(|| io::Error::other("test generation overflowed"))?,
9964            "one incremental publication generation",
9965        )?;
9966        require_eq(
9967            &store.load_node_by_path("deleted.rs")?.is_none(),
9968            &true,
9969            "deleted path is absent",
9970        )?;
9971        require_eq(
9972            &store
9973                .load_symbols(Some("deleted.rs"), Some("removed"), 10)?
9974                .is_empty(),
9975            &true,
9976            "deleted symbols are invalidated",
9977        )?;
9978        require_eq(
9979            &store.load_node_by_path("deleted/descendant.rs")?.is_none(),
9980            &true,
9981            "deleted descendant path is absent",
9982        )?;
9983        require_eq(
9984            &store
9985                .load_symbols(Some("deleted/descendant.rs"), Some("descendant"), 10)?
9986                .is_empty(),
9987            &true,
9988            "deleted descendant symbols are invalidated",
9989        )?;
9990        require_eq(
9991            &store
9992                .load_symbols(Some("changed.rs"), Some("after"), 10)?
9993                .len(),
9994            &1,
9995            "changed symbols are published",
9996        )?;
9997        require_eq(
9998            &store
9999                .load_symbols(Some("changed.rs"), Some("before"), 10)?
10000                .is_empty(),
10001            &true,
10002            "replaced symbols are invalidated",
10003        )?;
10004        let reserved = store
10005            .load_node_by_path(".projectatlas/projectatlas-nonsource-files.toon")?
10006            .ok_or_else(|| io::Error::other("reserved metadata node missing"))?;
10007        require_eq(
10008            &reserved.purpose.purpose.as_deref(),
10009            &Some(reviewed_reserved_purpose),
10010            "stale reviewed built-in purpose text",
10011        )?;
10012        require_eq(
10013            &reserved.purpose.status,
10014            &PurposeStatus::Approved,
10015            "reviewed built-in purpose state",
10016        )?;
10017        require_eq(
10018            &old_reader
10019                .index_publication()?
10020                .as_ref()
10021                .map(|state| state.generation),
10022            &Some(before.generation),
10023            "old reader remains on the complete prior generation",
10024        )?;
10025        require_eq(
10026            &old_reader
10027                .load_file_text("changed.rs")?
10028                .map(|text| text.content),
10029            &Some(old_text.content),
10030            "old reader remains on prior source text",
10031        )?;
10032        let new_reader = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
10033        require_eq(
10034            &new_reader
10035                .index_publication()?
10036                .as_ref()
10037                .map(|state| state.generation),
10038            &Some(after.generation),
10039            "new reader sees the complete replacement generation",
10040        )?;
10041        require_eq(
10042            &new_reader
10043                .load_file_text("changed.rs")?
10044                .map(|text| text.content),
10045            &Some("pub fn after() {}\n".to_string()),
10046            "new reader sees replacement source text",
10047        )?;
10048        new_reader.finish_index_read_snapshot()?;
10049        old_reader.finish_index_read_snapshot()?;
10050        Ok(())
10051    }
10052
10053    #[test]
10054    fn one_sided_notify_rename_events_require_full_verification() -> Result<(), Box<dyn Error>> {
10055        let temp = tempfile::tempdir()?;
10056        let old_path = temp.path().join("old.rs");
10057        let new_path = temp.path().join("new.rs");
10058        for (mode, path) in [
10059            (notify::event::RenameMode::From, old_path),
10060            (notify::event::RenameMode::To, new_path),
10061        ] {
10062            let event =
10063                Event::new(EventKind::Modify(notify::event::ModifyKind::Name(mode))).add_path(path);
10064            let changes = notify_event_changes(temp.path(), &ScanOptions::default(), &event);
10065            require_eq(
10066                &changes.requires_full_scan,
10067                &true,
10068                "one-sided rename full verification",
10069            )?;
10070        }
10071        Ok(())
10072    }
10073
10074    #[test]
10075    fn notify_rescan_and_ignored_policy_events_require_full_verification()
10076    -> Result<(), Box<dyn Error>> {
10077        let temp = tempfile::tempdir()?;
10078        fs::create_dir_all(temp.path().join(".projectatlas"))?;
10079        fs::write(temp.path().join(".gitignore"), ".projectatlas/\n")?;
10080        let config = temp.path().join(".projectatlas/config.toml");
10081        fs::write(&config, "[project]\nroot = \".\"\n")?;
10082        let config_event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
10083            notify::event::DataChange::Content,
10084        )))
10085        .add_path(config.clone());
10086        let config_changes =
10087            notify_event_changes(temp.path(), &ScanOptions::default(), &config_event);
10088        require_eq(
10089            &config_changes.requires_full_scan,
10090            &true,
10091            "ignored ProjectAtlas config full verification",
10092        )?;
10093        require_eq(
10094            &config_changes.paths.contains(&config),
10095            &true,
10096            "ignored ProjectAtlas config event path",
10097        )?;
10098
10099        let rescan_event = Event::new(EventKind::Any).set_flag(notify::event::Flag::Rescan);
10100        let rescan_changes =
10101            notify_event_changes(temp.path(), &ScanOptions::default(), &rescan_event);
10102        require_eq(
10103            &rescan_changes.requires_full_scan,
10104            &true,
10105            "backend rescan flag full verification",
10106        )?;
10107        Ok(())
10108    }
10109
10110    #[test]
10111    fn directory_only_deletion_re_resolves_external_inbound_callers() -> Result<(), Box<dyn Error>>
10112    {
10113        let temp = tempfile::tempdir()?;
10114        let atlas_dir = temp.path().join(".projectatlas");
10115        let source_dir = temp.path().join("src");
10116        let removed_dir = source_dir.join("removed");
10117        fs::create_dir_all(&atlas_dir)?;
10118        fs::create_dir_all(&removed_dir)?;
10119        fs::write(
10120            source_dir.join("caller.rs"),
10121            "pub fn caller() { target(); }\n",
10122        )?;
10123        fs::write(removed_dir.join("target.rs"), "pub fn target() {}\n")?;
10124
10125        let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
10126        let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
10127        let mut store =
10128            open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
10129        refresh_index(&mut store, &plan, &symbol_options)?;
10130        let before = store
10131            .index_publication()?
10132            .ok_or_else(|| io::Error::other("initial publication missing"))?;
10133        require_caller_resolution(
10134            &store,
10135            "src/caller.rs",
10136            |resolution| matches!(resolution, RelationResolution::Resolved { .. }),
10137            "initial caller resolution",
10138        )?;
10139
10140        fs::remove_dir_all(&removed_dir)?;
10141        let mut changes = WatchChangeSet::default();
10142        changes.paths.insert(removed_dir);
10143        refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
10144
10145        let after = store
10146            .index_publication()?
10147            .ok_or_else(|| io::Error::other("replacement publication missing"))?;
10148        require_eq(
10149            &after.generation,
10150            &before
10151                .generation
10152                .checked_next()
10153                .ok_or_else(|| io::Error::other("test generation overflowed"))?,
10154            "directory deletion generation",
10155        )?;
10156        require_eq(
10157            &store.load_node_by_path("src/removed/target.rs")?.is_none(),
10158            &true,
10159            "deleted descendant node",
10160        )?;
10161        require_eq(
10162            &store
10163                .load_symbols(Some("src/removed/target.rs"), Some("target"), 10)?
10164                .is_empty(),
10165            &true,
10166            "deleted descendant symbol",
10167        )?;
10168        require_caller_resolution(
10169            &store,
10170            "src/caller.rs",
10171            |resolution| matches!(resolution, RelationResolution::Unresolved { .. }),
10172            "caller resolution after directory deletion",
10173        )?;
10174        Ok(())
10175    }
10176
10177    fn require_caller_resolution(
10178        store: &AtlasStore,
10179        caller_path: &str,
10180        expected: impl FnOnce(&RelationResolution) -> bool,
10181        label: &str,
10182    ) -> Result<(), Box<dyn Error>> {
10183        let project = store
10184            .project_instance_id()?
10185            .ok_or_else(|| io::Error::other("project identity missing"))?;
10186        let caller_path = RepositoryNodePath::new(Path::new(caller_path))?;
10187        let caller_entities =
10188            store.repository_graph_entities_by_path(project, &caller_path, 100)?;
10189        let relations = store.repository_graph_relations(
10190            RepositoryGraphRelationQuery::Family {
10191                relation: GraphRelationKind::Legacy(RelationKind::Calls),
10192            },
10193            100,
10194        )?;
10195        let mut matching = relations.rows.iter().filter(|relation| {
10196            caller_entities
10197                .rows
10198                .iter()
10199                .any(|entity| entity.key() == relation.source())
10200        });
10201        let relation = matching
10202            .next()
10203            .ok_or_else(|| io::Error::other(format!("{label}: caller relation missing")))?;
10204        if matching.next().is_some() {
10205            return Err(io::Error::other(format!("{label}: multiple caller relations")).into());
10206        }
10207        if !expected(relation.resolution()) {
10208            return Err(io::Error::other(format!(
10209                "{label}: unexpected resolution {:?}",
10210                relation.resolution()
10211            ))
10212            .into());
10213        }
10214        Ok(())
10215    }
10216
10217    #[test]
10218    fn purpose_curator_handoff_applies_one_stale_safe_batch() -> Result<(), Box<dyn Error>> {
10219        let temp = tempfile::tempdir()?;
10220        let root = temp.path().join("repository");
10221        fs::create_dir(&root)?;
10222        let database = temp.path().join("projectatlas.db");
10223        let mut store = AtlasStore::open_for_project(&database, &root)?;
10224        store.replace_scan(&[
10225            Node {
10226                path: "src/main.rs".to_string(),
10227                kind: NodeKind::File,
10228                parent_path: Some("src".to_string()),
10229                extension: Some(".rs".to_string()),
10230                language: Some("rust".to_string()),
10231                size_bytes: Some(12),
10232                mtime_ns: Some(10),
10233                content_hash: Some("hash-main".to_string()),
10234            },
10235            Node {
10236                path: "src/detail.rs".to_string(),
10237                kind: NodeKind::File,
10238                parent_path: Some("src".to_string()),
10239                extension: Some(".rs".to_string()),
10240                language: Some("rust".to_string()),
10241                size_bytes: Some(12),
10242                mtime_ns: Some(10),
10243                content_hash: Some("hash-detail".to_string()),
10244            },
10245        ])?;
10246        store.set_suggested_purpose("src/detail.rs", "Generated detail suggestion")?;
10247        let task = "runtime-purpose-curator";
10248        let page = purpose_curation_page(
10249            &store,
10250            &HealthQuery {
10251                start_index: 0,
10252                limit: 20,
10253                category: None,
10254                severity: Some(Severity::Warning),
10255                path_prefix: None,
10256                summary_only: false,
10257                scope: HealthScope::all(),
10258            },
10259            task,
10260        )?;
10261        require_eq(&page.actionable, &true, "actionable queue")?;
10262        require_eq(&page.items.len(), &2, "queue item count")?;
10263        require_eq(&page.task, &task.to_string(), "queue task")?;
10264        let requests = page
10265            .items
10266            .iter()
10267            .map(|item| PurposeReviewRequest {
10268                path: item.path.clone(),
10269                purpose: Some(format!("Reviewed purpose for {}", item.path)),
10270                confirm_existing: false,
10271                task: Some(page.task.clone()),
10272                work_key: Some(item.work_key.clone()),
10273                state_token: Some(item.state_token.clone()),
10274            })
10275            .collect::<Vec<_>>();
10276        let handoff = purpose_curator_handoff(page);
10277        require_eq(
10278            &handoff.execution_owner,
10279            &"agent_host",
10280            "host-owned execution",
10281        )?;
10282        require_eq(
10283            &handoff.server_started_curator,
10284            &false,
10285            "no server-started curator",
10286        )?;
10287        require_eq(
10288            &handoff.recommended_subagent_reasoning,
10289            &"lowest_host_enforced",
10290            "lowest host-enforced reasoning",
10291        )?;
10292        require_eq(&handoff.main_agent_fallback, &true, "main-agent fallback")?;
10293
10294        let mixed = vec![
10295            requests[0].clone(),
10296            PurposeReviewRequest {
10297                path: requests[1].path.clone(),
10298                purpose: Some("Explicit correction must stay separate".to_string()),
10299                confirm_existing: false,
10300                task: None,
10301                work_key: None,
10302                state_token: None,
10303            },
10304        ];
10305        match review_purposes(&store, &mixed, true) {
10306            Err(CliError::InvalidInput(_)) => {}
10307            Err(error) => {
10308                return Err(io::Error::other(format!(
10309                    "mixed purpose batch returned the wrong error: {error}"
10310                ))
10311                .into());
10312            }
10313            Ok(_) => return Err(io::Error::other("mixed purpose batch was accepted").into()),
10314        }
10315        let mut partial = requests[1].clone();
10316        partial.state_token = None;
10317        match review_purposes(&store, &[requests[0].clone(), partial], true) {
10318            Err(CliError::InvalidInput(_)) => {}
10319            Err(error) => {
10320                return Err(io::Error::other(format!(
10321                    "partial conditional batch returned the wrong error: {error}"
10322                ))
10323                .into());
10324            }
10325            Ok(_) => {
10326                return Err(io::Error::other("partial conditional batch was accepted").into());
10327            }
10328        }
10329        drop(store);
10330        store = AtlasStore::open_for_project(&database, &root)?;
10331        let unchanged =
10332            store.load_nodes_by_paths(&["src/main.rs".to_string(), "src/detail.rs".to_string()])?;
10333        require_eq(
10334            &unchanged.iter().all(|node| !node.purpose.agent_reviewed()),
10335            &true,
10336            "rejected batch left every purpose unapproved after reopen",
10337        )?;
10338
10339        let applied = review_purposes(&store, &requests, true)?;
10340        require_eq(&applied.changed, &2, "conditional batch changed count")?;
10341        require_eq(&applied.conflicts, &0, "conditional batch conflicts")?;
10342        require_eq(
10343            &applied
10344                .items
10345                .iter()
10346                .all(|item| item.action == PurposeReviewAction::Review),
10347            &true,
10348            "conditional batch actions",
10349        )?;
10350
10351        let repeated = review_purposes(&store, &requests, true)?;
10352        require_eq(&repeated.changed, &0, "accepted repeat changed count")?;
10353        require_eq(&repeated.conflicts, &2, "accepted repeat conflicts")?;
10354        require_eq(
10355            &repeated
10356                .items
10357                .iter()
10358                .all(|item| item.action == PurposeReviewAction::Accepted),
10359            &true,
10360            "accepted repeat actions",
10361        )?;
10362        let empty = purpose_curation_page(
10363            &store,
10364            &HealthQuery {
10365                start_index: 0,
10366                limit: 20,
10367                category: None,
10368                severity: Some(Severity::Warning),
10369                path_prefix: None,
10370                summary_only: false,
10371                scope: HealthScope::all(),
10372            },
10373            task,
10374        )?;
10375        require_eq(&empty.actionable, &false, "accepted queue is quiet")?;
10376
10377        let correction = review_purposes(
10378            &store,
10379            &[PurposeReviewRequest {
10380                path: "src/main.rs".to_string(),
10381                purpose: Some("Explicit corrected purpose".to_string()),
10382                confirm_existing: false,
10383                task: None,
10384                work_key: None,
10385                state_token: None,
10386            }],
10387            true,
10388        )?;
10389        require_eq(
10390            &correction.items[0].action,
10391            &PurposeReviewAction::Review,
10392            "explicit correction action",
10393        )?;
10394        let corrected = store
10395            .load_node_by_path("src/main.rs")?
10396            .ok_or_else(|| io::Error::other("corrected runtime path disappeared"))?;
10397        require_eq(
10398            &corrected.purpose.purpose.as_deref(),
10399            &Some("Explicit corrected purpose"),
10400            "explicit correction value",
10401        )?;
10402        Ok(())
10403    }
10404
10405    #[test]
10406    fn purpose_review_admission_bounds_input_and_prevents_partial_apply()
10407    -> Result<(), Box<dyn Error>> {
10408        let temp = tempfile::tempdir()?;
10409        let root = temp.path().join("repository");
10410        fs::create_dir(&root)?;
10411        let database = temp.path().join("projectatlas.db");
10412        let mut store = AtlasStore::open_for_project(&database, &root)?;
10413        store.replace_scan(&[
10414            Node {
10415                path: "src/first.rs".to_string(),
10416                kind: NodeKind::File,
10417                parent_path: Some("src".to_string()),
10418                extension: Some(".rs".to_string()),
10419                language: Some("rust".to_string()),
10420                size_bytes: Some(12),
10421                mtime_ns: Some(10),
10422                content_hash: Some("hash-first".to_string()),
10423            },
10424            Node {
10425                path: "src/second.rs".to_string(),
10426                kind: NodeKind::File,
10427                parent_path: Some("src".to_string()),
10428                extension: Some(".rs".to_string()),
10429                language: Some("rust".to_string()),
10430                size_bytes: Some(12),
10431                mtime_ns: Some(10),
10432                content_hash: Some("hash-second".to_string()),
10433            },
10434        ])?;
10435        store.set_suggested_purpose("src/first.rs", "Generated first purpose")?;
10436        store.set_purpose(
10437            "src/second.rs",
10438            &"x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES + 1),
10439            PurposeSource::Imported,
10440        )?;
10441
10442        let valid_first = PurposeReviewRequest {
10443            path: "src/first.rs".to_string(),
10444            purpose: Some("Reviewed café λ purpose".to_string()),
10445            confirm_existing: false,
10446            task: None,
10447            work_key: None,
10448            state_token: None,
10449        };
10450        let oversized_report = PurposeReviewRequest {
10451            path: "src/second.rs".to_string(),
10452            purpose: None,
10453            confirm_existing: true,
10454            task: None,
10455            work_key: None,
10456            state_token: None,
10457        };
10458        let Err(error) = review_purposes(&store, &[valid_first.clone(), oversized_report], true)
10459        else {
10460            return Err(io::Error::other(
10461                "oversized retained report field was accepted before apply",
10462            )
10463            .into());
10464        };
10465        require_eq(
10466            &error
10467                .to_string()
10468                .contains("purpose review report field purpose"),
10469            &true,
10470            "oversized report field error",
10471        )?;
10472        let unchanged = store
10473            .load_node_by_path("src/first.rs")?
10474            .ok_or_else(|| io::Error::other("first review fixture disappeared"))?;
10475        require_eq(
10476            &unchanged.purpose.agent_reviewed(),
10477            &false,
10478            "report admission failure prevented partial apply",
10479        )?;
10480
10481        let oversized_field = PurposeReviewRequest {
10482            purpose: Some("x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES + 1)),
10483            ..valid_first.clone()
10484        };
10485        let Err(error) = review_purposes(&store, &[oversized_field], true) else {
10486            return Err(io::Error::other("oversized request field passed admission").into());
10487        };
10488        require_eq(
10489            &error.to_string().contains("field purpose"),
10490            &true,
10491            "oversized input field error",
10492        )?;
10493
10494        let too_many = vec![valid_first.clone(); MAX_PURPOSE_CURATION_BATCH_ROWS + 1];
10495        let Err(error) = review_purposes(&store, &too_many, true) else {
10496            return Err(io::Error::other("oversized request count passed admission").into());
10497        };
10498        require_eq(
10499            &error.to_string().contains("maximum is 200"),
10500            &true,
10501            "oversized item count error",
10502        )?;
10503
10504        let aggregate = (0..9)
10505            .map(|index| PurposeReviewRequest {
10506                path: format!("src/{index}.rs"),
10507                purpose: Some("x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES)),
10508                confirm_existing: false,
10509                task: None,
10510                work_key: None,
10511                state_token: None,
10512            })
10513            .collect::<Vec<_>>();
10514        let Err(error) = review_purposes(&store, &aggregate, false) else {
10515            return Err(io::Error::other("oversized aggregate request passed admission").into());
10516        };
10517        require_eq(
10518            &error.to_string().contains("aggregate string bytes"),
10519            &true,
10520            "oversized aggregate input error",
10521        )?;
10522
10523        let preview = review_purposes(&store, &[valid_first], false)?;
10524        require_eq(
10525            &preview.items[0].purpose,
10526            &"Reviewed café λ purpose".to_string(),
10527            "UTF-8 purpose compatibility",
10528        )?;
10529        require_eq(
10530            &render_purpose_review_report(&preview).contains("Reviewed café λ purpose"),
10531            &true,
10532            "UTF-8 TOON compatibility",
10533        )?;
10534        Ok(())
10535    }
10536
10537    #[test]
10538    fn indexed_navigation_read_rejects_stale_or_oversized_source_before_allocation()
10539    -> Result<(), Box<dyn Error>> {
10540        let temp = tempfile::tempdir()?;
10541        let root = temp.path().join("repository");
10542        let source_dir = root.join("src");
10543        fs::create_dir_all(&source_dir)?;
10544        let source = source_dir.join("large.rs");
10545        fs::File::create(&source)?.set_len(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1)?;
10546        let database = temp.path().join("projectatlas.db");
10547        let mut store = AtlasStore::open_for_project(&database, &root)?;
10548        store.replace_scan(&[Node {
10549            path: "src/large.rs".to_string(),
10550            kind: NodeKind::File,
10551            parent_path: Some("src".to_string()),
10552            extension: Some(".rs".to_string()),
10553            language: Some("rust".to_string()),
10554            size_bytes: Some(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1),
10555            mtime_ns: Some(10),
10556            content_hash: Some("unused-oversized-hash".to_string()),
10557        }])?;
10558
10559        let Err(error) = read_indexed_file_content(&store, "src/large.rs") else {
10560            return Err(
10561                io::Error::other("oversized indexed source was allocated and accepted").into(),
10562            );
10563        };
10564        let CliError::VerificationIncomplete(details) = error else {
10565            return Err(io::Error::other("oversized source returned the wrong error type").into());
10566        };
10567        require_eq(
10568            &details.reason,
10569            &IndexVerificationReason::SourceTooLarge,
10570            "oversized source reason",
10571        )?;
10572
10573        fs::File::create(&source)?.set_len(1)?;
10574        let Err(error) = read_indexed_file_content(&store, "src/large.rs") else {
10575            return Err(io::Error::other("changed source size did not require refresh").into());
10576        };
10577        let CliError::RefreshRequired(details) = error else {
10578            return Err(
10579                io::Error::other("changed source size returned the wrong error type").into(),
10580            );
10581        };
10582        require_eq(
10583            &details.reason,
10584            &IndexRefreshReason::SourceChanged,
10585            "changed source size reason",
10586        )?;
10587        Ok(())
10588    }
10589
10590    #[cfg(windows)]
10591    #[test]
10592    fn extended_windows_watch_roots_keep_deleted_paths_in_scope() -> Result<(), Box<dyn Error>> {
10593        let root = Path::new(r"\\?\C:\repo");
10594        let deleted = Path::new(r"C:\repo\src\deleted.rs");
10595        require_eq(
10596            &normalized_deleted_path(root, deleted)?,
10597            &Some("src/deleted.rs".to_string()),
10598            "extended root deleted path",
10599        )?;
10600        Ok(())
10601    }
10602
10603    #[cfg(windows)]
10604    #[test]
10605    fn notify_events_normalize_unicode_paths_against_extended_windows_roots()
10606    -> Result<(), Box<dyn Error>> {
10607        let temp = tempfile::tempdir()?;
10608        let source_dir = temp.path().join("src");
10609        fs::create_dir(&source_dir)?;
10610        let deleted = source_dir.join("Über.rs");
10611        fs::write(&deleted, "pub fn before() {}\n")?;
10612        fs::remove_file(&deleted)?;
10613
10614        let root_text = temp
10615            .path()
10616            .to_str()
10617            .ok_or_else(|| io::Error::other("temporary path is not UTF-8"))?;
10618        let extended_root = if root_text.starts_with(r"\\?\") {
10619            temp.path().to_path_buf()
10620        } else {
10621            PathBuf::from(format!(r"\\?\{root_text}"))
10622        };
10623        let event = Event::new(EventKind::Remove(notify::event::RemoveKind::File))
10624            .add_path(deleted.clone());
10625        let changes = notify_event_changes(&extended_root, &ScanOptions::default(), &event);
10626
10627        require_eq(
10628            &changes.paths.contains(&deleted),
10629            &true,
10630            "native Unicode watcher path",
10631        )?;
10632        require_eq(
10633            &changes.requires_full_scan,
10634            &false,
10635            "source removal full-scan policy",
10636        )?;
10637        require_eq(
10638            &normalized_deleted_path(&extended_root, &deleted)?,
10639            &Some("src/Über.rs".to_string()),
10640            "normalized Unicode deleted path",
10641        )?;
10642        Ok(())
10643    }
10644
10645    #[cfg(unix)]
10646    #[test]
10647    fn notify_events_preserve_native_backslash_paths_on_unix() -> Result<(), Box<dyn Error>> {
10648        let temp = tempfile::tempdir()?;
10649        let source = temp.path().join(r"src\generated.rs");
10650        fs::write(&source, "pub fn generated() {}\n")?;
10651        let event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
10652            notify::event::DataChange::Content,
10653        )))
10654        .add_path(source.clone());
10655
10656        let changes = notify_event_changes(temp.path(), &ScanOptions::default(), &event);
10657
10658        require_eq(
10659            &changes.paths,
10660            &HashSet::from([source]),
10661            "native Unix watcher paths",
10662        )?;
10663        Ok(())
10664    }
10665
10666    /// Require equal test values without panicking from a fallible test.
10667    fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
10668    where
10669        T: Debug + PartialEq,
10670    {
10671        if actual == expected {
10672            Ok(())
10673        } else {
10674            Err(io::Error::other(format!(
10675                "{label} mismatch: expected {expected:?}, got {actual:?}"
10676            ))
10677            .into())
10678        }
10679    }
10680}