Skip to main content

projectatlas_db/
lib.rs

1//! Purpose: Persist `ProjectAtlas` 3 indexes in `SQLite`.
2
3mod content_classification;
4mod derived_snapshot;
5mod diagnostics;
6mod hydration;
7mod legacy_relations;
8mod project_identity;
9mod repository_graph;
10mod schema;
11mod sqlite_profile;
12mod telemetry;
13mod worktree_registry;
14
15pub use content_classification::{
16    FileContentClassification, FileContentClassificationPage,
17    MAX_FILE_CONTENT_CLASSIFICATION_PAGE_ROWS, MAX_FILE_CONTENT_CLASSIFICATION_PATHS,
18};
19pub use derived_snapshot::{
20    DerivedGraphSnapshot, DerivedGraphSnapshotImport, DerivedGraphSnapshotMetadata,
21    DerivedSnapshotContent, MAX_DERIVED_SNAPSHOT_JSON_BYTES,
22};
23pub use diagnostics::{
24    DatabaseCoverageSample, DatabaseCoverageSummary, DatabaseCoverageTotalState,
25    DatabaseFilesystemSupport, DatabaseOperatingProfileReport, DatabasePublicationContractState,
26    DatabasePublicationReport, DatabaseSchemaCompatibility, DatabaseSchemaReport,
27    DatabaseSettingsReport, SqliteCompileOptionsIdentity, SqliteRuntimeReport,
28    database_settings_report,
29};
30pub use hydration::{
31    PreparedWorktreeHydrationCandidate, WorktreeHydrationActivation, WorktreeHydrationCandidate,
32};
33pub use legacy_relations::{
34    LegacyRelationRead, LegacyRelationReadBudget, LegacyRelationReadLimit, LegacyRelationReadWork,
35};
36pub use project_identity::{ProjectRootTransition, ProjectRootTransitionResult};
37pub use repository_graph::{
38    MAX_REPOSITORY_GRAPH_FRONTIER, RepositoryAffectedSourceFootprint, RepositoryCoverageQuery,
39    RepositoryCoverageRow, RepositoryGraphAdjacencyContinuation, RepositoryGraphAdjacencyPage,
40    RepositoryGraphAdjacencyReadPage, RepositoryGraphAdjacencyRow,
41    RepositoryGraphClassifiedRelationRow, RepositoryGraphDirection, RepositoryGraphPage,
42    RepositoryGraphReadBatch, RepositoryGraphReadBudget, RepositoryGraphReadPage,
43    RepositoryGraphReadPages, RepositoryGraphReadWork, RepositoryGraphRelationQuery,
44    RepositoryGraphRelationRow, RepositoryGraphStagingGuard, RepositoryNavigationConnections,
45    RepositoryNavigationNode, RepositoryResolutionCandidate, RepositoryResolutionReadPage,
46};
47pub use sqlite_profile::validate_database_location;
48pub use telemetry::{
49    PlannerStatisticsPolicy, PlannerStatisticsState, SpillCleanupState, TelemetryCheckpointState,
50    TelemetryRetentionPolicy, TelemetryRetentionState, WorktreeUsageSnapshot,
51    WorktreeUsageSyncState,
52};
53pub use worktree_registry::{
54    ActiveWorktreeRegistrationGuard, MAIN_WORKTREE_ALIAS, MAX_WORKTREE_ALIAS_BYTES, WorktreeAlias,
55    WorktreeRegistration, WorktreeRegistrationState,
56};
57
58use blake3::Hasher;
59use projectatlas_core::graph::{GraphContractError, ProjectInstanceId};
60use projectatlas_core::health::{
61    CATEGORY_DUPLICATE_PURPOSE, CATEGORY_MISSING_PURPOSE, CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
62    CATEGORY_REPEATED_TEMPORARY_FOLDER, CATEGORY_STALE_PURPOSE, CATEGORY_SUGGESTED_PURPOSE_REVIEW,
63    HealthFinding, MESSAGE_MISSING_PURPOSE, MESSAGE_PURPOSE_AGENT_REVIEW_REQUIRED,
64    MESSAGE_STALE_PURPOSE, MESSAGE_SUGGESTED_PURPOSE_REVIEW, RECOMMENDATION_DUPLICATE_PURPOSE,
65    RECOMMENDATION_MISSING_PURPOSE_QUEUE, RECOMMENDATION_PURPOSE_AGENT_REVIEW_REQUIRED,
66    RECOMMENDATION_REPEATED_TEMPORARY_FOLDER, RECOMMENDATION_STALE_PURPOSE,
67    RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW_QUEUE, STRUCTURAL_HEALTH_CATEGORIES, Severity,
68    TEMP_FOLDER_BUCKETS, finding_id,
69};
70use projectatlas_core::language::{ContentClassification, ContentSelection};
71use projectatlas_core::symbols::{
72    CodeSymbol, ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
73    SymbolRelation, SymbolSourceSelector,
74};
75use projectatlas_core::telemetry::{
76    TelemetryContractError, TokenOverview, TokenTrendReport, TokenTrendWindow, UsageEvent,
77    UsageInstanceId, UsageInstanceOwner,
78};
79use projectatlas_core::{
80    AGENT_REVIEWED_SOURCE_VALUES, CanonicalProjectRoot, CoreError, HIGH_IMPACT_FILE_NAMES,
81    HIGH_IMPACT_PATH_PREFIXES, HIGH_IMPACT_PATH_SEGMENTS, IndexGeneration, IndexWorkControl,
82    IndexWorkFailure, IndexWorkStage, IndexedNode, LEGACY_HUMAN_PURPOSE_SOURCE, Node, NodeKind,
83    Overview, Purpose, PurposeSource, PurposeStatus, normalize_native_path_display,
84    normalize_repo_path_prefix,
85};
86use rusqlite::types::Value;
87use rusqlite::{
88    Connection, ErrorCode, OpenFlags, OptionalExtension, TransactionBehavior, params,
89    params_from_iter,
90};
91use serde::{Deserialize, Serialize};
92use std::cell::{Cell, RefCell};
93use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
94use std::num::{ParseIntError, TryFromIntError};
95use std::ops::{Deref, DerefMut};
96use std::path::{Path, PathBuf};
97use std::time::Duration;
98use thiserror::Error;
99
100#[cfg(all(test, windows))]
101use schema::PREVIOUS_SCHEMA_VERSION;
102use schema::{
103    FILE_TEXT_FTS_PROJECTION_REVISION_KEY, FILE_TEXT_FTS_SOURCE_REVISION_KEY,
104    INDEX_PUBLICATION_FINGERPRINT_KEY, INDEX_PUBLICATION_GENERATION_KEY,
105    INDEX_PUBLICATION_STATE_KEY, PROJECT_ROOT_KEY, SchemaState,
106};
107#[cfg(test)]
108use schema::{SCHEMA_VERSION, SCHEMA_VERSION_KEY, sqlite_sidecar_path};
109use sqlite_profile::{
110    DatabaseLocation, JournalModePolicy, SQLITE_BUSY_TIMEOUT, open_writable_connection,
111};
112
113/// Maximum persisted text for denormalized symbol-name search summaries.
114const MAX_SYMBOL_SEARCH_SUMMARY_CHARS: usize = 16_000;
115/// Publication acquisition fails fast so callers must restage after contention.
116const SQLITE_PUBLICATION_ACQUIRE_TIMEOUT: Duration = Duration::ZERO;
117/// Ancillary telemetry must not delay a valid navigation result under contention.
118const SQLITE_TELEMETRY_BUSY_TIMEOUT: Duration = Duration::from_millis(25);
119/// Maximum paths admitted to one purpose-curation hydration statement.
120pub const MAX_PURPOSE_CURATION_BATCH_ROWS: usize = 200;
121/// `SQLite` schema version supported by this runtime.
122pub const CURRENT_SCHEMA_VERSION: i64 = schema::SCHEMA_VERSION;
123/// Maximum FTS candidates decoded for one exact-verification request.
124pub const MAX_FILE_TEXT_FTS_CANDIDATES: usize = 4_096;
125/// Path-indexed metadata cursor for one exact path-or-descendant fallback scope.
126const FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL: &str = "
127    SELECT text.path, text.content_hash, text.byte_count, text.line_count,
128           classification.classification
129    FROM file_texts AS text
130    LEFT JOIN file_content_classifications AS classification
131      ON classification.path = text.path
132    WHERE text.path = ?1 OR (text.path >= ?2 AND text.path < ?3)
133    ORDER BY text.path
134";
135/// Metadata cursor used when a fallback glob has no safe fixed prefix.
136const FILE_TEXT_FALLBACK_ALL_METADATA_SQL: &str = "
137    SELECT text.path, text.content_hash, text.byte_count, text.line_count,
138           classification.classification
139    FROM file_texts AS text
140    LEFT JOIN file_content_classifications AS classification
141      ON classification.path = text.path
142    ORDER BY text.path
143";
144/// Exact authoritative content hydration after service-owned admission.
145const FILE_TEXT_FALLBACK_CONTENT_SQL: &str = "SELECT content FROM file_texts WHERE path = ?1";
146/// Maximum UTF-8 bytes retained in one host-owned purpose task label.
147const MAX_PURPOSE_CURATION_TASK_BYTES: usize = 256;
148/// Domain separator for deterministic purpose-curation item work keys.
149const PURPOSE_CURATION_ITEM_KEY_DOMAIN: &str = "projectatlas:purpose-curation:item:v1";
150/// Domain separator for deterministic purpose-curation row-state tokens.
151const PURPOSE_CURATION_STATE_TOKEN_DOMAIN: &str = "projectatlas:purpose-curation:state:v1";
152/// Domain separator for deterministic purpose-curation batch work keys.
153const PURPOSE_CURATION_BATCH_KEY_DOMAIN: &str = "projectatlas:purpose-curation:batch:v1";
154/// Monotonic metadata revision for accepted authored-purpose mutations.
155const AUTHORED_PURPOSE_REVISION_KEY: &str = "purpose.authored_revision";
156/// Select create capability only for a path proven absent by preflight.
157fn writable_open_flags(state: SchemaState, database_exists: bool) -> OpenFlags {
158    match (state, database_exists) {
159        (SchemaState::Fresh, false) => {
160            OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE
161        }
162        (SchemaState::Fresh | SchemaState::Current | SchemaState::UpgradeRequired, true)
163        | (SchemaState::Current | SchemaState::UpgradeRequired, false) => {
164            OpenFlags::SQLITE_OPEN_READ_WRITE
165        }
166    }
167}
168
169/// Establish WAL only while creating or upgrading an admitted database.
170const fn writable_journal_policy(state: SchemaState) -> JournalModePolicy {
171    match state {
172        SchemaState::Current => JournalModePolicy::RequireWal,
173        SchemaState::Fresh | SchemaState::UpgradeRequired => JournalModePolicy::EnsureWal,
174    }
175}
176
177/// Database-layer error type.
178#[derive(Debug, Error)]
179pub enum DbError {
180    /// `SQLite` operation failed.
181    #[error("sqlite error: {0}")]
182    Sqlite(#[from] rusqlite::Error),
183    /// A compatibility read could not establish a complete bounded result.
184    #[error(
185        "legacy relation read reached {limit:?}; use view detailed with an exact file anchor and continuation"
186    )]
187    LegacyRelationReadLimit {
188        /// Resource that prevented complete output.
189        limit: LegacyRelationReadLimit,
190        /// Bounded work performed before refusal.
191        work: LegacyRelationReadWork,
192    },
193    /// A live project database is located on a known unsupported filesystem.
194    #[error(
195        "database path {path:?} is on an unsupported filesystem (mount: {mount_point:?}, type: {filesystem_type:?}); live SQLite WAL requires supported local storage"
196    )]
197    DatabaseFilesystemUnsupported {
198        /// Database path rejected before writable access.
199        path: PathBuf,
200        /// Resolved mount point when one was safely available.
201        mount_point: Option<PathBuf>,
202        /// Normalized filesystem type when one was safely available.
203        filesystem_type: Option<String>,
204    },
205    /// Local WAL-safe filesystem placement could not be proved.
206    #[error(
207        "database path {path:?} has uncertain filesystem placement (mount: {mount_point:?}, type: {filesystem_type:?}): {reason}"
208    )]
209    DatabaseFilesystemUncertain {
210        /// Database path rejected before writable access.
211        path: PathBuf,
212        /// Resolved mount point when one was safely available.
213        mount_point: Option<PathBuf>,
214        /// Normalized filesystem type when one was safely available.
215        filesystem_type: Option<String>,
216        /// Bounded reason the local profile could not be established.
217        reason: String,
218    },
219    /// `SQLite` did not retain a required connection operating-profile value.
220    #[error("SQLite operating profile mismatch for {setting}: expected {expected}, found {found}")]
221    DatabaseOperatingProfile {
222        /// Connection or durable setting that did not match.
223        setting: &'static str,
224        /// Required value.
225        expected: String,
226        /// Observed value.
227        found: String,
228    },
229    /// A persisted or requested graph value violates the typed domain contract.
230    #[error("repository graph contract error: {0}")]
231    GraphContract(#[from] GraphContractError),
232    /// A telemetry identity violates its typed domain contract.
233    #[error("telemetry contract error: {0}")]
234    TelemetryContract(#[from] TelemetryContractError),
235    /// A requested worktree alias violates the public registry contract.
236    #[error("invalid worktree alias {alias:?}: {reason}")]
237    InvalidWorktreeAlias {
238        /// Rejected caller value.
239        alias: String,
240        /// Stable validation reason.
241        reason: &'static str,
242    },
243    /// No active worktree registration has the requested alias.
244    #[error("active worktree registration {alias:?} was not found")]
245    WorktreeRegistrationNotFound {
246        /// Requested normalized alias.
247        alias: String,
248    },
249    /// A registration identity is already owned by another active worktree.
250    #[error("worktree registration {field} conflicts with active value {value:?}")]
251    WorktreeRegistrationConflict {
252        /// Conflicting identity field.
253        field: &'static str,
254        /// Bounded caller-visible value.
255        value: String,
256    },
257    /// A legacy active registration would violate native identity uniqueness.
258    #[error(
259        "worktree registration migration found duplicate active native {field} identities in registrations {first_registration_id} and {second_registration_id}; repair one legacy row and retry"
260    )]
261    WorktreeRegistrationMigrationConflict {
262        /// Native identity column whose uniqueness would be violated.
263        field: &'static str,
264        /// Lowest stable registration ID in the collision.
265        first_registration_id: i64,
266        /// Highest stable registration ID in the collision.
267        second_registration_id: i64,
268    },
269    /// A legacy path projection cannot establish its original native identity.
270    #[error(
271        "worktree registration migration cannot establish native {field} identity for registration {registration_id}; repair the legacy row and retry"
272    )]
273    WorktreeRegistrationMigrationIdentityUnavailable {
274        /// Legacy path field whose native identity cannot be recovered safely.
275        field: &'static str,
276        /// Stable registration ID that needs repair.
277        registration_id: i64,
278    },
279    /// The bounded worktree-registration catalog is full.
280    #[error("worktree registration capacity {limit} is exhausted")]
281    WorktreeRegistrationCapacity {
282        /// Maximum active and retired registrations retained by one control atlas.
283        limit: usize,
284    },
285    /// A registration path is not an absolute caller-validated structural identity.
286    #[error("invalid worktree registration {field} path {path:?}")]
287    InvalidWorktreeRegistrationPath {
288        /// Path responsibility being validated.
289        field: &'static str,
290        /// Rejected normalized path.
291        path: String,
292    },
293    /// A persisted worktree registration row violates its typed contract.
294    #[error("invalid worktree registration row: {reason}")]
295    WorktreeRegistrationRow {
296        /// Stable validation failure.
297        reason: &'static str,
298    },
299    /// A worktree aggregate snapshot belongs to a different initialized atlas.
300    #[error("worktree telemetry project identity does not match registration {registration_id}")]
301    WorktreeTelemetryProjectMismatch {
302        /// Stable control-database registration identity.
303        registration_id: i64,
304    },
305    /// A bounded worktree telemetry snapshot resource ceiling was exceeded.
306    #[error("worktree telemetry snapshot {resource} exceeded limit {limit}: observed {observed}")]
307    WorktreeTelemetrySnapshotLimit {
308        /// Resource whose fixed bound was exceeded.
309        resource: &'static str,
310        /// Maximum admitted value.
311        limit: usize,
312        /// Observed rejected value.
313        observed: usize,
314    },
315    /// One telemetry runtime instance was reused across incompatible origins.
316    #[error("telemetry runtime instance has a conflicting worktree origin")]
317    WorktreeTelemetryOriginConflict,
318    /// A worktree hydration request violates its target-local safety contract.
319    #[error("invalid worktree hydration request: {reason}")]
320    WorktreeHydrationInvalid {
321        /// Stable rejection reason.
322        reason: &'static str,
323    },
324    /// A hydration destination already contains a database and cannot be replaced.
325    #[error("worktree hydration destination already exists: {path:?}")]
326    WorktreeHydrationDestinationExists {
327        /// Existing destination preserved without mutation.
328        path: PathBuf,
329    },
330    /// Worktree hydration could not create, clean, or activate its private candidate.
331    #[error("worktree hydration I/O failed for {path:?}: {source}")]
332    WorktreeHydrationIo {
333        /// Candidate or destination path involved in the failure.
334        path: PathBuf,
335        /// Underlying filesystem failure.
336        #[source]
337        source: std::io::Error,
338    },
339    /// Online backup could not make progress within its fixed busy retry budget.
340    #[error("worktree hydration backup remained busy after {attempts} attempts")]
341    WorktreeHydrationBackupBusy {
342        /// Consecutive busy or locked backup steps.
343        attempts: usize,
344    },
345    /// The candidate has not completed a post-hydration source reconciliation.
346    #[error(
347        "worktree hydration candidate is not reconciled: baseline generation {baseline}, found {found}"
348    )]
349    WorktreeHydrationNotReconciled {
350        /// Generation published by baseline rebinding.
351        baseline: IndexGeneration,
352        /// Current complete generation, or zero when unavailable.
353        found: IndexGeneration,
354    },
355    /// Persisted graph project identity differs from the selected identity.
356    #[error(
357        "repository graph project identity {found} does not match selected identity {expected}"
358    )]
359    GraphProjectIdentityMismatch {
360        /// Project identity selected by the caller.
361        expected: String,
362        /// Project identity stored in the database.
363        found: String,
364    },
365    /// A graph query requires one complete nonzero publication generation.
366    #[error("repository graph is unavailable without a complete published generation")]
367    GraphPublicationUnavailable,
368    /// A normalized graph row has an impossible column shape.
369    #[error("invalid {table} row: {reason}")]
370    GraphRowShape {
371        /// Owning normalized graph table.
372        table: &'static str,
373        /// Stable shape diagnostic.
374        reason: &'static str,
375    },
376    /// A derived graph snapshot violates its bounded portable contract.
377    #[error("invalid derived graph snapshot: {reason}")]
378    DerivedSnapshotInvalid {
379        /// Stable validation failure.
380        reason: &'static str,
381    },
382    /// A derived graph snapshot exceeded one declared resource ceiling.
383    #[error(
384        "derived graph snapshot {resource} exceeds the limit: found {found}, maximum {maximum}"
385    )]
386    DerivedSnapshotLimit {
387        /// Bounded resource that was exceeded.
388        resource: &'static str,
389        /// Observed amount.
390        found: u64,
391        /// Maximum admitted amount.
392        maximum: u64,
393    },
394    /// A private snapshot capture could not be created or cleaned up.
395    #[error("derived graph snapshot I/O failed for {path:?}: {source}")]
396    DerivedSnapshotIo {
397        /// Private temporary path involved in the operation.
398        path: PathBuf,
399        /// Underlying filesystem failure.
400        #[source]
401        source: std::io::Error,
402    },
403    /// A portable snapshot payload was not valid JSON.
404    #[error("derived graph snapshot JSON is invalid: {0}")]
405    DerivedSnapshotJson(#[from] serde_json::Error),
406    /// Persisted per-file symbol rows do not match their owning parser metadata.
407    #[error("invalid persisted symbol graph for {path:?}: {reason}")]
408    SymbolGraphRowShape {
409        /// Repository path whose persisted graph is inconsistent.
410        path: String,
411        /// Stable shape diagnostic.
412        reason: &'static str,
413    },
414    /// Equal scoped resolution digests retained different canonical witnesses.
415    #[error("resolution-key collision in {domain} for digest {digest:?}")]
416    ResolutionKeyCollision {
417        /// Closed resolution domain containing the conflict.
418        domain: &'static str,
419        /// Fixed digest shared by conflicting witnesses.
420        digest: [u8; 32],
421    },
422    /// A normalized binary graph field has the wrong width.
423    #[error("invalid {field} blob length {found}; expected {expected}")]
424    InvalidBlobLength {
425        /// Owning database field.
426        field: &'static str,
427        /// Required fixed byte width.
428        expected: usize,
429        /// Observed byte width.
430        found: usize,
431    },
432    /// An unsigned graph count cannot be represented by `SQLite`.
433    #[error("graph count for {field} exceeds SQLite integer range: {value}")]
434    GraphCountOverflow {
435        /// Owning database field.
436        field: &'static str,
437        /// Unsigned domain value that exceeded the database range.
438        value: u64,
439    },
440    /// Schema version is not supported.
441    #[error("unsupported schema version {found}, expected {expected}")]
442    SchemaVersion {
443        /// Version found in database.
444        found: i64,
445        /// Expected version.
446        expected: i64,
447    },
448    /// An existing database has no durable schema version.
449    #[error("existing database is missing schema_version metadata")]
450    SchemaVersionMissing,
451    /// The caller requires a previously initialized schema.
452    #[error("database initialization is required before this operation")]
453    InitializationRequired,
454    /// A durable `SQLite` object does not match the supported schema contract.
455    #[error("incompatible schema object {object:?}: expected {expected}, found {found}")]
456    SchemaShape {
457        /// Table, index, or column whose shape is incompatible.
458        object: String,
459        /// Required `SQLite` object kind.
460        expected: String,
461        /// Observed `SQLite` object kind.
462        found: String,
463    },
464    /// `SQLite` integrity validation failed before migration.
465    #[error("database integrity check failed: {message}")]
466    IntegrityCheck {
467        /// Bounded `SQLite` integrity diagnostic.
468        message: String,
469    },
470    /// A migration did not reach the supported current schema.
471    #[error("schema migration did not reach expected version {expected}")]
472    SchemaPostcondition {
473        /// Version required after migration.
474        expected: i64,
475    },
476    /// A source-owned database has no durable project identity.
477    #[error("existing database is missing project_root metadata")]
478    ProjectRootMissing,
479    /// A source-owned database belongs to another project root.
480    #[error("database project root {found:?} does not match selected root {expected:?}")]
481    ProjectRootMismatch {
482        /// Canonical root selected by the caller.
483        expected: String,
484        /// Durable root recorded in `SQLite`.
485        found: String,
486        /// Lossless native identities when the admission proof had both roots.
487        identities: Option<Box<ProjectRootMismatchIdentities>>,
488    },
489    /// A native project-root identity or its lossless codec is invalid.
490    #[error("project-root identity error: {0}")]
491    ProjectRootIdentity(#[from] CoreError),
492    /// A current bound database has no lossless native project-root identity.
493    #[error("bound project database is missing canonical project-root identity")]
494    ProjectRootIdentityMissing,
495    /// Explicit legacy adoption requires the intact released schema-19 state.
496    #[error(
497        "legacy root adoption requires an intact schema-19 database at the selected root's .projectatlas/projectatlas.db, without native root identity"
498    )]
499    LegacyRootAdoptionUnavailable,
500    /// Adoption committed, but its database location could not be verified afterward.
501    #[error(
502        "legacy root adoption committed, but the selected database location changed or could not be verified; stop recovery and preserve both database locations and their WAL/SHM sidecars: {source}"
503    )]
504    LegacyRootAdoptionCommittedLocationChanged {
505        /// Post-commit location or opened-file identity failure; no rollback is claimed.
506        #[source]
507        source: Box<DbError>,
508    },
509    /// A root transition destination is not an absolute existing directory.
510    #[error("invalid project root transition destination {root:?}: {source}")]
511    ProjectRootDestinationInvalid {
512        /// Destination rejected before database preflight or mutation.
513        root: String,
514        /// Filesystem or input failure that made the destination invalid.
515        #[source]
516        source: std::io::Error,
517    },
518    /// A move or detach requires a previously bound project root.
519    #[error("project root transition requires an existing bound root")]
520    ProjectRootTransitionRequiresExistingRoot,
521    /// A move must select a destination different from the old root.
522    #[error("project root move destination {root:?} matches the existing root")]
523    ProjectRootTransitionRequiresDifferentRoot {
524        /// Root that was selected as both source and destination.
525        root: String,
526    },
527    /// A verified move cannot preserve identity while the old root still exists.
528    #[error("project root {root:?} still exists; use detach for an independent copy")]
529    ProjectRootStillPresent {
530        /// Recorded old root that remains accessible.
531        root: String,
532    },
533    /// Filesystem state could not prove that a move's old root is absent.
534    #[error("cannot prove project root {root:?} is absent: {source}")]
535    ProjectRootAbsenceUncertain {
536        /// Recorded old root whose state is uncertain.
537        root: String,
538        /// Filesystem failure that prevents an absence proof.
539        #[source]
540        source: std::io::Error,
541    },
542    /// Root or identity state changed after transition preflight.
543    #[error(
544        "project root transition state changed: root {expected_root:?} -> {found_root:?}, identity {expected_identity:?} -> {found_identity:?}"
545    )]
546    ProjectRootTransitionChanged {
547        /// Root captured by read-only preflight.
548        expected_root: Option<String>,
549        /// Root observed inside the write transaction.
550        found_root: Option<String>,
551        /// Identity captured by read-only preflight.
552        expected_identity: Option<String>,
553        /// Identity observed inside the write transaction.
554        found_identity: Option<String>,
555    },
556    /// A bound project database has no durable instance identity.
557    #[error("bound project database is missing project instance identity")]
558    ProjectInstanceIdentityMissing,
559    /// `SQLite` did not yield a usable nonzero project identity.
560    #[error("failed to generate a distinct nonzero project instance identity")]
561    ProjectInstanceIdentityGenerationFailed,
562    /// A transaction failed and the explicit rollback also failed.
563    #[error("{operation}; rollback also failed: {rollback}")]
564    TransactionRollback {
565        /// Primary operation failure that caused rollback.
566        #[source]
567        operation: Box<DbError>,
568        /// Secondary rollback failure retained for diagnosis.
569        rollback: rusqlite::Error,
570    },
571    /// Publication acquisition failed and its standard busy policy was not restored.
572    #[error(
573        "publication writer acquisition failed: {operation}; restoring the standard busy policy also failed: {restore}"
574    )]
575    PublicationAcquirePolicyRestore {
576        /// Writer-acquisition failure observed with fail-fast busy handling.
577        #[source]
578        operation: Box<rusqlite::Error>,
579        /// Failure restoring the ordinary connection busy policy.
580        restore: Box<rusqlite::Error>,
581    },
582    /// Invalid enum value read from the database.
583    #[error("invalid {field} value in database: {value}")]
584    InvalidEnum {
585        /// Field name.
586        field: &'static str,
587        /// Invalid value.
588        value: String,
589    },
590    /// A literal cannot safely use the complete-candidate FTS path.
591    #[error("literal token cannot use FTS acceleration: {reason}")]
592    FileTextFtsTokenUnsafe {
593        /// Stable rejection reason for service fallback selection.
594        reason: &'static str,
595    },
596    /// One FTS request exceeded the storage-owned candidate bound.
597    #[error("FTS candidate request {requested} exceeds the maximum {maximum}")]
598    FileTextFtsCandidateLimit {
599        /// Requested exact-verification candidates.
600        requested: usize,
601        /// Storage-owned hard maximum.
602        maximum: usize,
603    },
604    /// `SQLite` returned a non-finite lexical ranking score.
605    #[error("invalid FTS BM25 score for {path:?}")]
606    FileTextFtsScoreInvalid {
607        /// Candidate path whose score was invalid.
608        path: String,
609    },
610    /// Durable FTS synchronization metadata is absent or contradictory.
611    #[error("invalid FTS synchronization state: {reason}")]
612    FileTextFtsStateInvalid {
613        /// Stable reason that makes the acceleration state untrustworthy.
614        reason: &'static str,
615    },
616    /// Persisted text metadata disagrees with its authoritative UTF-8 content.
617    #[error("file text {path:?} has invalid {field}: recorded {recorded}, actual {actual}")]
618    FileTextMetadataMismatch {
619        /// Repository-relative path owning the invalid text row.
620        path: String,
621        /// Metadata field that disagrees with content.
622        field: &'static str,
623        /// Persisted or caller-supplied value.
624        recorded: usize,
625        /// Value derived from authoritative content.
626        actual: usize,
627    },
628    /// A bounded database read observed cancellation or its deadline.
629    #[error("{0}")]
630    IndexWork(#[from] IndexWorkFailure),
631    /// Count value from `SQLite` could not fit its owning unsigned domain type.
632    #[error("invalid count for {field}: {value}")]
633    InvalidCount {
634        /// Count field name.
635        field: &'static str,
636        /// Invalid database count.
637        value: i64,
638        /// Source conversion error.
639        source: TryFromIntError,
640    },
641    /// Integer metadata could not be parsed without losing its source error.
642    #[error("invalid integer metadata for {field}: {value:?}: {source}")]
643    InvalidInteger {
644        /// Metadata field name.
645        field: &'static str,
646        /// Invalid persisted value.
647        value: String,
648        /// Source parse failure.
649        source: ParseIntError,
650    },
651    /// Unsigned integer metadata could not advance without overflow.
652    #[error("integer metadata for {field} overflowed at {value}")]
653    IntegerMetadataOverflow {
654        /// Metadata key owning the monotonic value.
655        field: &'static str,
656        /// Largest persisted value that could not advance.
657        value: u64,
658    },
659    /// A caller supplied a path that is not in the current index.
660    #[error("path {path:?} is not indexed; run scan, fix the path, or choose an indexed path")]
661    PathNotIndexed {
662        /// Repository-relative path.
663        path: String,
664    },
665    /// One classification write/read batch exceeded its fixed path ceiling.
666    #[error("file classification batch requested {requested} paths; maximum is {maximum}")]
667    FileContentClassificationBatchTooLarge {
668        /// Unique paths requested.
669        requested: usize,
670        /// Storage-owned hard maximum.
671        maximum: usize,
672    },
673    /// One classification batch repeated an exact path.
674    #[error("file classification batch repeats path {path:?}")]
675    FileContentClassificationDuplicatePath {
676        /// Repeated repository-relative path.
677        path: String,
678    },
679    /// A current admitted file has no classification in the active transaction.
680    #[error("current file {path:?} has no content classification")]
681    FileContentClassificationMissing {
682        /// Current repository-relative file path.
683        path: String,
684    },
685    /// A classification row outlived current file ownership.
686    #[error("content classification for {path:?} does not belong to a current file")]
687    FileContentClassificationNotCurrent {
688        /// Stale or non-file repository-relative path.
689        path: String,
690    },
691    /// One classification/path page requested an invalid row limit.
692    #[error("file classification page requested {requested} rows; maximum is {maximum}")]
693    FileContentClassificationLimit {
694        /// Requested page rows.
695        requested: u32,
696        /// Storage-owned hard maximum.
697        maximum: u32,
698    },
699    /// A purpose-curation task label is blank, unsafe, or too large.
700    #[error("invalid purpose-curation task label: {reason}")]
701    PurposeCurationTaskInvalid {
702        /// Stable validation reason.
703        reason: &'static str,
704    },
705    /// A purpose-curation hydration request exceeds the bounded statement size.
706    #[error("purpose-curation batch requested {requested} paths; maximum is {maximum}")]
707    PurposeCurationBatchTooLarge {
708        /// Number of unique paths requested.
709        requested: usize,
710        /// Maximum paths admitted to one prepared set query.
711        maximum: usize,
712    },
713    /// A caller attempted to resolve a health finding that is not currently active.
714    #[error(
715        "health finding {finding_id:?} with category {category:?} and path {path:?} is not active; run health-check and use an exact finding id/path/category"
716    )]
717    HealthFindingNotActive {
718        /// Requested finding id.
719        finding_id: String,
720        /// Requested category.
721        category: String,
722        /// Requested primary path.
723        path: String,
724    },
725    /// A projection-only refresh no longer matches the established index contract.
726    #[error("index publication contract changed during projection refresh")]
727    PublicationContractChanged,
728    /// Prepared publication work was based on a generation that is no longer current.
729    #[error("index publication base generation changed: expected {expected}, found {found}")]
730    PublicationBaseGenerationChanged {
731        /// Complete generation used to prepare the publication batch.
732        expected: IndexGeneration,
733        /// Complete generation observed after reserving the writer transaction.
734        found: IndexGeneration,
735    },
736    /// A complete index generation cannot advance any further.
737    #[error("index publication generation overflowed")]
738    PublicationGenerationOverflow,
739    /// A full scan replacement has not removed its remaining absent projections.
740    #[error("index publication cannot complete before scan replacement finishes")]
741    ScanReplacementIncomplete,
742    /// The store already has an active read snapshot.
743    #[error("index read snapshot is already active on this store")]
744    IndexReadSnapshotActive,
745    /// A read-only store cannot locate its database for a separate telemetry write.
746    #[error("read-only store has no database path for telemetry persistence")]
747    TelemetryPathUnavailable,
748    /// One telemetry field exceeds its declared UTF-8 byte budget.
749    #[error("telemetry field {field} uses {bytes} UTF-8 bytes; limit is {limit}")]
750    TelemetryFieldTooLarge {
751        /// Stable field owner.
752        field: &'static str,
753        /// Observed UTF-8 byte count.
754        bytes: usize,
755        /// Maximum admitted UTF-8 byte count.
756        limit: usize,
757    },
758    /// A telemetry retention policy bound cannot make forward progress.
759    #[error("invalid telemetry retention limit for {field}: {value}")]
760    TelemetryLimitInvalid {
761        /// Stable retention-policy field.
762        field: &'static str,
763        /// Rejected policy value.
764        value: usize,
765    },
766    /// A telemetry counter cannot be represented exactly in `SQLite`.
767    #[error("telemetry integer overflow for {field}")]
768    TelemetryIntegerOverflow {
769        /// Stable counter owner.
770        field: &'static str,
771    },
772    /// A runtime attempted to reuse a sealed or expired instance.
773    #[error("telemetry usage instance is sealed or expired")]
774    TelemetryInstanceInactive,
775    /// Operating-system randomness was unavailable for optional telemetry identity creation.
776    #[error("telemetry runtime identity is unavailable")]
777    TelemetryIdentityUnavailable,
778    /// A runtime identity was reused with incompatible durable instance metadata.
779    #[error("telemetry runtime identity does not match its retained owner or caller label")]
780    TelemetryInstanceMismatch,
781    /// The bounded active-instance capacity is exhausted.
782    #[error("telemetry active-instance capacity is exhausted")]
783    TelemetryInstanceCapacity,
784    /// The bounded active-baseline capacity is exhausted.
785    #[error("telemetry modeled-baseline capacity is exhausted")]
786    TelemetryBaselineCapacity,
787    /// A compact modeled-baseline key collided with different witness material.
788    #[error("telemetry modeled-baseline key collided with different witness material")]
789    TelemetryBaselineCollision,
790}
791
792impl DbError {
793    /// Return a schema version with a complete migration route owned by this runtime.
794    #[must_use]
795    pub fn supported_schema_migration(&self) -> Option<(i64, i64, u32)> {
796        match self {
797            Self::SchemaVersion { found, expected } => schema::migration_steps_remaining(*found)
798                .filter(|steps| *steps > 0)
799                .map(|steps| (*found, *expected, steps)),
800            _ => None,
801        }
802    }
803
804    /// Return a schema version rejected by this runtime's migration inventory.
805    #[must_use]
806    pub fn unsupported_schema_version(&self) -> Option<(i64, i64)> {
807        match self {
808            Self::SchemaVersion { found, expected }
809                if schema::migration_steps_remaining(*found).is_none() =>
810            {
811                Some((*found, *expected))
812            }
813            _ => None,
814        }
815    }
816
817    /// Return whether a derived-index write could not proceed without implying
818    /// corruption, schema drift, or an identity-contract failure.
819    #[must_use]
820    pub fn is_write_unavailable(&self) -> bool {
821        match self {
822            Self::Sqlite(error) => matches!(
823                error.sqlite_error_code(),
824                Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked | ErrorCode::ReadOnly)
825            ),
826            _ => false,
827        }
828    }
829}
830
831/// Lossless native identities involved in a project-root mismatch.
832#[derive(Clone, Debug, Eq, PartialEq)]
833pub struct ProjectRootMismatchIdentities {
834    /// Canonical root selected by the caller.
835    pub expected: CanonicalProjectRoot,
836    /// Canonical root recorded by the opened index.
837    pub found: CanonicalProjectRoot,
838}
839
840/// Convenient result alias for database operations.
841pub type DbResult<T> = Result<T, DbError>;
842
843/// Maximum exact paths admitted to one bounded symbol hydration request.
844pub const MAX_SYMBOL_BATCH_PATHS: u32 = 64;
845/// Maximum symbol rows admitted to one bounded hydration request.
846pub const MAX_SYMBOL_BATCH_ROWS: u32 = 4_096;
847/// Maximum decoded symbol bytes admitted to one bounded hydration request.
848pub const MAX_SYMBOL_BATCH_DECODED_BYTES: u64 = 4 * 1_024 * 1_024;
849/// Paths bound per statement, below supported `SQLite` variable ceilings.
850const SYMBOL_BATCH_BIND_PATHS: usize = 48;
851
852/// One persisted symbol with the classification of its owning file.
853#[derive(Clone, Debug, Eq, PartialEq)]
854pub struct ClassifiedSymbol {
855    /// Persisted symbol detail.
856    pub symbol: CodeSymbol,
857    /// Registry-owned content role for the symbol's file.
858    pub classification: ContentClassification,
859}
860
861/// Typed database envelope for one exact-path symbol batch.
862#[derive(Clone, Copy, Debug, Eq, PartialEq)]
863pub struct SymbolBatchReadBudget {
864    /// Maximum unique exact repository paths.
865    paths: u32,
866    /// Maximum decoded symbol rows.
867    rows: u32,
868    /// Maximum retained Rust row and owned string allocation bytes.
869    decoded_bytes: u64,
870}
871
872impl SymbolBatchReadBudget {
873    /// Construct one bounded exact-path symbol read envelope.
874    ///
875    /// # Errors
876    ///
877    /// Returns an error when a limit is zero or above its product ceiling.
878    pub fn new(paths: u32, rows: u32, decoded_bytes: u64) -> DbResult<Self> {
879        if paths == 0
880            || paths > MAX_SYMBOL_BATCH_PATHS
881            || rows == 0
882            || rows > MAX_SYMBOL_BATCH_ROWS
883            || decoded_bytes == 0
884            || decoded_bytes > MAX_SYMBOL_BATCH_DECODED_BYTES
885        {
886            return Err(GraphContractError::InvalidLimits {
887                reason: "symbol batch limit is zero or above the product ceiling",
888            }
889            .into());
890        }
891        Ok(Self {
892            paths,
893            rows,
894            decoded_bytes,
895        })
896    }
897
898    /// Return the exact-path ceiling.
899    #[must_use]
900    pub const fn paths(self) -> u32 {
901        self.paths
902    }
903
904    /// Return the decoded-row ceiling.
905    #[must_use]
906    pub const fn rows(self) -> u32 {
907        self.rows
908    }
909
910    /// Return the decoded-byte ceiling.
911    #[must_use]
912    pub const fn decoded_bytes(self) -> u64 {
913        self.decoded_bytes
914    }
915}
916
917/// Exact work retained by one bounded symbol batch read.
918#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
919pub struct SymbolBatchReadWork {
920    /// Unique exact paths supplied to `SQLite`.
921    pub requested_paths: u32,
922    /// Symbol rows retained after all bounds.
923    pub returned_rows: u32,
924    /// Retained Rust row and owned string allocation bytes after all bounds.
925    pub decoded_bytes: u64,
926}
927
928/// Bounded symbols and truncation state returned for exact paths.
929#[derive(Clone, Debug, Default, Eq, PartialEq)]
930pub struct SymbolBatchRead {
931    /// Deterministically ordered persisted symbols.
932    pub rows: Vec<CodeSymbol>,
933    /// Whether a path, row, or decoded-byte bound omitted rows.
934    pub truncated: bool,
935    /// First deterministic database limit that omitted symbol rows.
936    pub reached_limit: Option<SymbolBatchReadLimit>,
937    /// Exact work retained by this database read.
938    pub work: SymbolBatchReadWork,
939}
940
941/// Closed database limits that can truncate an exact-path symbol batch.
942#[derive(Clone, Copy, Debug, Eq, PartialEq)]
943pub enum SymbolBatchReadLimit {
944    /// More unique paths were requested than the batch admits.
945    Paths,
946    /// More symbol rows matched than the batch admits.
947    Rows,
948    /// The next symbol row crossed the decoded-byte envelope.
949    DecodedBytes,
950}
951
952/// Durable state of the multi-projection derived index publication.
953#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
954#[serde(rename_all = "snake_case")]
955pub enum IndexPublicationState {
956    /// A publisher may have changed only part of the derived index.
957    Updating,
958    /// Every projection completed under the recorded contract fingerprint.
959    Complete,
960}
961
962impl IndexPublicationState {
963    /// Return the stable `SQLite` representation.
964    const fn as_str(self) -> &'static str {
965        match self {
966            Self::Updating => "updating",
967            Self::Complete => "complete",
968        }
969    }
970
971    /// Parse the stable `SQLite` representation.
972    fn from_db(value: String) -> DbResult<Self> {
973        match value.as_str() {
974            "updating" => Ok(Self::Updating),
975            "complete" => Ok(Self::Complete),
976            _ => Err(DbError::InvalidEnum {
977                field: INDEX_PUBLICATION_STATE_KEY,
978                value,
979            }),
980        }
981    }
982}
983
984/// Persisted state needed to reject mixed or incompatible derived projections.
985#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
986pub struct IndexPublication {
987    /// Current publication state.
988    pub state: IndexPublicationState,
989    /// Contract fingerprint recorded by the last complete publication.
990    pub contract_fingerprint: Option<String>,
991    /// Monotonic generation of the last complete derived index.
992    pub generation: IndexGeneration,
993}
994
995/// Publication contract applied when one atomic writer commits.
996enum PublicationContract {
997    /// Establish or replace the complete derived-index contract.
998    Full(String),
999    /// Preserve the already established complete contract.
1000    Projection(String),
1001}
1002
1003/// Exclusive parent-owned atomic publication over all derived projections.
1004pub struct IndexPublicationGuard<'store> {
1005    /// Store whose connection owns the active `SQLite` write transaction.
1006    store: &'store mut AtlasStore,
1007    /// Contract behavior selected when the publication began.
1008    contract: PublicationContract,
1009    /// Generation visible before this publication began.
1010    previous_generation: IndexGeneration,
1011    /// Whether a full scan replacement still needs absent-projection cleanup.
1012    scan_replacement_pending: bool,
1013    /// Whether drop must roll the transaction back.
1014    active: bool,
1015}
1016
1017/// `SQLite`-backed `ProjectAtlas` index store.
1018pub struct AtlasStore {
1019    /// Active database connection for index reads and writes.
1020    connection: Connection,
1021    /// Whether normal reads currently share one explicit `SQLite` snapshot.
1022    read_snapshot_active: Cell<bool>,
1023    /// Durable database path when the store is file-backed.
1024    database_path: Option<PathBuf>,
1025    /// WAL-safe filesystem identity retained for later ancillary connections.
1026    database_location: Option<DatabaseLocation>,
1027    /// Whether this connection is restricted to non-mutating queries.
1028    read_only: bool,
1029    /// Project root validated for this store, when the database records one.
1030    validated_project_root: Option<String>,
1031    /// Native root identity captured with the validated root binding.
1032    validated_project_root_identity: Option<CanonicalProjectRoot>,
1033    /// Project identity captured with the validated root binding.
1034    validated_project_instance_id: Option<ProjectInstanceId>,
1035    /// Bounded per-label instances used by direct library callers for this handle lifetime.
1036    library_usage_instances: RefCell<HashMap<String, Option<UsageInstanceId>>>,
1037}
1038
1039/// Caller-owned purpose mutation transaction with an explicit commit boundary.
1040///
1041/// Purpose adapters keep this guard alive while they revalidate saved-source
1042/// admission. Dropping it before [`Self::commit`] rolls the mutation back.
1043pub struct PurposeMutationTransaction<'connection> {
1044    /// Immediate transaction rolled back on drop until the caller accepts the mutation.
1045    transaction: rusqlite::Transaction<'connection>,
1046}
1047
1048impl PurposeMutationTransaction<'_> {
1049    /// Commit the admitted purpose mutation.
1050    ///
1051    /// # Errors
1052    ///
1053    /// Returns an error when `SQLite` cannot commit the transaction.
1054    pub fn commit(self) -> DbResult<()> {
1055        self.transaction.commit().map_err(Into::into)
1056    }
1057
1058    /// Roll back a rejected purpose mutation and report any storage failure.
1059    ///
1060    /// # Errors
1061    ///
1062    /// Returns an error when `SQLite` cannot roll the transaction back.
1063    pub fn rollback(self) -> DbResult<()> {
1064        self.transaction.rollback().map_err(Into::into)
1065    }
1066}
1067
1068/// Root and project identity captured when one store binding was validated.
1069#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1070pub struct CapturedProjectBinding {
1071    /// Stable project identity captured at open or explicit root transition.
1072    pub project_instance_id: ProjectInstanceId,
1073    /// Lossless UTF-8 display of the local source root, when one exists.
1074    ///
1075    /// `None` is the typed unavailable state for native roots that cannot be
1076    /// represented as UTF-8. Identity-critical callers must use the native
1077    /// [`AtlasStore::project_root_identity`] value instead.
1078    pub project_root: Option<String>,
1079    /// Lossless native root identity captured with the project identity.
1080    #[serde(skip)]
1081    pub project_root_identity: CanonicalProjectRoot,
1082}
1083
1084/// Lightweight persisted import fact used by alias resolution.
1085#[derive(Clone, Debug, Eq, PartialEq)]
1086pub struct StoredImportRelation {
1087    /// Repository-relative source path that owns the import.
1088    pub path: String,
1089    /// Parser-selected source declaration or module.
1090    pub source_name: String,
1091    /// Original persisted import text.
1092    pub target_name: String,
1093    /// One-based source line.
1094    pub line: usize,
1095}
1096
1097/// One unapproved purpose row bound to deterministic curator work.
1098#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1099pub struct PurposeCurationCandidate {
1100    /// Current indexed node, purpose, and summary state.
1101    pub node: IndexedNode,
1102    /// Project/generation/task/path work identity used to coalesce duplicate work.
1103    pub work_key: String,
1104    /// Opaque token binding conditional apply to this exact unapproved purpose row.
1105    pub state_token: String,
1106}
1107
1108/// Bounded purpose-curation work selected from one project generation.
1109#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1110pub struct PurposeCurationBatch {
1111    /// Stable selected-project identity.
1112    pub project_instance_id: ProjectInstanceId,
1113    /// Active graph/index generation observed with the queue rows.
1114    pub active_generation: IndexGeneration,
1115    /// Host-supplied task label used to coalesce task-local work.
1116    pub task: String,
1117    /// Deterministic identity of the complete returned batch.
1118    pub work_key: String,
1119    /// Current missing or suggested purpose rows, ordered by path.
1120    pub items: Vec<PurposeCurationCandidate>,
1121}
1122
1123/// Result of applying a purpose through the stale-safe curator path.
1124#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1125#[serde(rename_all = "snake_case")]
1126pub enum PurposeConditionalApplyState {
1127    /// The current unapproved row matched and was approved atomically.
1128    Applied,
1129    /// Project, generation, task, path, or purpose row state changed after selection.
1130    Stale,
1131    /// The path now has accepted authored intent and was not overwritten.
1132    Accepted,
1133    /// The selected path is no longer active in the current index.
1134    PathUnavailable,
1135}
1136
1137/// One stale-safe purpose approval copied from a queue item.
1138#[derive(Clone, Debug, Eq, PartialEq)]
1139pub struct PurposeConditionalApplyRequest {
1140    /// Queue task label.
1141    pub task: String,
1142    /// Exact repository-relative path.
1143    pub path: String,
1144    /// Queue item work key.
1145    pub work_key: String,
1146    /// Queue item current-row state token.
1147    pub state_token: String,
1148    /// Agent-reviewed purpose to approve on an exact state match.
1149    pub purpose: String,
1150}
1151
1152/// Per-item outcome from one atomic conditional purpose-review batch.
1153#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1154pub struct PurposeConditionalApplyResult {
1155    /// Exact requested path.
1156    pub path: String,
1157    /// Applied or non-mutating stale/accepted/unavailable outcome.
1158    pub state: PurposeConditionalApplyState,
1159    /// Current purpose state observed or written inside the owning transaction.
1160    pub current_purpose: Option<Purpose>,
1161}
1162
1163/// Identity depth required while opening one current project binding.
1164#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1165enum ProjectIdentityRequirement {
1166    /// Ordinary stores require a complete root and identity binding.
1167    Required,
1168    /// An explicit root transition owns identity creation or repair.
1169    TransitionOwned,
1170}
1171
1172/// Whether opening a writable store may create its initial schema.
1173#[derive(Clone, Copy, Eq, PartialEq)]
1174enum StoreInitializationPolicy {
1175    /// Explicit initialization and ordinary creation APIs admit fresh storage.
1176    AllowFresh,
1177    /// Existing-state operations admit current or supported predecessor schemas only.
1178    ExistingSchema,
1179}
1180
1181impl ProjectIdentityRequirement {
1182    /// Whether a missing identity must fail the open.
1183    const fn is_required(self) -> bool {
1184        matches!(self, Self::Required)
1185    }
1186}
1187
1188impl Deref for IndexPublicationGuard<'_> {
1189    type Target = AtlasStore;
1190
1191    fn deref(&self) -> &Self::Target {
1192        self.store
1193    }
1194}
1195
1196impl DerefMut for IndexPublicationGuard<'_> {
1197    fn deref_mut(&mut self) -> &mut Self::Target {
1198        self.store
1199    }
1200}
1201
1202impl IndexPublicationGuard<'_> {
1203    /// Mark the current scan projection absent before bounded replacement batches.
1204    ///
1205    /// The caller must finish with [`Self::finish_scan_replacement`] before
1206    /// completing this publication. Dropping the guard rolls every partial
1207    /// replacement batch back with the parent transaction.
1208    ///
1209    /// # Errors
1210    ///
1211    /// Returns an error if the scan projection cannot be updated.
1212    pub fn begin_scan_replacement(&mut self) -> DbResult<()> {
1213        mark_all_scan_nodes_absent(&self.store.connection)?;
1214        self.scan_replacement_pending = true;
1215        Ok(())
1216    }
1217
1218    /// Upsert one bounded scan-node batch inside the parent publication.
1219    ///
1220    /// # Errors
1221    ///
1222    /// Returns an error if any node in the batch cannot be persisted.
1223    pub fn upsert_scan_node_batch(&mut self, nodes: &[Node]) -> DbResult<()> {
1224        upsert_nodes(&self.store.connection, nodes)
1225    }
1226
1227    /// Remove derived projections for nodes left absent after replacement.
1228    ///
1229    /// # Errors
1230    ///
1231    /// Returns an error if stale projections cannot be removed.
1232    pub fn finish_scan_replacement(&mut self) -> DbResult<()> {
1233        delete_absent_scan_projections(&self.store.connection)?;
1234        content_classification::validate_complete_file_content_classifications(
1235            &self.store.connection,
1236        )?;
1237        self.scan_replacement_pending = false;
1238        Ok(())
1239    }
1240
1241    /// Commit every derived projection and advance the complete generation
1242    /// exactly once.
1243    ///
1244    /// # Errors
1245    ///
1246    /// Returns an error if generation metadata is invalid, a projection-only
1247    /// refresh no longer matches its established contract, or commit fails.
1248    pub fn complete(mut self) -> DbResult<()> {
1249        if self.scan_replacement_pending {
1250            return Err(DbError::ScanReplacementIncomplete);
1251        }
1252        content_classification::validate_complete_file_content_classifications(
1253            &self.store.connection,
1254        )?;
1255        repository_graph::validate_complete_document_unresolved_reasons(&self.store.connection)?;
1256        let next_generation = self
1257            .previous_generation
1258            .checked_next()
1259            .ok_or(DbError::PublicationGenerationOverflow)?;
1260        match &self.contract {
1261            PublicationContract::Full(contract_fingerprint) => {
1262                set_metadata(
1263                    &self.store.connection,
1264                    INDEX_PUBLICATION_FINGERPRINT_KEY,
1265                    contract_fingerprint,
1266                )?;
1267            }
1268            PublicationContract::Projection(contract_fingerprint) => {
1269                let matches =
1270                    load_index_publication(&self.store.connection)?.is_some_and(|publication| {
1271                        publication.state == IndexPublicationState::Updating
1272                            && publication.contract_fingerprint.as_deref()
1273                                == Some(contract_fingerprint.as_str())
1274                            && publication.generation == self.previous_generation
1275                    });
1276                if !matches {
1277                    return Err(DbError::PublicationContractChanged);
1278                }
1279            }
1280        }
1281        set_metadata(
1282            &self.store.connection,
1283            INDEX_PUBLICATION_GENERATION_KEY,
1284            &next_generation.to_string(),
1285        )?;
1286        set_metadata(
1287            &self.store.connection,
1288            INDEX_PUBLICATION_STATE_KEY,
1289            IndexPublicationState::Complete.as_str(),
1290        )?;
1291        self.store.connection.execute_batch("COMMIT")?;
1292        self.active = false;
1293        Ok(())
1294    }
1295}
1296
1297impl Drop for IndexPublicationGuard<'_> {
1298    fn drop(&mut self) {
1299        if self.active {
1300            let _rollback_result = self.store.connection.execute_batch("ROLLBACK");
1301        }
1302    }
1303}
1304
1305/// UTF-8 source text persisted for indexed search.
1306#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1307pub struct IndexedFileText {
1308    /// Repository-relative file path using forward slashes.
1309    pub path: String,
1310    /// BLAKE3 content hash from the scanned file node.
1311    pub content_hash: Option<String>,
1312    /// UTF-8 byte count stored for telemetry.
1313    pub byte_count: usize,
1314    /// Number of text lines stored for context extraction.
1315    pub line_count: usize,
1316    /// Full UTF-8 source text used by indexed search.
1317    pub content: String,
1318}
1319
1320/// Bounded safe-token request for FTS candidate acceleration.
1321#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1322pub struct FileTextFtsQuery<'query> {
1323    /// One ASCII-alphanumeric token that exact lexical matching will verify.
1324    pub literal_token: &'query str,
1325    /// Optional exact path-or-descendant scope.
1326    pub path_prefix: Option<&'query str>,
1327    /// Maximum candidates returned before reporting overflow.
1328    pub limit: usize,
1329}
1330
1331/// One FTS-ranked candidate carrying only authoritative persisted metadata.
1332#[derive(Clone, Debug, PartialEq)]
1333pub struct FileTextFtsCandidate {
1334    /// Repository-relative file path used for budgeted content hydration.
1335    pub path: String,
1336    /// BLAKE3 content hash from the authoritative persisted text row.
1337    pub content_hash: Option<String>,
1338    /// Persisted UTF-8 source byte count used for pre-hydration budgets.
1339    pub byte_count: usize,
1340    /// Persisted line count.
1341    pub line_count: usize,
1342    /// `SQLite` FTS5 BM25 score; lower values rank first.
1343    pub bm25: f64,
1344}
1345
1346/// Bounded FTS candidate page for service-owned exact verification.
1347#[derive(Clone, Debug, PartialEq)]
1348pub struct FileTextFtsPage {
1349    /// Candidates ordered by BM25 and repository path.
1350    pub candidates: Vec<FileTextFtsCandidate>,
1351    /// Whether at least one more candidate matched the bound query.
1352    pub overflow: bool,
1353}
1354
1355/// Persisted file metadata available before source-content decoding.
1356#[derive(Clone, Debug, Eq, PartialEq)]
1357pub struct FileTextMetadata {
1358    /// Repository-relative file path using forward slashes.
1359    pub path: String,
1360    /// BLAKE3 content hash from the scanned file node.
1361    pub content_hash: Option<String>,
1362    /// Persisted UTF-8 source byte count.
1363    pub byte_count: usize,
1364    /// Persisted line count.
1365    pub line_count: usize,
1366    /// Closed content role available before source decoding.
1367    pub classification: ContentClassification,
1368}
1369
1370/// Service decision made before one fallback row decodes source content.
1371#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1372pub enum FileTextAdmission {
1373    /// Decode and visit this row's source content.
1374    Read,
1375    /// Continue without decoding this row's source content.
1376    Skip,
1377    /// Stop fallback iteration without decoding this row's source content.
1378    Stop,
1379}
1380
1381/// Content-free synchronization state for the rebuildable FTS projection.
1382#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1383pub struct FileTextFtsState {
1384    /// Authoritative persisted-text rows.
1385    pub source_rows: usize,
1386    /// Document rows currently represented by the FTS projection.
1387    pub indexed_rows: usize,
1388    /// Whether authoritative and projected document identities agree exactly.
1389    pub synchronized: bool,
1390}
1391
1392/// Agent-approved resolution for a deterministic health finding.
1393#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1394pub struct HealthResolution {
1395    /// Stable health finding id.
1396    pub finding_id: String,
1397    /// Finding category.
1398    pub category: String,
1399    /// Primary path.
1400    pub path: String,
1401    /// Related path, when any.
1402    pub related_path: Option<String>,
1403    /// Agent rationale for suppressing future repeats.
1404    pub rationale: String,
1405}
1406
1407/// Bounded health query used by agent-facing adapters.
1408#[derive(Clone, Debug, Eq, PartialEq)]
1409pub struct HealthQuery {
1410    /// Pagination start index after filters are applied.
1411    pub start_index: usize,
1412    /// Maximum findings to return.
1413    pub limit: usize,
1414    /// Optional finding category filter.
1415    pub category: Option<String>,
1416    /// Optional severity filter.
1417    pub severity: Option<Severity>,
1418    /// Optional repository-relative path prefix filter.
1419    pub path_prefix: Option<String>,
1420    /// Return counts without finding rows.
1421    pub summary_only: bool,
1422    /// Health and purpose-curation scope.
1423    pub scope: HealthScope,
1424}
1425
1426/// Resolution ownership used by bounded health queries.
1427#[derive(Clone, Copy)]
1428enum HealthResolutionFilter<'a> {
1429    /// Caller-owned compatibility filter.
1430    Explicit(&'a [String]),
1431    /// Durable resolutions owned by the current project database.
1432    Stored,
1433}
1434
1435/// Scope controls for bounded health and purpose-curation queries.
1436#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1437pub enum HealthScope {
1438    /// Include all indexed paths.
1439    All,
1440    /// Include only source files and folders with source descendants.
1441    SourceOnly,
1442    /// Include all folders plus high-impact files.
1443    PurposeDefault,
1444    /// Include all folders, high-impact files, and non-source files.
1445    PurposeWithAssets,
1446    /// Include all folders, high-impact files, and all source files.
1447    PurposeWithSourceFiles,
1448    /// Include every indexed file and folder.
1449    PurposeStrict,
1450}
1451
1452impl HealthScope {
1453    /// Scope matching unfiltered health output.
1454    pub fn all() -> Self {
1455        Self::All
1456    }
1457
1458    /// Scope restricted to source-relevant paths.
1459    pub fn source_only() -> Self {
1460        Self::SourceOnly
1461    }
1462
1463    /// Default agent purpose curation scope: folders plus high-impact files.
1464    pub fn purpose_default() -> Self {
1465        Self::PurposeDefault
1466    }
1467
1468    /// Purpose curation scope including non-source asset files.
1469    pub fn purpose_with_assets() -> Self {
1470        Self::PurposeWithAssets
1471    }
1472
1473    /// Purpose curation scope including all source files.
1474    pub fn purpose_with_source_files() -> Self {
1475        Self::PurposeWithSourceFiles
1476    }
1477
1478    /// Strict purpose curation scope including every indexed path.
1479    pub fn purpose_strict() -> Self {
1480        Self::PurposeStrict
1481    }
1482
1483    /// Whether this scope should be reported as source-focused in agent payloads.
1484    pub fn is_source_focused(self) -> bool {
1485        self.source_only_filter()
1486    }
1487
1488    /// Whether this scope uses the folder-first high-impact purpose queue.
1489    pub fn is_purpose_queue(self) -> bool {
1490        self.high_impact_queue()
1491    }
1492
1493    /// Whether source relevance should be applied before queue-specific filters.
1494    fn source_only_filter(self) -> bool {
1495        matches!(
1496            self,
1497            Self::SourceOnly | Self::PurposeDefault | Self::PurposeWithSourceFiles
1498        )
1499    }
1500
1501    /// Whether the scope should use folder-first purpose queue selection.
1502    fn high_impact_queue(self) -> bool {
1503        matches!(
1504            self,
1505            Self::PurposeDefault
1506                | Self::PurposeWithAssets
1507                | Self::PurposeWithSourceFiles
1508                | Self::PurposeStrict
1509        )
1510    }
1511
1512    /// Whether non-source asset files should be included in queue selection.
1513    fn include_assets(self) -> bool {
1514        matches!(self, Self::PurposeWithAssets)
1515    }
1516
1517    /// Whether all source files should be included in queue selection.
1518    fn include_source_files(self) -> bool {
1519        matches!(self, Self::PurposeWithSourceFiles | Self::PurposeStrict)
1520    }
1521
1522    /// Whether all files should be included in queue selection.
1523    fn include_all_files(self) -> bool {
1524        matches!(self, Self::PurposeStrict)
1525    }
1526}
1527
1528/// Bounded health findings page returned by the database layer.
1529#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1530pub struct HealthFindingsPage {
1531    /// Findings after filters are applied.
1532    pub total: usize,
1533    /// Findings before filters are applied, after resolved findings are removed.
1534    pub unfiltered_total: usize,
1535    /// Findings returned in this page.
1536    pub returned: usize,
1537    /// Pagination start index used for this page.
1538    pub start_index: usize,
1539    /// Maximum findings requested for this page.
1540    pub limit: usize,
1541    /// Returned health finding rows.
1542    pub findings: Vec<HealthFinding>,
1543}
1544
1545/// Static metadata for one purpose lifecycle health category.
1546#[derive(Clone, Copy, Debug)]
1547struct PurposeHealthSpec {
1548    /// Stored purpose status that emits this health category.
1549    status: &'static str,
1550    /// Health finding category for the lifecycle status.
1551    category: &'static str,
1552    /// Health finding message for every row in this lifecycle category.
1553    message: &'static str,
1554    /// Agent recommendation for resolving this lifecycle category.
1555    recommendation: &'static str,
1556}
1557
1558/// Purpose lifecycle health categories that can be paged directly in `SQLite`.
1559const PURPOSE_HEALTH_SPECS: [PurposeHealthSpec; 3] = [
1560    PurposeHealthSpec {
1561        status: PurposeStatus::Missing.as_str(),
1562        category: CATEGORY_MISSING_PURPOSE,
1563        message: MESSAGE_MISSING_PURPOSE,
1564        recommendation: RECOMMENDATION_MISSING_PURPOSE_QUEUE,
1565    },
1566    PurposeHealthSpec {
1567        status: PurposeStatus::Suggested.as_str(),
1568        category: CATEGORY_SUGGESTED_PURPOSE_REVIEW,
1569        message: MESSAGE_SUGGESTED_PURPOSE_REVIEW,
1570        recommendation: RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW_QUEUE,
1571    },
1572    PurposeHealthSpec {
1573        status: PurposeStatus::Stale.as_str(),
1574        category: CATEGORY_STALE_PURPOSE,
1575        message: MESSAGE_STALE_PURPOSE,
1576        recommendation: RECOMMENDATION_STALE_PURPOSE,
1577    },
1578];
1579
1580/// Run one standalone write only while the captured schema and project binding still match.
1581#[cfg(test)]
1582fn with_validated_write_transaction<T>(
1583    connection: &Connection,
1584    expected_root: Option<&str>,
1585    expected_identity: Option<ProjectInstanceId>,
1586    operation: impl FnOnce(&Connection) -> DbResult<T>,
1587) -> DbResult<T> {
1588    let transaction =
1589        rusqlite::Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?;
1590    let result = schema::validate_active_binding(&transaction, expected_root, expected_identity)
1591        .and_then(|()| operation(&transaction));
1592    match result {
1593        Ok(value) => {
1594            transaction.commit()?;
1595            Ok(value)
1596        }
1597        Err(operation) => match transaction.rollback() {
1598            Ok(()) => Err(operation),
1599            Err(rollback) => Err(DbError::TransactionRollback {
1600                operation: Box::new(operation),
1601                rollback,
1602            }),
1603        },
1604    }
1605}
1606
1607/// Run one standalone write while the captured native root and project binding still match.
1608fn with_validated_native_write_transaction<T>(
1609    connection: &Connection,
1610    expected_root: Option<&CanonicalProjectRoot>,
1611    expected_identity: Option<ProjectInstanceId>,
1612    operation: impl FnOnce(&Connection) -> DbResult<T>,
1613) -> DbResult<T> {
1614    let transaction =
1615        rusqlite::Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?;
1616    let result =
1617        schema::validate_active_native_binding(&transaction, expected_root, expected_identity)
1618            .and_then(|()| operation(&transaction));
1619    match result {
1620        Ok(value) => {
1621            transaction.commit()?;
1622            Ok(value)
1623        }
1624        Err(operation) => match transaction.rollback() {
1625            Ok(()) => Err(operation),
1626            Err(rollback) => Err(DbError::TransactionRollback {
1627                operation: Box::new(operation),
1628                rollback,
1629            }),
1630        },
1631    }
1632}
1633
1634impl AtlasStore {
1635    /// Reject mutations while this connection owns an ordinary read snapshot.
1636    fn require_mutation_scope(&self) -> DbResult<()> {
1637        if self.read_snapshot_active.get() {
1638            Err(DbError::IndexReadSnapshotActive)
1639        } else {
1640            Ok(())
1641        }
1642    }
1643
1644    /// Open a nested-capable write scope after validating the active binding.
1645    fn validated_savepoint(&mut self) -> DbResult<rusqlite::Savepoint<'_>> {
1646        self.require_mutation_scope()?;
1647        let validate_binding = self.connection.is_autocommit();
1648        let expected_root_identity = self.validated_project_root_identity.clone();
1649        let expected_identity = self.validated_project_instance_id;
1650        let savepoint = self.connection.savepoint()?;
1651        if validate_binding {
1652            schema::validate_active_native_binding(
1653                &savepoint,
1654                expected_root_identity.as_ref(),
1655                expected_identity,
1656            )?;
1657        }
1658        Ok(savepoint)
1659    }
1660
1661    /// Run one atomic standalone write, reusing an already validated parent transaction.
1662    fn with_validated_write<T>(
1663        &self,
1664        operation: impl FnOnce(&Connection) -> DbResult<T>,
1665    ) -> DbResult<T> {
1666        self.require_mutation_scope()?;
1667        if !self.connection.is_autocommit() {
1668            return operation(&self.connection);
1669        }
1670        with_validated_native_write_transaction(
1671            &self.connection,
1672            self.validated_project_root_identity.as_ref(),
1673            self.validated_project_instance_id,
1674            operation,
1675        )
1676    }
1677
1678    /// Begin one purpose mutation whose caller owns final source revalidation.
1679    ///
1680    /// Purpose writes performed through this store join the returned immediate
1681    /// transaction. The caller must revalidate its saved-source witness before
1682    /// committing; an early return drops the guard and rolls the complete batch
1683    /// back.
1684    ///
1685    /// # Errors
1686    ///
1687    /// Returns an error when mutation is unavailable, the binding changed, or
1688    /// `SQLite` cannot start the transaction.
1689    pub fn begin_purpose_mutation(&self) -> DbResult<PurposeMutationTransaction<'_>> {
1690        self.require_mutation_scope()?;
1691        let transaction =
1692            rusqlite::Transaction::new_unchecked(&self.connection, TransactionBehavior::Immediate)?;
1693        schema::validate_active_native_binding(
1694            &transaction,
1695            self.validated_project_root_identity.as_ref(),
1696            self.validated_project_instance_id,
1697        )?;
1698        Ok(PurposeMutationTransaction { transaction })
1699    }
1700
1701    /// Run telemetry against this store's exact captured database binding.
1702    ///
1703    /// File-backed stores use a separate short-lived writable connection with
1704    /// an ancillary fail-fast busy budget. Read-only navigation stores release
1705    /// their read snapshot first. The connection is never discovered, attached,
1706    /// substituted, or shared with another project.
1707    fn with_telemetry_connection<T>(
1708        &self,
1709        operation: impl FnOnce(&Connection) -> DbResult<T>,
1710    ) -> DbResult<T> {
1711        if self.read_only {
1712            self.finish_index_read_snapshot()?;
1713        } else {
1714            self.require_mutation_scope()?;
1715        }
1716        let (Some(path), Some(location)) =
1717            (self.database_path.as_ref(), self.database_location.as_ref())
1718        else {
1719            if self.database_path.is_none() && self.database_location.is_none() {
1720                return operation(&self.connection);
1721            }
1722            return Err(DbError::TelemetryPathUnavailable);
1723        };
1724        let _ = schema::preflight(path, None)?;
1725        let connection = open_writable_connection(
1726            path,
1727            OpenFlags::SQLITE_OPEN_READ_WRITE,
1728            location,
1729            SQLITE_TELEMETRY_BUSY_TIMEOUT,
1730            JournalModePolicy::RequireWal,
1731        )?;
1732        operation(&connection)
1733    }
1734
1735    /// Open or create an index store.
1736    ///
1737    /// # Errors
1738    ///
1739    /// Returns an error if `SQLite` setup or schema validation fails.
1740    pub fn open(path: &Path) -> DbResult<Self> {
1741        Self::open_with_project_root(path, None)
1742    }
1743
1744    /// Open or create an index store owned by one canonical project root.
1745    ///
1746    /// Existing databases bound to another root are rejected before writable
1747    /// access. A genuinely fresh database records the supplied root in the
1748    /// same transaction that creates its schema.
1749    ///
1750    /// # Errors
1751    ///
1752    /// Returns an error if read-only compatibility preflight, root validation,
1753    /// transactional migration, or `SQLite` setup fails.
1754    pub fn open_for_project(path: &Path, root: &Path) -> DbResult<Self> {
1755        let expected_identity = CanonicalProjectRoot::from_path(root)?;
1756        Self::open_with_binding_requirement(
1757            path,
1758            None,
1759            Some(&expected_identity),
1760            ProjectIdentityRequirement::Required,
1761            StoreInitializationPolicy::AllowFresh,
1762        )
1763    }
1764
1765    /// Open an initialized project store, migrating a supported predecessor when needed.
1766    ///
1767    /// Fresh storage is refused during read-only preflight, before writable setup.
1768    /// Supported migrations recheck schema-creation authority inside their writer transaction.
1769    /// Existing native root and instance identity checks retain their ordinary guarantees.
1770    ///
1771    /// # Errors
1772    ///
1773    /// Returns [`DbError::InitializationRequired`] for fresh storage, or the normal
1774    /// compatibility, binding, migration and storage errors for an existing schema.
1775    pub fn open_existing_for_project(path: &Path, root: &Path) -> DbResult<Self> {
1776        let expected_identity = CanonicalProjectRoot::from_path(root)?;
1777        Self::open_with_binding_requirement(
1778            path,
1779            None,
1780            Some(&expected_identity),
1781            ProjectIdentityRequirement::Required,
1782            StoreInitializationPolicy::ExistingSchema,
1783        )
1784    }
1785
1786    /// Open with an optional source-owned project identity.
1787    fn open_with_project_root(path: &Path, expected_root: Option<&str>) -> DbResult<Self> {
1788        Self::open_with_binding_requirement(
1789            path,
1790            expected_root,
1791            None,
1792            ProjectIdentityRequirement::Required,
1793            StoreInitializationPolicy::AllowFresh,
1794        )
1795    }
1796
1797    /// Open for an explicit root transition that owns identity repair.
1798    fn open_for_root_transition(path: &Path) -> DbResult<Self> {
1799        Self::open_with_binding_requirement(
1800            path,
1801            None,
1802            None,
1803            ProjectIdentityRequirement::TransitionOwned,
1804            StoreInitializationPolicy::AllowFresh,
1805        )
1806    }
1807
1808    /// Open with the root and identity validation required by the caller.
1809    fn open_with_binding_requirement(
1810        path: &Path,
1811        expected_root: Option<&str>,
1812        expected_identity: Option<&CanonicalProjectRoot>,
1813        identity_requirement: ProjectIdentityRequirement,
1814        initialization: StoreInitializationPolicy,
1815    ) -> DbResult<Self> {
1816        let (preflight, location) = if let Some(expected_identity) = expected_identity {
1817            schema::preflight_for_project(path, expected_identity)?
1818        } else {
1819            schema::preflight(path, None)?
1820        };
1821        if initialization == StoreInitializationPolicy::ExistingSchema
1822            && preflight.state == SchemaState::Fresh
1823        {
1824            return Err(DbError::InitializationRequired);
1825        }
1826        if expected_identity.is_none()
1827            && identity_requirement.is_required()
1828            && preflight.state == SchemaState::UpgradeRequired
1829            && schema::legacy_root_requires_native_authority(preflight.project_root.as_deref())
1830        {
1831            // Rootless predecessor migration has no native caller authority
1832            // with which to identify a lossy legacy display. Refuse before
1833            // opening a writable connection or creating WAL state.
1834            return Err(DbError::ProjectRootIdentityMissing);
1835        }
1836        let validated_project_root = expected_identity
1837            .and_then(|identity| identity.display_string().ok())
1838            .or_else(|| expected_root.map(str::to_owned))
1839            .or_else(|| {
1840                expected_identity
1841                    .is_none()
1842                    .then(|| preflight.project_root.clone())
1843                    .flatten()
1844            });
1845        if preflight.state == SchemaState::Current
1846            && expected_identity.is_some()
1847            && identity_requirement.is_required()
1848            && preflight.project_instance_id.is_none()
1849        {
1850            return Err(DbError::ProjectInstanceIdentityMissing);
1851        }
1852        let connection = open_writable_connection(
1853            path,
1854            writable_open_flags(preflight.state, location.database_exists),
1855            &location,
1856            SQLITE_BUSY_TIMEOUT,
1857            writable_journal_policy(preflight.state),
1858        )?;
1859        let transition_transaction = preflight.state == SchemaState::UpgradeRequired
1860            && identity_requirement == ProjectIdentityRequirement::TransitionOwned;
1861        if transition_transaction {
1862            connection.execute_batch("BEGIN IMMEDIATE")?;
1863        }
1864        let opened = (|| {
1865            if preflight.state == SchemaState::Current
1866                && let Some(identity) = expected_identity
1867            {
1868                project_identity::ensure_project_root_identity(&connection, identity)?;
1869            }
1870            let validated_project_instance_id = if preflight.state == SchemaState::Current {
1871                if let Some(expected_identity) = expected_identity {
1872                    schema::revalidate_current_native_binding(
1873                        &connection,
1874                        expected_identity,
1875                        identity_requirement.is_required(),
1876                    )?
1877                } else if identity_requirement == ProjectIdentityRequirement::TransitionOwned {
1878                    // Move/Detach revalidate the recorded native identity and
1879                    // transition state in apply_root_transition. Their recorded
1880                    // root may intentionally be absent, so do not run the
1881                    // existing-root admission proof here.
1882                    project_identity::load_project_identity(&connection)?
1883                } else if let Some(stored_identity) =
1884                    project_identity::load_project_root_identity(&connection)?
1885                {
1886                    schema::revalidate_current_native_binding(
1887                        &connection,
1888                        &stored_identity,
1889                        identity_requirement.is_required(),
1890                    )?
1891                } else {
1892                    let stored_instance_id = project_identity::load_project_identity(&connection)?;
1893                    schema::validate_binding_completeness(
1894                        validated_project_root.as_deref(),
1895                        stored_instance_id,
1896                        identity_requirement.is_required(),
1897                    )?;
1898                    if stored_instance_id.is_some() {
1899                        return Err(DbError::ProjectRootIdentityMissing);
1900                    }
1901                    None
1902                }
1903            } else {
1904                if let Some(expected_identity) = expected_identity {
1905                    let expected_display = expected_identity.display_string().ok();
1906                    match initialization {
1907                        StoreInitializationPolicy::AllowFresh => {
1908                            schema::initialize_with_project_root(
1909                                &connection,
1910                                expected_display.as_deref(),
1911                                Some(expected_identity),
1912                            )?;
1913                        }
1914                        StoreInitializationPolicy::ExistingSchema => {
1915                            schema::migrate_existing_with_project_root(
1916                                &connection,
1917                                expected_display.as_deref(),
1918                                Some(expected_identity),
1919                            )?;
1920                        }
1921                    }
1922                } else {
1923                    let initialization_root = expected_root.or(preflight.project_root.as_deref());
1924                    if transition_transaction {
1925                        schema::initialize_with_project_root_in_transaction(
1926                            &connection,
1927                            initialization_root,
1928                            None,
1929                        )?;
1930                    } else {
1931                        schema::initialize_with_project_root(
1932                            &connection,
1933                            initialization_root,
1934                            None,
1935                        )?;
1936                    }
1937                }
1938                project_identity::load_project_identity(&connection)?
1939            };
1940            let validated_project_root_identity =
1941                project_identity::load_project_root_identity(&connection)?;
1942            if identity_requirement.is_required()
1943                && (validated_project_root.is_some() || validated_project_root_identity.is_some())
1944                && validated_project_instance_id.is_none()
1945            {
1946                return Err(DbError::ProjectInstanceIdentityMissing);
1947            }
1948            let database_location = if location.database_exists {
1949                location
1950            } else {
1951                sqlite_profile::inspect_database_location(path)?
1952            };
1953            Ok((
1954                database_location,
1955                validated_project_root,
1956                validated_project_root_identity,
1957                validated_project_instance_id,
1958            ))
1959        })();
1960        if transition_transaction {
1961            match opened {
1962                Ok((
1963                    database_location,
1964                    validated_project_root,
1965                    validated_project_root_identity,
1966                    validated_project_instance_id,
1967                )) => Ok(Self {
1968                    connection,
1969                    read_snapshot_active: Cell::new(false),
1970                    database_path: Some(path.to_path_buf()),
1971                    database_location: Some(database_location),
1972                    read_only: false,
1973                    validated_project_root,
1974                    validated_project_root_identity,
1975                    validated_project_instance_id,
1976                    library_usage_instances: RefCell::new(HashMap::new()),
1977                }),
1978                Err(error) => Err(schema::rollback_after_error(&connection, error)),
1979            }
1980        } else {
1981            let (
1982                database_location,
1983                validated_project_root,
1984                validated_project_root_identity,
1985                validated_project_instance_id,
1986            ) = opened?;
1987            Ok(Self {
1988                connection,
1989                read_snapshot_active: Cell::new(false),
1990                database_path: Some(path.to_path_buf()),
1991                database_location: Some(database_location),
1992                read_only: false,
1993                validated_project_root,
1994                validated_project_root_identity,
1995                validated_project_instance_id,
1996                library_usage_instances: RefCell::new(HashMap::new()),
1997            })
1998        }
1999    }
2000
2001    /// Open an existing index without creating, migrating, or backfilling it.
2002    ///
2003    /// # Errors
2004    ///
2005    /// Returns an error if the database cannot be opened read-only or its
2006    /// schema version is not exactly supported by this runtime.
2007    pub fn open_read_only(path: &Path) -> DbResult<Self> {
2008        Self::open_read_only_with_project_root(path, None)
2009    }
2010
2011    /// Open one current read snapshot owned by a canonical project root.
2012    ///
2013    /// # Errors
2014    ///
2015    /// Returns an error if the database is incompatible, belongs to another
2016    /// root, or cannot be opened without database mutation.
2017    pub fn open_read_only_for_project(path: &Path, root: &Path) -> DbResult<Self> {
2018        let expected_identity = CanonicalProjectRoot::from_path(root)?;
2019        Self::open_read_only_with_project_root(path, Some(&expected_identity))
2020    }
2021
2022    /// Return whether this store is restricted to non-mutating queries.
2023    #[must_use]
2024    pub const fn is_read_only(&self) -> bool {
2025        self.read_only
2026    }
2027
2028    /// Return whether this store currently owns an open read snapshot.
2029    #[must_use]
2030    pub fn has_active_read_snapshot(&self) -> bool {
2031        self.read_snapshot_active.get()
2032    }
2033
2034    /// Open a current read snapshot with optional project identity validation.
2035    fn open_read_only_with_project_root(
2036        path: &Path,
2037        expected_identity: Option<&CanonicalProjectRoot>,
2038    ) -> DbResult<Self> {
2039        let (connection, preflight) = schema::open_current_read_only(path, None)?;
2040        let validated_project_instance_id = project_identity::load_project_identity(&connection)?;
2041        let validated_project_root_identity =
2042            project_identity::load_project_root_identity(&connection)?;
2043        if expected_identity.is_some() && validated_project_root_identity.is_none() {
2044            return Err(DbError::ProjectRootIdentityMissing);
2045        }
2046        if let (Some(expected), Some(found)) =
2047            (expected_identity, validated_project_root_identity.as_ref())
2048        {
2049            project_identity::prove_existing_root_equivalence(expected.as_path(), found.as_path())?;
2050        }
2051        if validated_project_root_identity.is_none() {
2052            schema::validate_binding_completeness(
2053                preflight.project_root.as_deref(),
2054                validated_project_instance_id,
2055                true,
2056            )?;
2057        } else if validated_project_instance_id.is_none() {
2058            return Err(DbError::ProjectInstanceIdentityMissing);
2059        }
2060        let validated_project_root = match validated_project_root_identity.as_ref() {
2061            Some(identity) => identity.display_string().ok(),
2062            None => preflight.project_root,
2063        };
2064        let database_location = sqlite_profile::inspect_database_location(path)?;
2065        Ok(Self {
2066            connection,
2067            read_snapshot_active: Cell::new(true),
2068            database_path: Some(path.to_path_buf()),
2069            database_location: Some(database_location),
2070            read_only: true,
2071            validated_project_root,
2072            validated_project_root_identity,
2073            validated_project_instance_id,
2074            library_usage_instances: RefCell::new(HashMap::new()),
2075        })
2076    }
2077
2078    /// Open an in-memory store for tests.
2079    ///
2080    /// # Errors
2081    ///
2082    /// Returns an error if schema setup fails.
2083    pub fn in_memory() -> DbResult<Self> {
2084        let store = Self {
2085            connection: Connection::open_in_memory()?,
2086            read_snapshot_active: Cell::new(false),
2087            database_path: None,
2088            database_location: None,
2089            read_only: false,
2090            validated_project_root: None,
2091            validated_project_root_identity: None,
2092            validated_project_instance_id: None,
2093            library_usage_instances: RefCell::new(HashMap::new()),
2094        };
2095        schema::initialize(&store.connection, None)?;
2096        Ok(store)
2097    }
2098
2099    /// Validate, initialize, or migrate the schema through the storage owner.
2100    ///
2101    /// # Errors
2102    ///
2103    /// Returns an error when schema compatibility, integrity, or migration fails.
2104    pub fn initialize_schema(&self) -> DbResult<()> {
2105        schema::initialize(&self.connection, None)
2106    }
2107
2108    /// Upsert a full scan result and mark previously seen missing paths absent.
2109    ///
2110    /// # Errors
2111    ///
2112    /// Returns an error if persistence fails.
2113    pub fn replace_scan(&mut self, nodes: &[Node]) -> DbResult<()> {
2114        let savepoint = self.validated_savepoint()?;
2115        mark_all_scan_nodes_absent(&savepoint)?;
2116        upsert_nodes(&savepoint, nodes)?;
2117        delete_absent_scan_projections(&savepoint)?;
2118        savepoint.commit()?;
2119        Ok(())
2120    }
2121
2122    /// Upsert a partial scan result without marking unrelated paths absent.
2123    ///
2124    /// # Errors
2125    ///
2126    /// Returns an error if persistence fails.
2127    pub fn upsert_scan_nodes(&mut self, nodes: &[Node]) -> DbResult<()> {
2128        let savepoint = self.validated_savepoint()?;
2129        upsert_nodes(&savepoint, nodes)?;
2130        savepoint.commit()?;
2131        Ok(())
2132    }
2133
2134    /// Mark paths and their descendants absent after filesystem delete events.
2135    ///
2136    /// # Errors
2137    ///
2138    /// Returns an error if persistence fails.
2139    pub fn mark_paths_absent(&mut self, paths: &[String]) -> DbResult<()> {
2140        let savepoint = self.validated_savepoint()?;
2141        let fts_revision = begin_file_text_fts_update(&savepoint)?;
2142        {
2143            let mut mark_nodes = savepoint.prepare_cached(
2144                "UPDATE nodes SET exists_now = 0 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2145            )?;
2146            let mut delete_classifications = savepoint.prepare_cached(
2147                "DELETE FROM file_content_classifications
2148                  WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2149            )?;
2150            let mut delete_relations = savepoint.prepare_cached(
2151                "DELETE FROM symbol_relations WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2152            )?;
2153            let mut delete_symbols = savepoint.prepare_cached(
2154                "DELETE FROM symbols WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2155            )?;
2156            let mut delete_parse_metadata = savepoint.prepare_cached(
2157                "DELETE FROM source_parse_metadata WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2158            )?;
2159            let mut delete_text_fts = savepoint.prepare_cached(
2160                "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
2161                 SELECT 'delete', rowid, content FROM file_texts \
2162                 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2163            )?;
2164            let mut delete_text = savepoint.prepare_cached(
2165                "DELETE FROM file_texts WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2166            )?;
2167            for path in paths {
2168                if path == "." || path.is_empty() {
2169                    continue;
2170                }
2171                let descendant_pattern = sqlite_descendant_pattern(path);
2172                delete_classifications.execute(params![path, descendant_pattern])?;
2173                mark_nodes.execute(params![path, descendant_pattern])?;
2174                delete_relations.execute(params![path, descendant_pattern])?;
2175                delete_symbols.execute(params![path, descendant_pattern])?;
2176                delete_parse_metadata.execute(params![path, descendant_pattern])?;
2177                delete_text_fts.execute(params![path, descendant_pattern])?;
2178                delete_text.execute(params![path, descendant_pattern])?;
2179            }
2180        }
2181        complete_file_text_fts_update(&savepoint, fts_revision)?;
2182        savepoint.commit()?;
2183        Ok(())
2184    }
2185
2186    /// Replace indexed text for scanned file paths.
2187    ///
2188    /// `paths` should contain every file path considered by the scan batch.
2189    /// Existing indexed text for those paths is cleared first so binary,
2190    /// deleted, or no-longer-UTF-8 files cannot leave stale searchable content.
2191    ///
2192    /// # Errors
2193    ///
2194    /// Returns an error if persistence fails.
2195    pub fn replace_file_texts_for_paths<'text>(
2196        &mut self,
2197        paths: &[String],
2198        texts: impl IntoIterator<Item = &'text IndexedFileText>,
2199    ) -> DbResult<()> {
2200        let texts = texts.into_iter().collect::<Vec<_>>();
2201        for text in &texts {
2202            validate_indexed_file_text(text)?;
2203        }
2204        let savepoint = self.validated_savepoint()?;
2205        let fts_revision = begin_file_text_fts_update(&savepoint)?;
2206        {
2207            let mut delete_fts = savepoint.prepare_cached(
2208                "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
2209                 SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
2210            )?;
2211            let mut delete = savepoint.prepare_cached("DELETE FROM file_texts WHERE path = ?1")?;
2212            for path in paths {
2213                delete_fts.execute([path])?;
2214                delete.execute([path])?;
2215            }
2216        }
2217        {
2218            let mut delete_current_fts = savepoint.prepare_cached(
2219                "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
2220                 SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
2221            )?;
2222            let mut upsert = savepoint.prepare_cached(
2223                "
2224                INSERT INTO file_texts(path, content_hash, byte_count, line_count, content, updated_at)
2225                VALUES(?1, ?2, ?3, ?4, ?5, CURRENT_TIMESTAMP)
2226                ON CONFLICT(path) DO UPDATE SET
2227                    content_hash = excluded.content_hash,
2228                    byte_count = excluded.byte_count,
2229                    line_count = excluded.line_count,
2230                    content = excluded.content,
2231                    updated_at = CURRENT_TIMESTAMP
2232                ",
2233            )?;
2234            let mut index = savepoint.prepare_cached(
2235                "INSERT INTO file_text_fts(rowid, content) \
2236                 SELECT rowid, content FROM file_texts WHERE path = ?1",
2237            )?;
2238            for text in texts {
2239                delete_current_fts.execute([&text.path])?;
2240                upsert.execute(params![
2241                    text.path,
2242                    text.content_hash.as_deref(),
2243                    usize_to_i64(text.byte_count),
2244                    usize_to_i64(text.line_count),
2245                    text.content
2246                ])?;
2247                index.execute([&text.path])?;
2248            }
2249        }
2250        complete_file_text_fts_update(&savepoint, fts_revision)?;
2251        savepoint.commit()?;
2252        Ok(())
2253    }
2254
2255    /// Load one indexed text row by repository path.
2256    ///
2257    /// # Errors
2258    ///
2259    /// Returns an error if reading fails or stored counts are invalid.
2260    pub fn load_file_text(&self, path: &str) -> DbResult<Option<IndexedFileText>> {
2261        let mut statement = self.connection.prepare_cached(
2262            "
2263            SELECT path, content_hash, byte_count, line_count, content
2264            FROM file_texts
2265            WHERE path = ?1
2266            ",
2267        )?;
2268        let mut rows = statement.query([path])?;
2269        rows.next()?.map(file_text_from_row).transpose()
2270    }
2271
2272    /// Load a bounded FTS candidate superset for service-owned exact verification.
2273    ///
2274    /// The request accepts only one safe ASCII-alphanumeric token. The MATCH
2275    /// expression is bound as data. Only authoritative metadata is returned;
2276    /// callers hydrate selected paths with [`Self::load_file_text`] after
2277    /// applying their file, byte, and time budgets.
2278    ///
2279    /// # Errors
2280    ///
2281    /// Returns an error when the token shape or limit is unsafe, bounded work
2282    /// is canceled or reaches its deadline, or persisted rows are invalid.
2283    pub fn query_file_text_fts_candidates(
2284        &self,
2285        query: &FileTextFtsQuery<'_>,
2286        control: Option<&IndexWorkControl>,
2287    ) -> DbResult<FileTextFtsPage> {
2288        validate_file_text_fts_token(query.literal_token)?;
2289        if query.limit > MAX_FILE_TEXT_FTS_CANDIDATES {
2290            return Err(DbError::FileTextFtsCandidateLimit {
2291                requested: query.limit,
2292                maximum: MAX_FILE_TEXT_FTS_CANDIDATES,
2293            });
2294        }
2295        let match_expression = format!("\"{}\"", query.literal_token);
2296        let row_limit = usize_to_i64(query.limit.saturating_add(1));
2297        with_file_text_progress(&self.connection, control, || {
2298            let mut candidates = Vec::with_capacity(query.limit.saturating_add(1));
2299            if let Some(path_prefix) = normalized_file_text_path_prefix(query.path_prefix) {
2300                let descendant_pattern = sqlite_descendant_pattern(path_prefix);
2301                let mut statement = self.connection.prepare_cached(
2302                    "
2303                    SELECT
2304                        f.path,
2305                        f.content_hash,
2306                        f.byte_count,
2307                        f.line_count,
2308                        bm25(file_text_fts)
2309                    FROM file_text_fts
2310                    JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
2311                    WHERE file_text_fts MATCH ?1
2312                      AND (f.path = ?2 OR f.path LIKE ?3 ESCAPE '\\')
2313                    ORDER BY bm25(file_text_fts), f.path
2314                    LIMIT ?4
2315                    ",
2316                )?;
2317                let mut rows = statement.query(params![
2318                    match_expression,
2319                    path_prefix,
2320                    descendant_pattern,
2321                    row_limit,
2322                ])?;
2323                collect_file_text_fts_candidates(&mut rows, control, &mut candidates)?;
2324            } else {
2325                let mut statement = self.connection.prepare_cached(
2326                    "
2327                    SELECT
2328                        f.path,
2329                        f.content_hash,
2330                        f.byte_count,
2331                        f.line_count,
2332                        bm25(file_text_fts)
2333                    FROM file_text_fts
2334                    JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
2335                    WHERE file_text_fts MATCH ?1
2336                    ORDER BY bm25(file_text_fts), f.path
2337                    LIMIT ?2
2338                    ",
2339                )?;
2340                let mut rows = statement.query(params![match_expression, row_limit])?;
2341                collect_file_text_fts_candidates(&mut rows, control, &mut candidates)?;
2342            }
2343            let overflow = candidates.len() > query.limit;
2344            candidates.truncate(query.limit);
2345            Ok(FileTextFtsPage {
2346                candidates,
2347                overflow,
2348            })
2349        })
2350    }
2351
2352    /// Visit correctness-authoritative persisted text with predecode admission.
2353    ///
2354    /// `admit` receives path and byte metadata before the source `String` is
2355    /// decoded from `SQLite`. Returning [`FileTextAdmission::Skip`] or
2356    /// [`FileTextAdmission::Stop`] therefore avoids allocating excluded source
2357    /// content. Returning `false` from `visitor` stops after an admitted row.
2358    ///
2359    /// # Errors
2360    ///
2361    /// Returns an error when bounded work is canceled or reaches its deadline,
2362    /// persisted counts are invalid, or either callback returns an error.
2363    pub fn visit_file_texts_for_fallback<A, V>(
2364        &self,
2365        path_prefix: Option<&str>,
2366        control: Option<&IndexWorkControl>,
2367        mut admit: A,
2368        mut visitor: V,
2369    ) -> DbResult<()>
2370    where
2371        A: FnMut(&FileTextMetadata) -> DbResult<FileTextAdmission>,
2372        V: FnMut(IndexedFileText) -> DbResult<bool>,
2373    {
2374        with_file_text_progress(&self.connection, control, || {
2375            let mut content_statement = self
2376                .connection
2377                .prepare_cached(FILE_TEXT_FALLBACK_CONTENT_SQL)?;
2378            if let Some(path_prefix) = normalized_file_text_path_prefix(path_prefix) {
2379                let (descendant_start, descendant_end) = file_text_descendant_range(path_prefix);
2380                let mut statement = self
2381                    .connection
2382                    .prepare_cached(FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL)?;
2383                let mut rows =
2384                    statement.query(params![path_prefix, descendant_start, descendant_end])?;
2385                visit_file_text_fallback_rows(
2386                    &mut rows,
2387                    &mut content_statement,
2388                    control,
2389                    &mut admit,
2390                    &mut visitor,
2391                )
2392            } else {
2393                let mut statement = self
2394                    .connection
2395                    .prepare_cached(FILE_TEXT_FALLBACK_ALL_METADATA_SQL)?;
2396                let mut rows = statement.query([])?;
2397                visit_file_text_fallback_rows(
2398                    &mut rows,
2399                    &mut content_statement,
2400                    control,
2401                    &mut admit,
2402                    &mut visitor,
2403                )
2404            }
2405        })
2406    }
2407
2408    /// Return whether the transactional FTS projection matches authoritative text.
2409    ///
2410    /// This constant-work readiness check is suitable for the search hot path.
2411    /// Explicit settings diagnostics may additionally count projection rows.
2412    ///
2413    /// # Errors
2414    ///
2415    /// Returns an error when durable revision metadata is malformed or partial.
2416    pub fn file_text_fts_ready(&self) -> DbResult<bool> {
2417        Ok(load_file_text_fts_revisions(&self.connection)?
2418            .is_some_and(|(source, projection)| source == projection))
2419    }
2420
2421    /// Report content-free FTS synchronization state for settings/readiness.
2422    ///
2423    /// # Errors
2424    ///
2425    /// Returns an error when authoritative or projection row state cannot be
2426    /// inspected exactly.
2427    pub fn file_text_fts_state(&self) -> DbResult<FileTextFtsState> {
2428        let revision_synchronized = self.file_text_fts_ready()?;
2429        let (source_rows, indexed_rows, synchronized) = self.connection.query_row(
2430            "
2431            SELECT
2432                (SELECT COUNT(*) FROM file_texts),
2433                (SELECT COUNT(*) FROM file_text_fts_docsize),
2434                NOT EXISTS(
2435                    SELECT 1
2436                    FROM file_texts AS f
2437                    LEFT JOIN file_text_fts_docsize AS d ON d.id = f.rowid
2438                    WHERE d.id IS NULL
2439                )
2440                AND NOT EXISTS(
2441                    SELECT 1
2442                    FROM file_text_fts_docsize AS d
2443                    LEFT JOIN file_texts AS f ON f.rowid = d.id
2444                    WHERE f.rowid IS NULL
2445                )
2446            ",
2447            [],
2448            |row| {
2449                Ok((
2450                    row.get::<_, i64>(0)?,
2451                    row.get::<_, i64>(1)?,
2452                    row.get::<_, i64>(2)? != 0,
2453                ))
2454            },
2455        )?;
2456        Ok(FileTextFtsState {
2457            source_rows: count_to_usize("file_texts", source_rows)?,
2458            indexed_rows: count_to_usize("file_text_fts", indexed_rows)?,
2459            synchronized: revision_synchronized && synchronized,
2460        })
2461    }
2462
2463    /// Load indexed text rows for search.
2464    ///
2465    /// For a nonempty case-sensitive `literal_pattern`, `SQLite` prefilters
2466    /// candidate files with an exact substring search. Case-insensitive visits
2467    /// admit all rows because SQLite's built-in `lower()` is ASCII-only.
2468    /// Regex and fuzzy searches pass `None` and still use the persisted text
2469    /// index instead of reopening source files from disk.
2470    ///
2471    /// # Errors
2472    ///
2473    /// Returns an error if reading fails or stored counts are invalid.
2474    pub fn load_file_texts_for_search(
2475        &self,
2476        literal_pattern: Option<&str>,
2477        case_sensitive: bool,
2478    ) -> DbResult<Vec<IndexedFileText>> {
2479        let mut texts = Vec::new();
2480        self.visit_file_texts_for_search(literal_pattern, case_sensitive, |text| {
2481            texts.push(text);
2482            Ok(true)
2483        })?;
2484        Ok(texts)
2485    }
2486
2487    /// Visit indexed text rows for search without materializing all rows.
2488    ///
2489    /// For a nonempty case-sensitive `literal_pattern`, `SQLite` prefilters
2490    /// candidate files with an exact substring search. Case-insensitive visits
2491    /// admit all rows because SQLite's built-in `lower()` is ASCII-only.
2492    /// Returning `false` from `visitor` stops iteration early.
2493    ///
2494    /// # Errors
2495    ///
2496    /// Returns an error if reading fails, stored counts are invalid, or the
2497    /// visitor returns an error.
2498    pub fn visit_file_texts_for_search<F>(
2499        &self,
2500        literal_pattern: Option<&str>,
2501        case_sensitive: bool,
2502        mut visitor: F,
2503    ) -> DbResult<()>
2504    where
2505        F: FnMut(IndexedFileText) -> DbResult<bool>,
2506    {
2507        if let Some(pattern) =
2508            literal_pattern.filter(|pattern| case_sensitive && !pattern.is_empty())
2509        {
2510            let mut statement = self.connection.prepare(
2511                "
2512                SELECT path, content_hash, byte_count, line_count, content
2513                FROM file_texts
2514                WHERE instr(content, ?1) > 0
2515                ORDER BY path
2516                ",
2517            )?;
2518            let mut rows = statement.query([pattern])?;
2519            while let Some(row) = rows.next()? {
2520                if !visitor(file_text_from_row(row)?)? {
2521                    return Ok(());
2522                }
2523            }
2524        } else {
2525            let mut statement = self.connection.prepare(
2526                "
2527                SELECT path, content_hash, byte_count, line_count, content
2528                FROM file_texts
2529                ORDER BY path
2530                ",
2531            )?;
2532            let mut rows = statement.query([])?;
2533            while let Some(row) = rows.next()? {
2534                if !visitor(file_text_from_row(row)?)? {
2535                    return Ok(());
2536                }
2537            }
2538        }
2539        Ok(())
2540    }
2541
2542    /// Count files with persisted UTF-8 text for indexed search.
2543    ///
2544    /// # Errors
2545    ///
2546    /// Returns an error if reading fails.
2547    pub fn file_text_count(&self) -> DbResult<usize> {
2548        let count = self
2549            .connection
2550            .query_row("SELECT COUNT(*) FROM file_texts", [], |row| {
2551                row.get::<_, i64>(0)
2552            })?;
2553        count_to_usize("file_texts", count)
2554    }
2555
2556    /// Sum persisted UTF-8 source bytes used by indexed search.
2557    ///
2558    /// # Errors
2559    ///
2560    /// Returns an error if reading fails.
2561    pub fn file_text_byte_count(&self) -> DbResult<usize> {
2562        let count = self.connection.query_row(
2563            "SELECT COALESCE(SUM(byte_count), 0) FROM file_texts",
2564            [],
2565            |row| row.get::<_, i64>(0),
2566        )?;
2567        count_to_usize("file_text_bytes", count)
2568    }
2569
2570    /// Persist the canonical filesystem root for indexed repository files.
2571    ///
2572    /// # Errors
2573    ///
2574    /// Returns an error if persistence fails.
2575    pub fn set_project_root(&mut self, root: &Path) -> DbResult<()> {
2576        let identity = CanonicalProjectRoot::from_path(root)?;
2577        let value = identity.display_string().ok();
2578        let previous_identity = self.validated_project_instance_id;
2579        let savepoint = self.validated_savepoint()?;
2580        let found = savepoint
2581            .query_row(
2582                "SELECT value FROM metadata WHERE key = ?1",
2583                [PROJECT_ROOT_KEY],
2584                |row| row.get::<_, String>(0),
2585            )
2586            .optional()?;
2587        let found_identity = project_identity::load_project_root_identity(&savepoint)?;
2588        if found.is_none() && found_identity.is_none() {
2589            project_identity::set_project_root_identity(&savepoint, &identity)?;
2590            project_identity::set_project_root_metadata(&savepoint, &identity)?;
2591        } else {
2592            project_identity::ensure_project_root_identity_in_transaction(&savepoint, &identity)?;
2593        }
2594        let (project_identity, _) = project_identity::ensure_project_identity(&savepoint)?;
2595        let identity_changed = previous_identity != Some(project_identity);
2596        savepoint.commit()?;
2597        self.validated_project_root = value;
2598        self.validated_project_root_identity = Some(identity);
2599        self.validated_project_instance_id = Some(project_identity);
2600        if identity_changed {
2601            self.library_usage_instances.get_mut().clear();
2602        }
2603        Ok(())
2604    }
2605
2606    /// Load the canonical filesystem root for indexed repository files.
2607    ///
2608    /// # Errors
2609    ///
2610    /// Returns an error if reading fails.
2611    pub fn project_root(&self) -> DbResult<Option<String>> {
2612        self.connection
2613            .query_row(
2614                "SELECT value FROM metadata WHERE key = ?1",
2615                [PROJECT_ROOT_KEY],
2616                |row| row.get::<_, String>(0),
2617            )
2618            .optional()
2619            .map_err(DbError::from)
2620    }
2621
2622    /// Return the project binding captured when this store was validated.
2623    ///
2624    /// This accessor performs no database read. It is intended for services
2625    /// that must bind a result to the exact root and identity selected at open.
2626    ///
2627    /// # Errors
2628    ///
2629    /// Returns an error when the store has no complete project binding.
2630    pub fn captured_project_binding(&self) -> DbResult<CapturedProjectBinding> {
2631        Ok(CapturedProjectBinding {
2632            project_instance_id: self
2633                .validated_project_instance_id
2634                .ok_or(DbError::ProjectInstanceIdentityMissing)?,
2635            project_root: self.validated_project_root.clone(),
2636            project_root_identity: self
2637                .validated_project_root_identity
2638                .clone()
2639                .ok_or(DbError::ProjectRootIdentityMissing)?,
2640        })
2641    }
2642
2643    /// Load the authoritative native project-root identity.
2644    ///
2645    /// # Errors
2646    ///
2647    /// Returns an error when the identity row cannot be read or its versioned
2648    /// codec payload is invalid.
2649    pub fn project_root_identity(&self) -> DbResult<Option<CanonicalProjectRoot>> {
2650        project_identity::load_project_root_identity(&self.connection)
2651    }
2652
2653    /// Return whether a selected canonical root resolves to this store's
2654    /// captured native root identity.
2655    ///
2656    /// The comparison re-canonicalizes both existing roots through the
2657    /// admission proof, so case-only spelling changes on case-insensitive
2658    /// filesystems are accepted while distinct case-sensitive roots remain
2659    /// different. An unavailable or unresolvable persisted root fails closed.
2660    #[must_use]
2661    pub fn project_root_identity_matches(&self, selected: &CanonicalProjectRoot) -> bool {
2662        self.validated_project_root_identity
2663            .as_ref()
2664            .is_some_and(|persisted| {
2665                project_identity::prove_existing_root_equivalence(
2666                    selected.as_path(),
2667                    persisted.as_path(),
2668                )
2669                .is_ok()
2670            })
2671    }
2672
2673    /// Revalidate the captured binding against a fresh database snapshot.
2674    ///
2675    /// File-backed stores open an independent read-only snapshot so a report
2676    /// connection pinned to an older WAL end mark cannot hide a concurrent
2677    /// same-root identity rotation. In-memory stores validate their only
2678    /// connection because no external database binding exists.
2679    ///
2680    /// # Errors
2681    ///
2682    /// Returns an error when the database, root, identity, schema, or
2683    /// filesystem binding no longer matches the state captured at open.
2684    pub fn revalidate_captured_project_binding(&self) -> DbResult<()> {
2685        let binding = self.captured_project_binding()?;
2686        let Some(path) = self.database_path.as_deref() else {
2687            return schema::validate_active_native_binding(
2688                &self.connection,
2689                Some(&binding.project_root_identity),
2690                Some(binding.project_instance_id),
2691            );
2692        };
2693        let (connection, _) = schema::open_current_read_only(path, None)?;
2694        schema::validate_active_native_binding(
2695            &connection,
2696            Some(&binding.project_root_identity),
2697            Some(binding.project_instance_id),
2698        )
2699    }
2700
2701    /// Read the selected project identity and current purpose-work generation.
2702    fn purpose_curation_context(
2703        &self,
2704        connection: &Connection,
2705    ) -> DbResult<(ProjectInstanceId, IndexGeneration)> {
2706        let selected = self
2707            .validated_project_instance_id
2708            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
2709        let current = project_identity::load_project_identity(connection)?
2710            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
2711        if current != selected {
2712            return Err(DbError::GraphProjectIdentityMismatch {
2713                expected: selected.to_string(),
2714                found: current.to_string(),
2715            });
2716        }
2717        let generation =
2718            project_identity::load_graph_generation(connection)?.unwrap_or(IndexGeneration::ZERO);
2719        Ok((current, generation))
2720    }
2721
2722    /// Begin one exclusive full derived-index publication.
2723    ///
2724    /// Every nested projection write remains inside the returned guard's
2725    /// `SQLite` transaction. Other connections keep the prior complete
2726    /// generation queryable until [`IndexPublicationGuard::complete`] commits.
2727    ///
2728    /// # Errors
2729    ///
2730    /// Returns an error if the exclusive write transaction cannot begin.
2731    pub fn begin_index_publication(
2732        &mut self,
2733        contract_fingerprint: &str,
2734    ) -> DbResult<IndexPublicationGuard<'_>> {
2735        let base_generation = self
2736            .index_publication()?
2737            .map_or(IndexGeneration::ZERO, |publication| publication.generation);
2738        self.begin_index_publication_from(contract_fingerprint, base_generation)
2739    }
2740
2741    /// Begin one exclusive full derived-index publication only when its
2742    /// prepared base generation is still current.
2743    ///
2744    /// [`IndexGeneration::ZERO`] matches only an uninitialized store. The
2745    /// generation comparison occurs after `BEGIN IMMEDIATE` and before any
2746    /// publication metadata or projection row is changed.
2747    ///
2748    /// # Errors
2749    ///
2750    /// Returns an error if the exclusive write transaction cannot begin or
2751    /// another publisher completed a newer generation after work was prepared.
2752    pub fn begin_index_publication_from(
2753        &mut self,
2754        contract_fingerprint: &str,
2755        expected_base_generation: IndexGeneration,
2756    ) -> DbResult<IndexPublicationGuard<'_>> {
2757        self.begin_publication(
2758            PublicationContract::Full(contract_fingerprint.to_string()),
2759            Some(expected_base_generation),
2760        )
2761    }
2762
2763    /// Check publication-writer availability without changing durable state.
2764    ///
2765    /// The probe uses the same fail-fast acquisition policy as a real
2766    /// publication and immediately rolls back a successful transaction. It
2767    /// lets callers reject a contended writer before doing expensive staging,
2768    /// while preserving the complete generation for every connection.
2769    ///
2770    /// # Errors
2771    ///
2772    /// Returns an error when the exclusive writer cannot be acquired or the
2773    /// probe transaction cannot be rolled back.
2774    pub fn probe_index_publication_writer(&mut self) -> DbResult<()> {
2775        begin_immediate_publication(&self.connection)?;
2776        self.connection.execute_batch("ROLLBACK")?;
2777        Ok(())
2778    }
2779
2780    /// Begin one exclusive symbol/projection refresh without replacing the
2781    /// established full-index contract.
2782    ///
2783    /// # Errors
2784    ///
2785    /// Returns an error if the index is incomplete, the established contract
2786    /// differs, or the exclusive write transaction cannot begin.
2787    pub fn begin_index_projection_refresh(
2788        &mut self,
2789        contract_fingerprint: &str,
2790    ) -> DbResult<IndexPublicationGuard<'_>> {
2791        let base_generation = self
2792            .index_publication()?
2793            .map_or(IndexGeneration::ZERO, |publication| publication.generation);
2794        self.begin_index_projection_refresh_from(contract_fingerprint, base_generation)
2795    }
2796
2797    /// Begin one exclusive symbol/projection refresh only when its prepared
2798    /// base generation is still current.
2799    ///
2800    /// # Errors
2801    ///
2802    /// Returns an error if the index is incomplete, the established contract
2803    /// differs, the base generation changed, or the exclusive write
2804    /// transaction cannot begin.
2805    pub fn begin_index_projection_refresh_from(
2806        &mut self,
2807        contract_fingerprint: &str,
2808        expected_base_generation: IndexGeneration,
2809    ) -> DbResult<IndexPublicationGuard<'_>> {
2810        self.begin_publication(
2811            PublicationContract::Projection(contract_fingerprint.to_string()),
2812            Some(expected_base_generation),
2813        )
2814    }
2815
2816    /// Begin one parent-owned atomic publication transaction.
2817    fn begin_publication(
2818        &mut self,
2819        contract: PublicationContract,
2820        expected_base_generation: Option<IndexGeneration>,
2821    ) -> DbResult<IndexPublicationGuard<'_>> {
2822        begin_immediate_publication(&self.connection)?;
2823        let setup = (|| {
2824            schema::validate_active_native_binding(
2825                &self.connection,
2826                self.validated_project_root_identity.as_ref(),
2827                self.validated_project_instance_id,
2828            )?;
2829            let previous = load_index_publication(&self.connection)?;
2830            if let Some(expected) = expected_base_generation {
2831                let base_matches = match previous.as_ref() {
2832                    None => expected == IndexGeneration::ZERO,
2833                    Some(publication) => {
2834                        publication.state == IndexPublicationState::Complete
2835                            && publication.generation != IndexGeneration::ZERO
2836                            && publication.generation == expected
2837                    }
2838                };
2839                if !base_matches {
2840                    return Err(DbError::PublicationBaseGenerationChanged {
2841                        expected,
2842                        found: previous
2843                            .as_ref()
2844                            .map_or(IndexGeneration::ZERO, |publication| publication.generation),
2845                    });
2846                }
2847            }
2848            if let PublicationContract::Projection(expected) = &contract {
2849                let matches = previous.as_ref().is_some_and(|publication| {
2850                    publication.state == IndexPublicationState::Complete
2851                        && publication.contract_fingerprint.as_deref() == Some(expected.as_str())
2852                });
2853                if !matches {
2854                    return Err(DbError::PublicationContractChanged);
2855                }
2856            }
2857            set_metadata(
2858                &self.connection,
2859                INDEX_PUBLICATION_STATE_KEY,
2860                IndexPublicationState::Updating.as_str(),
2861            )?;
2862            Ok(previous.map_or(IndexGeneration::ZERO, |publication| publication.generation))
2863        })();
2864        let previous_generation = match setup {
2865            Ok(generation) => generation,
2866            Err(error) => {
2867                self.connection.execute_batch("ROLLBACK")?;
2868                return Err(error);
2869            }
2870        };
2871        Ok(IndexPublicationGuard {
2872            store: self,
2873            contract,
2874            previous_generation,
2875            scan_replacement_pending: false,
2876            active: true,
2877        })
2878    }
2879
2880    /// Start one stable read snapshot for freshness verification and every
2881    /// subsequent query used to construct the response.
2882    ///
2883    /// # Errors
2884    ///
2885    /// Returns an error if a snapshot is already active or `SQLite` cannot
2886    /// begin the transaction.
2887    pub fn begin_index_read_snapshot(&self) -> DbResult<()> {
2888        if self.read_snapshot_active.replace(true) {
2889            return Err(DbError::IndexReadSnapshotActive);
2890        }
2891        if let Err(error) = self.connection.execute_batch("BEGIN DEFERRED") {
2892            self.read_snapshot_active.set(false);
2893            return Err(error.into());
2894        }
2895        Ok(())
2896    }
2897
2898    /// Finish an active read snapshot before an optional telemetry write.
2899    ///
2900    /// This method is a no-op for stores that were not opened for a normal
2901    /// freshness-verified read.
2902    ///
2903    /// # Errors
2904    ///
2905    /// Returns an error if `SQLite` cannot finish the read transaction.
2906    pub fn finish_index_read_snapshot(&self) -> DbResult<()> {
2907        if !self.read_snapshot_active.get() {
2908            return Ok(());
2909        }
2910        self.connection.execute_batch("COMMIT")?;
2911        self.read_snapshot_active.set(false);
2912        Ok(())
2913    }
2914
2915    /// Load the current derived-index publication state when initialized.
2916    ///
2917    /// # Errors
2918    ///
2919    /// Returns an error if metadata is invalid or cannot be read.
2920    pub fn index_publication(&self) -> DbResult<Option<IndexPublication>> {
2921        load_index_publication(&self.connection)
2922    }
2923
2924    /// Replace the symbol graph for a file path.
2925    ///
2926    /// # Errors
2927    ///
2928    /// Returns an error if persistence fails.
2929    pub fn replace_symbol_graph(&mut self, graph: &SymbolGraph) -> DbResult<()> {
2930        let metadata = SourceParseMetadata::from_graph(graph);
2931        self.replace_symbol_graph_with_metadata(graph, &metadata)
2932    }
2933
2934    /// Replace one file's symbol graph while preserving independent source parse metadata.
2935    ///
2936    /// This permits a grammar-backed source parse to coexist with conservative fallback
2937    /// symbol and relationship facts without relabeling those facts as grammar-native.
2938    ///
2939    /// # Errors
2940    ///
2941    /// Returns an error if metadata identity/counts differ from the graph or persistence fails.
2942    pub fn replace_symbol_graph_with_metadata(
2943        &mut self,
2944        graph: &SymbolGraph,
2945        metadata: &SourceParseMetadata,
2946    ) -> DbResult<()> {
2947        if metadata.path != graph.path
2948            || metadata.language != graph.language
2949            || metadata.symbol_count != graph.symbols.len()
2950            || metadata.relation_count != graph.relations.len()
2951        {
2952            return Err(DbError::SymbolGraphRowShape {
2953                path: graph.path.clone(),
2954                reason: "source parse metadata identity or fact counts differ from the graph",
2955            });
2956        }
2957        let savepoint = self.validated_savepoint()?;
2958        let node_id = {
2959            let mut delete_symbols =
2960                savepoint.prepare_cached("DELETE FROM symbols WHERE path = ?1")?;
2961            let mut delete_relations =
2962                savepoint.prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?;
2963            delete_symbols.execute([&graph.path])?;
2964            delete_relations.execute([&graph.path])?;
2965
2966            let mut upsert_metadata = savepoint.prepare_cached(
2967                "
2968                INSERT INTO source_parse_metadata(
2969                    path,
2970                    language,
2971                    source_parser,
2972                    fact_parser,
2973                    symbol_count,
2974                    relation_count,
2975                    updated_at
2976                )
2977                VALUES(?1, ?2, ?3, ?4, ?5, ?6, CURRENT_TIMESTAMP)
2978                ON CONFLICT(path) DO UPDATE SET
2979                    language = excluded.language,
2980                    source_parser = excluded.source_parser,
2981                    fact_parser = excluded.fact_parser,
2982                    symbol_count = excluded.symbol_count,
2983                    relation_count = excluded.relation_count,
2984                    updated_at = CURRENT_TIMESTAMP
2985                ",
2986            )?;
2987            upsert_metadata.execute(params![
2988                metadata.path,
2989                metadata.language.as_deref(),
2990                metadata.parser.to_string(),
2991                graph.parser.to_string(),
2992                usize_to_i64(metadata.symbol_count),
2993                usize_to_i64(metadata.relation_count),
2994            ])?;
2995            let mut select_node = savepoint
2996                .prepare_cached("SELECT id FROM nodes WHERE path = ?1 AND exists_now = 1")?;
2997            let node_id = select_node
2998                .query_row([&graph.path], |row| row.get::<_, i64>(0))
2999                .optional()?;
3000
3001            let mut insert_symbol = savepoint.prepare_cached(
3002                "
3003                INSERT INTO symbols(
3004                    path,
3005                    language,
3006                    name,
3007                    kind,
3008                    signature,
3009                    exported,
3010                    documentation,
3011                    line_start,
3012                    line_end,
3013                    source_byte_start,
3014                    source_byte_end,
3015                    source_column_start,
3016                    source_column_end,
3017                    parent,
3018                    parser,
3019                    detail
3020                )
3021                VALUES(
3022                    ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
3023                    ?13, ?14, ?15, ?16
3024                )
3025                ",
3026            )?;
3027            for symbol in &graph.symbols {
3028                let selector = symbol_source_selector_values(symbol)?;
3029                insert_symbol.execute(params![
3030                    symbol.path,
3031                    symbol.language.as_deref(),
3032                    symbol.name,
3033                    symbol.kind.to_string(),
3034                    symbol.signature,
3035                    symbol.exported,
3036                    symbol.documentation.as_deref(),
3037                    usize_to_i64(symbol.line_start),
3038                    usize_to_i64(symbol.line_end),
3039                    selector[0],
3040                    selector[1],
3041                    selector[2],
3042                    selector[3],
3043                    symbol.parent.as_deref(),
3044                    symbol.parser.to_string(),
3045                    symbol.detail.as_deref(),
3046                ])?;
3047            }
3048
3049            let mut insert_relation = savepoint.prepare_cached(
3050                "
3051                INSERT INTO symbol_relations(
3052                    path,
3053                    source_name,
3054                    target_name,
3055                    kind,
3056                    line,
3057                    context,
3058                    parser
3059                )
3060                VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)
3061                ",
3062            )?;
3063            for relation in &graph.relations {
3064                insert_relation.execute(params![
3065                    relation.path,
3066                    relation.source_name,
3067                    relation.target_name,
3068                    relation.kind.to_string(),
3069                    usize_to_i64(relation.line),
3070                    relation.context,
3071                    relation.parser.to_string(),
3072                ])?;
3073            }
3074            node_id
3075        };
3076        if let Some(node_id) = node_id {
3077            replace_symbol_search_summary(
3078                &savepoint,
3079                node_id,
3080                symbol_search_summary(graph).as_deref(),
3081            )?;
3082        }
3083        savepoint.commit()?;
3084        Ok(())
3085    }
3086
3087    /// Clear source-derived intelligence for one live file path.
3088    ///
3089    /// This removes symbols, relations, and the node-level content summary so
3090    /// skipped or failed parser work cannot leave stale source facts visible.
3091    ///
3092    /// # Errors
3093    ///
3094    /// Returns an error if the path does not exist or persistence fails.
3095    pub fn clear_source_index_for_path(&self, path: &str) -> DbResult<()> {
3096        self.with_validated_write(|connection| {
3097            let node_id = self.node_id_for_path(path)?;
3098            connection
3099                .prepare_cached("DELETE FROM symbols WHERE path = ?1")?
3100                .execute([path])?;
3101            connection
3102                .prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?
3103                .execute([path])?;
3104            connection
3105                .prepare_cached("DELETE FROM source_parse_metadata WHERE path = ?1")?
3106                .execute([path])?;
3107            connection
3108                .prepare_cached(
3109                    "
3110            DELETE FROM summaries
3111            WHERE node_id = ?1
3112              AND (
3113                    (summary_level = 'node' AND subject = '')
3114                    OR (summary_level = 'search' AND subject = 'symbols')
3115                  )
3116            ",
3117                )?
3118                .execute([node_id])?;
3119            Ok(())
3120        })
3121    }
3122
3123    /// Clear symbols and relations for one live file path while preserving node summaries.
3124    ///
3125    /// # Errors
3126    ///
3127    /// Returns an error if persistence fails.
3128    pub fn clear_symbol_graph_for_path(&self, path: &str) -> DbResult<()> {
3129        self.with_validated_write(|connection| {
3130            let node_id = self.node_id_for_path(path)?;
3131            connection
3132                .prepare_cached("DELETE FROM symbols WHERE path = ?1")?
3133                .execute([path])?;
3134            connection
3135                .prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?
3136                .execute([path])?;
3137            connection
3138                .prepare_cached("DELETE FROM source_parse_metadata WHERE path = ?1")?
3139                .execute([path])?;
3140            connection
3141                .prepare_cached(
3142                    "
3143            DELETE FROM summaries
3144            WHERE node_id = ?1
3145              AND summary_level = 'search'
3146              AND subject = 'symbols'
3147            ",
3148                )?
3149                .execute([node_id])?;
3150            Ok(())
3151        })
3152    }
3153
3154    /// Persist an observed one-line summary for an indexed node.
3155    ///
3156    /// # Errors
3157    ///
3158    /// Returns an error if the path does not exist or persistence fails.
3159    pub fn set_node_summary(&self, path: &str, summary: &str) -> DbResult<()> {
3160        self.with_validated_write(|connection| {
3161            let node_id = self.node_id_for_path(path)?;
3162            connection
3163                .prepare_cached(
3164                    "
3165            INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
3166            VALUES(?1, 'node', '', ?2, CURRENT_TIMESTAMP)
3167            ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
3168                summary_level = 'node',
3169                subject = '',
3170                summary = excluded.summary,
3171                updated_at = CURRENT_TIMESTAMP
3172            ",
3173                )?
3174                .execute(params![node_id, summary])?;
3175            Ok(())
3176        })
3177    }
3178
3179    /// Remove the observed node-level summary for an indexed node.
3180    ///
3181    /// # Errors
3182    ///
3183    /// Returns an error if the path does not exist or persistence fails.
3184    pub fn clear_node_summary(&self, path: &str) -> DbResult<()> {
3185        self.with_validated_write(|connection| {
3186            let node_id = self.node_id_for_path(path)?;
3187            connection
3188                .prepare_cached(
3189                    "
3190            DELETE FROM summaries
3191            WHERE node_id = ?1
3192              AND summary_level = 'node'
3193              AND subject = ''
3194            ",
3195                )?
3196                .execute([node_id])?;
3197            Ok(())
3198        })
3199    }
3200
3201    /// Load symbols filtered by optional file path and query.
3202    ///
3203    /// # Errors
3204    ///
3205    /// Returns an error if reading fails.
3206    pub fn load_symbols(
3207        &self,
3208        file: Option<&str>,
3209        query: Option<&str>,
3210        limit: usize,
3211    ) -> DbResult<Vec<CodeSymbol>> {
3212        let max_rows = usize_to_i64(limit.max(1));
3213        match (file, query) {
3214            (Some(file), Some(query)) => self.query_symbols(
3215                "
3216                SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3217                       source_byte_start, source_byte_end, source_column_start, source_column_end
3218                FROM symbols
3219                WHERE path = ?1 AND (name LIKE ?2 OR signature LIKE ?2 OR documentation LIKE ?2)
3220                  AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')
3221                ORDER BY path, line_start, name
3222                LIMIT ?3
3223                ",
3224                params![file, like_query(query), max_rows],
3225            ),
3226            (Some(file), None) => self.query_symbols(
3227                "
3228                SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3229                       source_byte_start, source_byte_end, source_column_start, source_column_end
3230                FROM symbols
3231                WHERE path = ?1
3232                  AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')
3233                ORDER BY path, line_start, name
3234                LIMIT ?2
3235                ",
3236                params![file, max_rows],
3237            ),
3238            (None, Some(query)) => self.query_symbols(
3239                "
3240                SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3241                       source_byte_start, source_byte_end, source_column_start, source_column_end
3242                FROM symbols
3243                WHERE (name LIKE ?1 OR signature LIKE ?1 OR documentation LIKE ?1 OR path LIKE ?1)
3244                  AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')
3245                ORDER BY path, line_start, name
3246                LIMIT ?2
3247                ",
3248                params![like_query(query), max_rows],
3249            ),
3250            (None, None) => self.query_symbols(
3251                "
3252                SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3253                       source_byte_start, source_byte_end, source_column_start, source_column_end
3254                FROM symbols
3255                WHERE NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')
3256                ORDER BY path, line_start, name
3257                LIMIT ?1
3258                ",
3259                params![max_rows],
3260            ),
3261        }
3262    }
3263
3264    /// Load symbols with their owning file classification and optional content selection.
3265    ///
3266    /// Omitted selection retains the legacy symbol candidate universe and order. Explicit
3267    /// selection is applied by `SQLite` before the row limit.
3268    ///
3269    /// # Errors
3270    ///
3271    /// Returns an error if a classification is missing or corrupt, or reading fails.
3272    pub fn load_classified_symbols(
3273        &self,
3274        file: Option<&str>,
3275        query: Option<&str>,
3276        selection: ContentSelection,
3277        limit: usize,
3278    ) -> DbResult<Vec<ClassifiedSymbol>> {
3279        let (sql, bindings) = classified_symbols_sql(file, query, selection, limit);
3280        let mut statement = self.connection.prepare(&sql)?;
3281        let mut rows = statement.query(params_from_iter(bindings.iter()))?;
3282        let mut symbols = Vec::new();
3283        while let Some(row) = rows.next()? {
3284            symbols.push(classified_symbol_from_row(row)?);
3285        }
3286        Ok(symbols)
3287    }
3288
3289    /// Load one bounded deterministic symbol set for exact repository paths.
3290    ///
3291    /// The request uses indexed `symbols.path` predicates, chunks bindings
3292    /// below the `SQLite` variable ceiling, and preserves the store's active
3293    /// read snapshot. The service remains responsible for matching exact
3294    /// declaration identities within these admitted owning paths.
3295    ///
3296    /// # Errors
3297    ///
3298    /// Returns an error when a path/budget is invalid, cancellation or the
3299    /// request deadline fires, a row is corrupt, or `SQLite` iteration fails.
3300    pub fn load_symbols_for_paths_bounded(
3301        &self,
3302        paths: &[String],
3303        budget: SymbolBatchReadBudget,
3304        control: Option<&IndexWorkControl>,
3305    ) -> DbResult<SymbolBatchRead> {
3306        let path_limit =
3307            usize::try_from(budget.paths()).map_err(|source| DbError::InvalidCount {
3308                field: "symbol batch paths",
3309                value: i64::from(budget.paths()),
3310                source,
3311            })?;
3312        let mut exact_paths = BTreeSet::new();
3313        let mut reached_limit = None;
3314        for path in paths {
3315            if let Some(control) = control {
3316                control.check(IndexWorkStage::RepositoryTraversal)?;
3317            }
3318            exact_paths.insert(path.clone());
3319            if exact_paths.len() > path_limit {
3320                exact_paths.pop_last();
3321                reached_limit.get_or_insert(SymbolBatchReadLimit::Paths);
3322            }
3323        }
3324        if exact_paths.is_empty() {
3325            return Ok(SymbolBatchRead::default());
3326        }
3327        let exact_paths = exact_paths.into_iter().collect::<Vec<_>>();
3328        let row_limit = usize::try_from(budget.rows()).map_err(|source| DbError::InvalidCount {
3329            field: "symbol batch rows",
3330            value: i64::from(budget.rows()),
3331            source,
3332        })?;
3333        let mut symbols = Vec::new();
3334        let mut decoded_bytes = 0_u64;
3335        'chunks: for chunk in exact_paths.chunks(SYMBOL_BATCH_BIND_PATHS) {
3336            if let Some(control) = control {
3337                control.check(IndexWorkStage::RepositoryTraversal)?;
3338            }
3339            let remaining_rows = row_limit.saturating_sub(symbols.len());
3340            if remaining_rows == 0 {
3341                reached_limit.get_or_insert(SymbolBatchReadLimit::Rows);
3342                break;
3343            }
3344            let placeholders = numbered_placeholders(1, chunk.len());
3345            let limit_parameter = chunk.len().saturating_add(1);
3346            let sql = format!(
3347                "
3348                SELECT
3349                    path,
3350                    language,
3351                    name,
3352                    kind,
3353                    signature,
3354                    line_start,
3355                    line_end,
3356                    parent,
3357                    parser,
3358                    detail,
3359                    exported,
3360                    documentation,
3361                    source_byte_start,
3362                    source_byte_end,
3363                    source_column_start,
3364                    source_column_end,
3365                    length(CAST(path AS BLOB))
3366                        + COALESCE(length(CAST(language AS BLOB)), 0)
3367                        + length(CAST(name AS BLOB))
3368                        + length(CAST(signature AS BLOB))
3369                        + COALESCE(length(CAST(parent AS BLOB)), 0)
3370                        + COALESCE(length(CAST(detail AS BLOB)), 0)
3371                        + COALESCE(length(CAST(documentation AS BLOB)), 0)
3372                FROM symbols
3373                WHERE path IN ({placeholders})
3374                  AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')
3375                ORDER BY path, line_start, name
3376                LIMIT ?{limit_parameter}
3377                "
3378            );
3379            let mut bindings = chunk.iter().cloned().map(Value::Text).collect::<Vec<_>>();
3380            bindings.push(Value::Integer(usize_to_i64(
3381                remaining_rows.saturating_add(1),
3382            )));
3383            with_sqlite_read_progress(
3384                &self.connection,
3385                control,
3386                IndexWorkStage::RepositoryTraversal,
3387                || {
3388                    let mut statement = self.connection.prepare(&sql)?;
3389                    let mut rows = statement.query(params_from_iter(bindings.iter()))?;
3390                    while let Some(row) = rows.next()? {
3391                        if let Some(control) = control {
3392                            control.check(IndexWorkStage::RepositoryTraversal)?;
3393                        }
3394                        if symbols.len() >= row_limit {
3395                            reached_limit.get_or_insert(SymbolBatchReadLimit::Rows);
3396                            break;
3397                        }
3398                        let preflight_bytes = code_symbol_preflight_bytes(row)?;
3399                        if decoded_bytes.saturating_add(preflight_bytes) > budget.decoded_bytes() {
3400                            reached_limit.get_or_insert(SymbolBatchReadLimit::DecodedBytes);
3401                            break;
3402                        }
3403                        let symbol = code_symbol_from_row(row)?;
3404                        let row_bytes = code_symbol_decoded_bytes(&symbol)?;
3405                        if decoded_bytes.saturating_add(row_bytes) > budget.decoded_bytes() {
3406                            reached_limit.get_or_insert(SymbolBatchReadLimit::DecodedBytes);
3407                            break;
3408                        }
3409                        decoded_bytes = decoded_bytes.saturating_add(row_bytes);
3410                        symbols.push(symbol);
3411                    }
3412                    Ok(())
3413                },
3414            )?;
3415            if matches!(
3416                reached_limit,
3417                Some(SymbolBatchReadLimit::Rows | SymbolBatchReadLimit::DecodedBytes)
3418            ) {
3419                break 'chunks;
3420            }
3421        }
3422        symbols.sort_by(|left, right| {
3423            (&left.path, left.line_start, &left.name, &left.signature).cmp(&(
3424                &right.path,
3425                right.line_start,
3426                &right.name,
3427                &right.signature,
3428            ))
3429        });
3430        let symbols = symbols.into_boxed_slice().into_vec();
3431        Ok(SymbolBatchRead {
3432            work: SymbolBatchReadWork {
3433                requested_paths: u32::try_from(exact_paths.len()).unwrap_or(u32::MAX),
3434                returned_rows: u32::try_from(symbols.len()).unwrap_or(u32::MAX),
3435                decoded_bytes,
3436            },
3437            rows: symbols,
3438            truncated: reached_limit.is_some(),
3439            reached_limit,
3440        })
3441    }
3442
3443    /// Load symbols for a file and one or more exact kinds.
3444    ///
3445    /// # Errors
3446    ///
3447    /// Returns an error if reading fails.
3448    pub fn load_symbols_by_kinds(
3449        &self,
3450        file: &str,
3451        kinds: &[SymbolKind],
3452        limit: usize,
3453    ) -> DbResult<Vec<CodeSymbol>> {
3454        if kinds.is_empty() {
3455            return Ok(Vec::new());
3456        }
3457        let max_rows = usize_to_i64(limit.max(1));
3458        let placeholders = numbered_placeholders(2, kinds.len());
3459        let sql = format!(
3460            "
3461            SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3462                   source_byte_start, source_byte_end, source_column_start, source_column_end
3463            FROM symbols INDEXED BY idx_symbols_path
3464            WHERE path = ?1 AND kind IN ({placeholders})
3465            ORDER BY path, line_start, name
3466            LIMIT {max_rows}
3467            "
3468        );
3469        let mut values = Vec::with_capacity(kinds.len() + 1);
3470        values.push(file.to_string());
3471        values.extend(kinds.iter().map(ToString::to_string));
3472        self.query_symbols(&sql, params_from_iter(values.iter()))
3473    }
3474
3475    /// Count symbols for a file and one or more exact kinds.
3476    ///
3477    /// # Errors
3478    ///
3479    /// Returns an error if reading fails.
3480    pub fn count_symbols_by_kinds(&self, file: &str, kinds: &[SymbolKind]) -> DbResult<usize> {
3481        if kinds.is_empty() {
3482            return Ok(0);
3483        }
3484        let placeholders = numbered_placeholders(2, kinds.len());
3485        let sql = format!(
3486            "SELECT COUNT(*) FROM symbols INDEXED BY idx_symbols_path
3487             WHERE path = ?1 AND kind IN ({placeholders})"
3488        );
3489        let mut values = Vec::with_capacity(kinds.len() + 1);
3490        values.push(file.to_string());
3491        values.extend(kinds.iter().map(ToString::to_string));
3492        let count = self
3493            .connection
3494            .query_row(&sql, params_from_iter(values.iter()), |row| {
3495                row.get::<_, i64>(0)
3496            })?;
3497        Ok(i64_to_usize(count))
3498    }
3499
3500    /// Count indexed symbols grouped by exact name.
3501    ///
3502    /// # Errors
3503    ///
3504    /// Returns an error if reading fails.
3505    pub fn symbol_name_counts(&self, names: &[String]) -> DbResult<HashMap<String, usize>> {
3506        if names.is_empty() {
3507            return Ok(HashMap::new());
3508        }
3509        let placeholders = numbered_placeholders(1, names.len());
3510        let sql = format!(
3511            "SELECT name, COUNT(*) FROM symbols WHERE name IN ({placeholders}) GROUP BY name"
3512        );
3513        let mut statement = self.connection.prepare(&sql)?;
3514        let rows = statement.query_map(params_from_iter(names.iter()), |row| {
3515            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
3516        })?;
3517        let mut counts = HashMap::new();
3518        for row in rows {
3519            let (name, count) = row?;
3520            counts.insert(name, i64_to_usize(count));
3521        }
3522        Ok(counts)
3523    }
3524
3525    /// Load symbols with exact names.
3526    ///
3527    /// # Errors
3528    ///
3529    /// Returns an error if reading fails.
3530    pub fn load_symbols_by_names(&self, names: &[String]) -> DbResult<Vec<CodeSymbol>> {
3531        if names.is_empty() {
3532            return Ok(Vec::new());
3533        }
3534        let placeholders = numbered_placeholders(1, names.len());
3535        let sql = format!(
3536            "
3537            SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3538                   source_byte_start, source_byte_end, source_column_start, source_column_end
3539            FROM symbols
3540            WHERE name IN ({placeholders})
3541              AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')
3542            ORDER BY path, line_start, name
3543            "
3544        );
3545        self.query_symbols(&sql, params_from_iter(names.iter()))
3546    }
3547
3548    /// Load exported symbol names for one file.
3549    ///
3550    /// # Errors
3551    ///
3552    /// Returns an error if reading fails.
3553    pub fn load_exported_symbol_names_for_path(
3554        &self,
3555        file: &str,
3556        limit: usize,
3557    ) -> DbResult<Vec<String>> {
3558        let max_rows = usize_to_i64(limit.max(1));
3559        let mut statement = self.connection.prepare(
3560            "
3561            SELECT DISTINCT name
3562            FROM symbols
3563            WHERE path = ?1 AND exported = 1
3564            ORDER BY name
3565            LIMIT ?2
3566            ",
3567        )?;
3568        let rows = statement.query_map(params![file, max_rows], |row| row.get::<_, String>(0))?;
3569        let mut names = Vec::new();
3570        for row in rows {
3571            names.push(row?);
3572        }
3573        Ok(names)
3574    }
3575
3576    /// Count exported symbol names for one file.
3577    ///
3578    /// # Errors
3579    ///
3580    /// Returns an error if reading fails.
3581    pub fn exported_symbol_count_for_path(&self, file: &str) -> DbResult<usize> {
3582        let count = self.connection.query_row(
3583            "SELECT COUNT(DISTINCT name) FROM symbols WHERE path = ?1 AND exported = 1",
3584            [file],
3585            |row| row.get::<_, i64>(0),
3586        )?;
3587        Ok(i64_to_usize(count))
3588    }
3589
3590    /// Load one symbol by exact file and name.
3591    ///
3592    /// # Errors
3593    ///
3594    /// Returns an error if reading fails.
3595    pub fn load_symbol_by_name(&self, file: &str, name: &str) -> DbResult<Option<CodeSymbol>> {
3596        let mut symbols = self.load_symbols(Some(file), Some(name), 100)?;
3597        symbols.retain(|symbol| symbol.name == name);
3598        Ok(symbols.into_iter().next())
3599    }
3600
3601    /// Load all symbols with an exact file and name.
3602    ///
3603    /// # Errors
3604    ///
3605    /// Returns an error if reading fails.
3606    pub fn load_symbols_by_exact_file_and_name(
3607        &self,
3608        file: &str,
3609        name: &str,
3610    ) -> DbResult<Vec<CodeSymbol>> {
3611        self.query_symbols(
3612            "
3613            SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3614                   source_byte_start, source_byte_end, source_column_start, source_column_end
3615            FROM symbols
3616            WHERE path = ?1 AND name = ?2
3617              AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')
3618            ORDER BY line_start, line_end, kind, parent
3619            ",
3620            params![file, name],
3621        )
3622    }
3623
3624    /// Load one existing node with purpose state by repository path.
3625    ///
3626    /// # Errors
3627    ///
3628    /// Returns an error if reading or enum conversion fails.
3629    pub fn load_node_by_path(&self, path: &str) -> DbResult<Option<IndexedNode>> {
3630        let mut statement = self.connection.prepare(
3631            "
3632            SELECT
3633                n.path,
3634                n.kind,
3635                n.parent_path,
3636                n.extension,
3637                n.language,
3638                n.size_bytes,
3639                n.mtime_ns,
3640                n.content_hash,
3641                p.purpose,
3642                p.source,
3643                p.status,
3644                s.summary
3645            FROM nodes n
3646            JOIN purposes p ON p.node_id = n.id
3647            LEFT JOIN summaries s ON s.node_id = n.id
3648                AND s.summary_level = 'node'
3649                AND s.subject = ''
3650            WHERE n.exists_now = 1 AND n.path = ?1
3651            ",
3652        )?;
3653        let row = statement
3654            .query_row([path], |row| {
3655                let kind_value: String = row.get(1)?;
3656                let source_value: String = row.get(9)?;
3657                let status_value: String = row.get(10)?;
3658                Ok((
3659                    row.get::<_, String>(0)?,
3660                    kind_value,
3661                    row.get::<_, Option<String>>(2)?,
3662                    row.get::<_, Option<String>>(3)?,
3663                    row.get::<_, Option<String>>(4)?,
3664                    option_u64_from_sql(row, 5)?,
3665                    row.get::<_, Option<i64>>(6)?,
3666                    row.get::<_, Option<String>>(7)?,
3667                    row.get::<_, Option<String>>(8)?,
3668                    source_value,
3669                    status_value,
3670                    row.get::<_, Option<String>>(11)?,
3671                ))
3672            })
3673            .optional()?;
3674        row.map(indexed_node_from_parts).transpose()
3675    }
3676
3677    /// Load existing nodes for exact repository paths.
3678    ///
3679    /// # Errors
3680    ///
3681    /// Returns an error if reading or enum conversion fails.
3682    pub fn load_nodes_by_paths(&self, paths: &[String]) -> DbResult<Vec<IndexedNode>> {
3683        self.load_nodes_by_paths_controlled(paths, None)
3684    }
3685
3686    /// Load existing nodes for exact repository paths in bounded cancellable batches.
3687    ///
3688    /// # Errors
3689    ///
3690    /// Returns an error if cancellation fires or reading or enum conversion fails.
3691    pub fn load_nodes_by_paths_controlled(
3692        &self,
3693        paths: &[String],
3694        control: Option<&IndexWorkControl>,
3695    ) -> DbResult<Vec<IndexedNode>> {
3696        let mut unique_paths = paths.to_vec();
3697        unique_paths.sort();
3698        unique_paths.dedup();
3699        if unique_paths.is_empty() {
3700            return Ok(Vec::new());
3701        }
3702        let mut nodes = Vec::new();
3703        for chunk in unique_paths.chunks(MAX_PURPOSE_CURATION_BATCH_ROWS) {
3704            let hydrated = with_sqlite_read_progress(
3705                &self.connection,
3706                control,
3707                IndexWorkStage::RepositoryTraversal,
3708                || {
3709                    let sql = load_nodes_by_paths_sql(chunk.len());
3710                    let mut statement = self.connection.prepare(&sql)?;
3711                    let rows = statement.query_map(params_from_iter(chunk), |row| {
3712                        let kind_value: String = row.get(1)?;
3713                        let source_value: String = row.get(9)?;
3714                        let status_value: String = row.get(10)?;
3715                        Ok((
3716                            row.get::<_, String>(0)?,
3717                            kind_value,
3718                            row.get::<_, Option<String>>(2)?,
3719                            row.get::<_, Option<String>>(3)?,
3720                            row.get::<_, Option<String>>(4)?,
3721                            option_u64_from_sql(row, 5)?,
3722                            row.get::<_, Option<i64>>(6)?,
3723                            row.get::<_, Option<String>>(7)?,
3724                            row.get::<_, Option<String>>(8)?,
3725                            source_value,
3726                            status_value,
3727                            row.get::<_, Option<String>>(11)?,
3728                        ))
3729                    })?;
3730                    let mut selected = Vec::new();
3731                    for row in rows {
3732                        selected.push(indexed_node_from_parts(row?)?);
3733                    }
3734                    Ok(selected)
3735                },
3736            )?;
3737            nodes.extend(hydrated);
3738        }
3739        Ok(nodes)
3740    }
3741
3742    /// Hydrate every exact purpose-owner path under one graph read envelope.
3743    ///
3744    /// Rows are returned in caller order. Unlike the compatibility path loader,
3745    /// this graph-facing boundary evaluates every unique requested path in the
3746    /// selected project and generation. Existing rows retain caller order;
3747    /// absent ancestor candidates are represented by omission rather than a
3748    /// partial-query failure.
3749    ///
3750    /// # Errors
3751    ///
3752    /// Returns an error for duplicate paths, a stale project or generation,
3753    /// cancellation, corrupt node or purpose state, `SQLite`
3754    /// failure, or any returned-row, decoded-byte, or hydrated-path budget
3755    /// overrun. No partial batch is returned.
3756    pub fn load_purpose_owner_nodes_by_paths_controlled(
3757        &self,
3758        project: ProjectInstanceId,
3759        generation: IndexGeneration,
3760        paths: &[String],
3761        budget: RepositoryGraphReadBudget,
3762        control: Option<&IndexWorkControl>,
3763    ) -> DbResult<RepositoryGraphReadBatch<IndexedNode>> {
3764        if paths
3765            .iter()
3766            .map(String::as_str)
3767            .collect::<HashSet<_>>()
3768            .len()
3769            != paths.len()
3770        {
3771            return Err(GraphContractError::InvalidLimits {
3772                reason: "purpose-owner hydration paths must be unique",
3773            }
3774            .into());
3775        }
3776        self.require_repository_graph_snapshot(project, generation)?;
3777        let mut meter = repository_graph::RepositoryGraphReadMeter::new(budget, paths.len())?;
3778        let path_count =
3779            u32::try_from(paths.len()).map_err(|_source| GraphContractError::InvalidLimits {
3780                reason: "purpose-owner hydration path count overflowed",
3781            })?;
3782        if path_count > budget.returned_rows() {
3783            return Err(GraphContractError::InvalidLimits {
3784                reason: "purpose-owner paths exceed the return budget",
3785            }
3786            .into());
3787        }
3788        if path_count > budget.hydrated_paths() {
3789            return Err(GraphContractError::InvalidLimits {
3790                reason: "purpose-owner paths exceed the hydration budget",
3791            }
3792            .into());
3793        }
3794        if paths.is_empty() {
3795            return Ok(RepositoryGraphReadBatch {
3796                rows: Vec::new(),
3797                work: meter.finish(0)?,
3798            });
3799        }
3800
3801        let mut selected = HashMap::with_capacity(paths.len());
3802        for chunk in paths.chunks(MAX_PURPOSE_CURATION_BATCH_ROWS) {
3803            let hydrated = with_sqlite_read_progress(
3804                &self.connection,
3805                control,
3806                IndexWorkStage::RepositoryTraversal,
3807                || {
3808                    let sql = load_nodes_by_paths_sql(chunk.len());
3809                    let mut statement = self.connection.prepare(&sql)?;
3810                    let mut rows = statement.query(params_from_iter(chunk))?;
3811                    let mut nodes = Vec::new();
3812                    while let Some(row) = rows.next()? {
3813                        let parts = indexed_node_parts_from_sql_row(row)?;
3814                        meter.record_decoded_bytes(indexed_node_parts_decoded_bytes(&parts)?)?;
3815                        let node = indexed_node_from_parts(parts)?;
3816                        meter.record_hydrated_path(&node.node.path)?;
3817                        nodes.push(node);
3818                    }
3819                    Ok(nodes)
3820                },
3821            )?;
3822            for node in hydrated {
3823                let path = node.node.path.clone();
3824                if selected.insert(path, node).is_some() {
3825                    return Err(DbError::GraphRowShape {
3826                        table: "nodes",
3827                        reason: "purpose-owner hydration returned a duplicate path",
3828                    });
3829                }
3830            }
3831        }
3832
3833        let mut ordered = Vec::with_capacity(paths.len());
3834        for path in paths {
3835            if let Some(node) = selected.remove(path) {
3836                ordered.push(node);
3837            }
3838        }
3839        let work = meter.finish(ordered.len())?;
3840        Ok(RepositoryGraphReadBatch {
3841            rows: ordered,
3842            work,
3843        })
3844    }
3845
3846    /// Load one bounded stale-safe purpose-curation batch for exact paths.
3847    ///
3848    /// The node hydration is one prepared set query regardless of row count.
3849    /// Accepted purposes are intentionally omitted from curator work.
3850    ///
3851    /// # Errors
3852    ///
3853    /// Returns an error for an invalid task, oversized batch, changed project
3854    /// binding, invalid persisted state, or failed `SQLite` read.
3855    pub fn load_purpose_curation_batch(
3856        &self,
3857        task: &str,
3858        paths: &[String],
3859    ) -> DbResult<PurposeCurationBatch> {
3860        let task = normalize_purpose_curation_task(task)?;
3861        let mut unique_paths = paths.to_vec();
3862        unique_paths.sort();
3863        unique_paths.dedup();
3864        if unique_paths.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
3865            return Err(DbError::PurposeCurationBatchTooLarge {
3866                requested: unique_paths.len(),
3867                maximum: MAX_PURPOSE_CURATION_BATCH_ROWS,
3868            });
3869        }
3870        let (project_instance_id, active_generation) =
3871            self.purpose_curation_context(&self.connection)?;
3872        let items = self
3873            .load_nodes_by_paths(&unique_paths)?
3874            .into_iter()
3875            .filter(|node| {
3876                matches!(
3877                    node.purpose.status,
3878                    PurposeStatus::Missing | PurposeStatus::Suggested
3879                )
3880            })
3881            .map(|node| {
3882                purpose_curation_candidate(project_instance_id, active_generation, &task, node)
3883            })
3884            .collect::<Vec<_>>();
3885        let work_key =
3886            purpose_curation_batch_work_key(project_instance_id, active_generation, &task, &items);
3887        Ok(PurposeCurationBatch {
3888            project_instance_id,
3889            active_generation,
3890            task,
3891            work_key,
3892            items,
3893        })
3894    }
3895
3896    /// Load symbol relations filtered by optional file path and query.
3897    ///
3898    /// # Errors
3899    ///
3900    /// Returns an error if reading fails.
3901    pub fn load_symbol_relations(
3902        &self,
3903        file: Option<&str>,
3904        query: Option<&str>,
3905        limit: usize,
3906    ) -> DbResult<Vec<SymbolRelation>> {
3907        let max_rows = usize_to_i64(limit.max(1));
3908        match (file, query) {
3909            (Some(file), Some(query)) => self.query_relations(
3910                "
3911                SELECT path, source_name, target_name, kind, line, context, parser
3912                FROM symbol_relations
3913                WHERE path = ?1 AND (source_name LIKE ?2 OR target_name LIKE ?2 OR context LIKE ?2)
3914                ORDER BY path, line, source_name, target_name
3915                LIMIT ?3
3916                ",
3917                params![file, like_query(query), max_rows],
3918            ),
3919            (Some(file), None) => self.query_relations(
3920                "
3921                SELECT path, source_name, target_name, kind, line, context, parser
3922                FROM symbol_relations
3923                WHERE path = ?1
3924                ORDER BY path, line, source_name, target_name
3925                LIMIT ?2
3926                ",
3927                params![file, max_rows],
3928            ),
3929            (None, Some(query)) => self.query_relations(
3930                "
3931                SELECT path, source_name, target_name, kind, line, context, parser
3932                FROM symbol_relations
3933                WHERE source_name LIKE ?1 OR target_name LIKE ?1 OR context LIKE ?1 OR path LIKE ?1
3934                ORDER BY path, line, source_name, target_name
3935                LIMIT ?2
3936                ",
3937                params![like_query(query), max_rows],
3938            ),
3939            (None, None) => self.query_relations(
3940                "
3941                SELECT path, source_name, target_name, kind, line, context, parser
3942                FROM symbol_relations
3943                ORDER BY path, line, source_name, target_name
3944                LIMIT ?1
3945                ",
3946                params![max_rows],
3947            ),
3948        }
3949    }
3950
3951    /// Load symbol relations for a file and exact relation kind.
3952    ///
3953    /// # Errors
3954    ///
3955    /// Returns an error if reading fails.
3956    pub fn load_symbol_relations_by_kind(
3957        &self,
3958        file: &str,
3959        kind: RelationKind,
3960        limit: usize,
3961    ) -> DbResult<Vec<SymbolRelation>> {
3962        let max_rows = usize_to_i64(limit.max(1));
3963        self.query_relations(
3964            "
3965            SELECT path, source_name, target_name, kind, line, context, parser
3966            FROM symbol_relations
3967            WHERE path = ?1 AND kind = ?2
3968            ORDER BY path, line, source_name, target_name
3969            LIMIT ?3
3970            ",
3971            params![file, kind.to_string(), max_rows],
3972        )
3973    }
3974
3975    /// Count symbol relations for a file and exact relation kind.
3976    ///
3977    /// # Errors
3978    ///
3979    /// Returns an error if reading fails.
3980    pub fn count_symbol_relations_by_kind(
3981        &self,
3982        file: &str,
3983        kind: RelationKind,
3984    ) -> DbResult<usize> {
3985        let count = self.connection.query_row(
3986            "SELECT COUNT(*) FROM symbol_relations WHERE path = ?1 AND kind = ?2",
3987            params![file, kind.to_string()],
3988            |row| row.get::<_, i64>(0),
3989        )?;
3990        Ok(i64_to_usize(count))
3991    }
3992
3993    /// Load distinct relation targets for a file and exact relation kind.
3994    ///
3995    /// # Errors
3996    ///
3997    /// Returns an error if reading fails.
3998    pub fn load_distinct_relation_targets_by_kind(
3999        &self,
4000        file: &str,
4001        kind: RelationKind,
4002        limit: usize,
4003    ) -> DbResult<Vec<String>> {
4004        let max_rows = usize_to_i64(limit.max(1));
4005        let mut statement = self.connection.prepare(
4006            "
4007            SELECT DISTINCT target_name
4008            FROM symbol_relations
4009            WHERE path = ?1 AND kind = ?2
4010            ORDER BY target_name
4011            LIMIT ?3
4012            ",
4013        )?;
4014        let rows = statement.query_map(params![file, kind.to_string(), max_rows], |row| {
4015            row.get::<_, String>(0)
4016        })?;
4017        let mut targets = Vec::new();
4018        for row in rows {
4019            targets.push(row?);
4020        }
4021        Ok(targets)
4022    }
4023
4024    /// Count distinct relation targets for a file and exact relation kind.
4025    ///
4026    /// # Errors
4027    ///
4028    /// Returns an error if reading fails.
4029    pub fn count_distinct_relation_targets_by_kind(
4030        &self,
4031        file: &str,
4032        kind: RelationKind,
4033    ) -> DbResult<usize> {
4034        let count = self.connection.query_row(
4035            "SELECT COUNT(DISTINCT target_name) FROM symbol_relations WHERE path = ?1 AND kind = ?2",
4036            params![file, kind.to_string()],
4037            |row| row.get::<_, i64>(0),
4038        )?;
4039        Ok(i64_to_usize(count))
4040    }
4041
4042    /// Load call relations targeting any of the requested symbol names.
4043    ///
4044    /// # Errors
4045    ///
4046    /// Returns an error if reading fails.
4047    pub fn load_call_relations_to_targets(
4048        &self,
4049        target_names: &[String],
4050        limit_per_target: usize,
4051    ) -> DbResult<Vec<SymbolRelation>> {
4052        if target_names.is_empty() {
4053            return Ok(Vec::new());
4054        }
4055        let placeholders = numbered_placeholders(1, target_names.len());
4056        let limit_placeholder = target_names.len() + 1;
4057        let sql = format!(
4058            "
4059            SELECT path, source_name, target_name, kind, line, context, parser
4060            FROM (
4061                SELECT
4062                    path,
4063                    source_name,
4064                    target_name,
4065                    kind,
4066                    line,
4067                    context,
4068                    parser,
4069                    ROW_NUMBER() OVER (
4070                        PARTITION BY target_name
4071                        ORDER BY path, line, source_name, target_name
4072                    ) AS target_row
4073                FROM symbol_relations INDEXED BY idx_symbol_relations_target
4074                WHERE kind = 'calls' AND target_name IN ({placeholders})
4075            )
4076            WHERE target_row <= ?{limit_placeholder}
4077            ORDER BY path, line, source_name, target_name
4078            "
4079        );
4080        let mut values = target_names
4081            .iter()
4082            .map(|target| Value::Text(target.clone()))
4083            .collect::<Vec<_>>();
4084        values.push(Value::Integer(usize_to_i64(limit_per_target.max(1))));
4085        let mut relations = self.query_relations(&sql, params_from_iter(values.iter()))?;
4086        relations.sort_by(|left, right| {
4087            left.path
4088                .cmp(&right.path)
4089                .then_with(|| left.line.cmp(&right.line))
4090                .then_with(|| left.source_name.cmp(&right.source_name))
4091                .then_with(|| left.target_name.cmp(&right.target_name))
4092        });
4093        relations.dedup_by(|left, right| {
4094            left.path == right.path
4095                && left.source_name == right.source_name
4096                && left.target_name == right.target_name
4097                && left.kind == right.kind
4098                && left.line == right.line
4099        });
4100        Ok(relations)
4101    }
4102
4103    /// Load import relations whose persisted target text mentions any term.
4104    ///
4105    /// # Errors
4106    ///
4107    /// Returns an error if reading fails.
4108    pub fn load_import_relations_matching_targets(
4109        &self,
4110        terms: &[String],
4111        limit_per_term: usize,
4112    ) -> DbResult<Vec<StoredImportRelation>> {
4113        let mut unique_terms = terms.to_vec();
4114        unique_terms.sort();
4115        unique_terms.dedup();
4116        let mut relations = Vec::new();
4117        for term in unique_terms.iter().filter(|term| !term.trim().is_empty()) {
4118            let mut statement = self.connection.prepare_cached(
4119                "
4120                SELECT path, source_name, target_name, line
4121                FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
4122                WHERE kind = 'imports' AND target_name LIKE ?1 ESCAPE '\\'
4123                ORDER BY path, line, source_name, target_name
4124                LIMIT ?2
4125                ",
4126            )?;
4127            let rows = statement.query_map(
4128                params![
4129                    sqlite_like_pattern(term),
4130                    usize_to_i64(limit_per_term.max(1))
4131                ],
4132                stored_import_relation_from_row,
4133            )?;
4134            for row in rows {
4135                relations.push(row?);
4136            }
4137        }
4138        relations.sort_by(|left, right| {
4139            left.path
4140                .cmp(&right.path)
4141                .then_with(|| left.line.cmp(&right.line))
4142                .then_with(|| left.source_name.cmp(&right.source_name))
4143                .then_with(|| left.target_name.cmp(&right.target_name))
4144        });
4145        relations.dedup_by(|left, right| {
4146            left.path == right.path
4147                && left.source_name == right.source_name
4148                && left.target_name == right.target_name
4149                && left.line == right.line
4150        });
4151        Ok(relations)
4152    }
4153
4154    /// Load bounded import facts for one exact caller path.
4155    ///
4156    /// # Errors
4157    ///
4158    /// Returns an error if the covering indexed read fails.
4159    pub fn load_import_relations_for_path(
4160        &self,
4161        path: &str,
4162        limit: usize,
4163    ) -> DbResult<Vec<StoredImportRelation>> {
4164        let mut statement = self.connection.prepare_cached(
4165            "
4166            SELECT path, source_name, target_name, line
4167            FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
4168            WHERE kind = 'imports' AND path = ?1
4169            ORDER BY path, line, source_name, target_name
4170            LIMIT ?2
4171            ",
4172        )?;
4173        let rows = statement.query_map(
4174            params![path, usize_to_i64(limit.max(1))],
4175            stored_import_relation_from_row,
4176        )?;
4177        let mut relations = Vec::new();
4178        for row in rows {
4179            relations.push(row?);
4180        }
4181        Ok(relations)
4182    }
4183
4184    /// Count persisted symbols.
4185    ///
4186    /// # Errors
4187    ///
4188    /// Returns an error if reading fails.
4189    pub fn symbol_count(&self) -> DbResult<usize> {
4190        let count = self
4191            .connection
4192            .query_row("SELECT COUNT(*) FROM symbols WHERE NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')", [], |row| {
4193                row.get::<_, i64>(0)
4194            })?;
4195        Ok(i64_to_usize(count))
4196    }
4197
4198    /// Count persisted symbol relations.
4199    ///
4200    /// # Errors
4201    ///
4202    /// Returns an error if reading fails.
4203    pub fn symbol_relation_count(&self) -> DbResult<usize> {
4204        let count =
4205            self.connection
4206                .query_row("SELECT COUNT(*) FROM symbol_relations", [], |row| {
4207                    row.get::<_, i64>(0)
4208                })?;
4209        count_to_usize("symbol_relations", count)
4210    }
4211
4212    /// Count persisted symbols for one file path.
4213    ///
4214    /// # Errors
4215    ///
4216    /// Returns an error if reading fails.
4217    pub fn symbol_count_for_path(&self, path: &str) -> DbResult<usize> {
4218        let count = self.connection.query_row(
4219            "SELECT COUNT(*) FROM symbols WHERE path = ?1 AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')",
4220            [path],
4221            |row| row.get::<_, i64>(0),
4222        )?;
4223        Ok(i64_to_usize(count))
4224    }
4225
4226    /// Count persisted symbols for a batch of file paths.
4227    ///
4228    /// Paths without symbols are omitted from the returned map.
4229    ///
4230    /// # Errors
4231    ///
4232    /// Returns an error if reading fails.
4233    pub fn symbol_counts_for_paths(&self, paths: &[String]) -> DbResult<HashMap<String, usize>> {
4234        let mut counts = HashMap::new();
4235        for chunk in paths.chunks(900) {
4236            if chunk.is_empty() {
4237                continue;
4238            }
4239            let placeholders = vec!["?"; chunk.len()].join(",");
4240            let sql = format!(
4241                "SELECT path, COUNT(*) FROM symbols WHERE path IN ({placeholders}) AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage') GROUP BY path"
4242            );
4243            let mut statement = self.connection.prepare(&sql)?;
4244            let rows = statement.query_map(params_from_iter(chunk.iter()), |row| {
4245                let path = row.get::<_, String>(0)?;
4246                let count = row.get::<_, i64>(1)?;
4247                Ok((path, i64_to_usize(count)))
4248            })?;
4249            for row in rows {
4250                let (path, count) = row?;
4251                counts.insert(path, count);
4252            }
4253        }
4254        Ok(counts)
4255    }
4256
4257    /// Return exact paths with persisted parser metadata from one bounded path set.
4258    ///
4259    /// # Errors
4260    ///
4261    /// Returns an error if reading or row decoding fails.
4262    pub fn source_parse_metadata_paths_for_paths(
4263        &self,
4264        paths: &[String],
4265    ) -> DbResult<HashSet<String>> {
4266        let mut indexed = HashSet::new();
4267        for chunk in paths.chunks(900) {
4268            if chunk.is_empty() {
4269                continue;
4270            }
4271            let placeholders = numbered_placeholders(1, chunk.len());
4272            let sql = format!(
4273                "SELECT path FROM source_parse_metadata
4274                 WHERE path IN ({placeholders})
4275                 ORDER BY path"
4276            );
4277            let mut statement = self.connection.prepare(&sql)?;
4278            let rows = statement.query_map(params_from_iter(chunk.iter()), |row| {
4279                row.get::<_, String>(0)
4280            })?;
4281            for row in rows {
4282                indexed.insert(row?);
4283            }
4284        }
4285        Ok(indexed)
4286    }
4287
4288    /// Return distinct parser strategies that produced symbols for one path.
4289    ///
4290    /// # Errors
4291    ///
4292    /// Returns an error if reading fails.
4293    pub fn symbol_parser_kinds_for_path(&self, path: &str) -> DbResult<Vec<ParserKind>> {
4294        let mut statement = self.connection.prepare(
4295            "
4296            SELECT DISTINCT parser
4297            FROM symbols
4298            WHERE path = ?1
4299            ORDER BY parser
4300            ",
4301        )?;
4302        let rows = statement.query_map([path], |row| {
4303            Ok(ParserKind::from_db(&row.get::<_, String>(0)?))
4304        })?;
4305        let mut parsers = Vec::new();
4306        for row in rows {
4307            parsers.push(row?);
4308        }
4309        Ok(parsers)
4310    }
4311
4312    /// Reconstruct persisted symbol graphs for exact repository paths in bounded batches.
4313    ///
4314    /// Paths without parser metadata are omitted. Any selected symbol or relation
4315    /// without matching metadata, or any metadata count mismatch, fails the whole
4316    /// operation instead of returning a partial graph set.
4317    ///
4318    /// # Errors
4319    ///
4320    /// Returns an error for `SQLite` failures, invalid persisted counts or enums,
4321    /// and inconsistent symbol-graph rows.
4322    pub fn load_symbol_graphs_for_paths(&self, paths: &[String]) -> DbResult<Vec<SymbolGraph>> {
4323        const PATHS_PER_QUERY: usize = 900;
4324
4325        let mut paths = paths.to_vec();
4326        paths.sort();
4327        paths.dedup();
4328        let mut graphs = Vec::with_capacity(paths.len());
4329        for chunk in paths.chunks(PATHS_PER_QUERY) {
4330            let placeholders = numbered_placeholders(1, chunk.len());
4331            let metadata_sql = format!(
4332                "SELECT path, language, source_parser, fact_parser, symbol_count, relation_count
4333                   FROM source_parse_metadata
4334                  WHERE path IN ({placeholders})
4335                  ORDER BY path"
4336            );
4337            let mut metadata_statement = self.connection.prepare(&metadata_sql)?;
4338            let metadata_rows =
4339                metadata_statement.query_map(params_from_iter(chunk.iter()), |row| {
4340                    Ok((
4341                        row.get::<_, String>(0)?,
4342                        row.get::<_, Option<String>>(1)?,
4343                        row.get::<_, String>(2)?,
4344                        row.get::<_, String>(3)?,
4345                        row.get::<_, i64>(4)?,
4346                        row.get::<_, i64>(5)?,
4347                    ))
4348                })?;
4349            let mut staged = BTreeMap::new();
4350            for row in metadata_rows {
4351                let (path, language, source_parser, fact_parser, symbol_count, relation_count) =
4352                    row?;
4353                staged.insert(
4354                    path.clone(),
4355                    (
4356                        SourceParseMetadata {
4357                            path,
4358                            language,
4359                            parser: ParserKind::from_db(&source_parser),
4360                            symbol_count: count_to_usize(
4361                                "source_parse_metadata.symbol_count",
4362                                symbol_count,
4363                            )?,
4364                            relation_count: count_to_usize(
4365                                "source_parse_metadata.relation_count",
4366                                relation_count,
4367                            )?,
4368                        },
4369                        ParserKind::from_db(&fact_parser),
4370                        Vec::new(),
4371                        Vec::new(),
4372                    ),
4373                );
4374            }
4375
4376            let symbol_sql = format!(
4377                "SELECT path, language, name, kind, signature, line_start, line_end,
4378                        parent, parser, detail, exported, documentation,
4379                        source_byte_start, source_byte_end,
4380                        source_column_start, source_column_end
4381                   FROM symbols
4382                  WHERE path IN ({placeholders})
4383                  ORDER BY path, line_start, line_end, name, kind"
4384            );
4385            for symbol in self.query_symbols(&symbol_sql, params_from_iter(chunk.iter()))? {
4386                let path = symbol.path.clone();
4387                let Some((_, _, symbols, _)) = staged.get_mut(&path) else {
4388                    return Err(DbError::SymbolGraphRowShape {
4389                        path,
4390                        reason: "symbol rows require matching parser metadata",
4391                    });
4392                };
4393                symbols.push(symbol);
4394            }
4395
4396            let relation_sql = format!(
4397                "SELECT path, source_name, target_name, kind, line, context, parser
4398                   FROM symbol_relations
4399                  WHERE path IN ({placeholders})
4400                  ORDER BY path, line, source_name, target_name, kind"
4401            );
4402            for relation in self.query_relations(&relation_sql, params_from_iter(chunk.iter()))? {
4403                let path = relation.path.clone();
4404                let Some((_, _, _, relations)) = staged.get_mut(&path) else {
4405                    return Err(DbError::SymbolGraphRowShape {
4406                        path,
4407                        reason: "relation rows require matching parser metadata",
4408                    });
4409                };
4410                relations.push(relation);
4411            }
4412
4413            for (path, (metadata, fact_parser, symbols, relations)) in staged {
4414                if metadata.symbol_count != symbols.len()
4415                    || metadata.relation_count != relations.len()
4416                {
4417                    return Err(DbError::SymbolGraphRowShape {
4418                        path,
4419                        reason: "parser metadata counts do not match persisted rows",
4420                    });
4421                }
4422                graphs.push(SymbolGraph {
4423                    path: metadata.path,
4424                    language: metadata.language,
4425                    parser: fact_parser,
4426                    symbols,
4427                    relations,
4428                });
4429            }
4430        }
4431        Ok(graphs)
4432    }
4433
4434    /// Load file-level parser metadata for one path.
4435    ///
4436    /// # Errors
4437    ///
4438    /// Returns an error if reading fails or stored counts are invalid.
4439    pub fn load_source_parse_metadata(&self, path: &str) -> DbResult<Option<SourceParseMetadata>> {
4440        self.connection
4441            .query_row(
4442                "
4443                SELECT path, language, source_parser, symbol_count, relation_count
4444                FROM source_parse_metadata
4445                WHERE path = ?1
4446                ",
4447                [path],
4448                |row| {
4449                    let symbol_count = row.get::<_, i64>(3)?;
4450                    let relation_count = row.get::<_, i64>(4)?;
4451                    Ok(SourceParseMetadata {
4452                        path: row.get(0)?,
4453                        language: row.get(1)?,
4454                        parser: ParserKind::from_db(&row.get::<_, String>(2)?),
4455                        symbol_count: i64_to_usize(symbol_count),
4456                        relation_count: i64_to_usize(relation_count),
4457                    })
4458                },
4459            )
4460            .optional()
4461            .map_err(Into::into)
4462    }
4463
4464    /// Load the maximum indexed symbol end line for one file path.
4465    ///
4466    /// # Errors
4467    ///
4468    /// Returns an error if reading fails.
4469    pub fn max_symbol_end_line_for_path(&self, path: &str) -> DbResult<usize> {
4470        let line = self.connection.query_row(
4471            "SELECT COALESCE(MAX(line_end), 0) FROM symbols WHERE path = ?1 AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')",
4472            [path],
4473            |row| row.get::<_, i64>(0),
4474        )?;
4475        Ok(i64_to_usize(line))
4476    }
4477
4478    /// Query symbols with a caller-provided statement and parameters.
4479    fn query_symbols<P>(&self, sql: &str, params: P) -> DbResult<Vec<CodeSymbol>>
4480    where
4481        P: rusqlite::Params,
4482    {
4483        let mut statement = self.connection.prepare(sql)?;
4484        let rows = statement.query_map(params, code_symbol_from_row)?;
4485        let mut symbols = Vec::new();
4486        for row in rows {
4487            symbols.push(row?);
4488        }
4489        Ok(symbols)
4490    }
4491
4492    /// Query relations with a caller-provided statement and parameters.
4493    fn query_relations<P>(&self, sql: &str, params: P) -> DbResult<Vec<SymbolRelation>>
4494    where
4495        P: rusqlite::Params,
4496    {
4497        let mut statement = self.connection.prepare(sql)?;
4498        let rows = statement.query_map(params, |row| {
4499            let kind_value: String = row.get(3)?;
4500            let relation_kind = RelationKind::from_db(&kind_value).ok_or_else(|| {
4501                rusqlite::Error::FromSqlConversionFailure(
4502                    3,
4503                    rusqlite::types::Type::Text,
4504                    Box::new(std::io::Error::other(format!(
4505                        "invalid relation kind {kind_value}"
4506                    ))),
4507                )
4508            })?;
4509            Ok(SymbolRelation {
4510                path: row.get(0)?,
4511                source_name: row.get(1)?,
4512                target_name: row.get(2)?,
4513                kind: relation_kind,
4514                line: i64_to_usize(row.get::<_, i64>(4)?),
4515                context: row.get(5)?,
4516                parser: ParserKind::from_db(&row.get::<_, String>(6)?),
4517            })
4518        })?;
4519        let mut relations = Vec::new();
4520        for row in rows {
4521            relations.push(row?);
4522        }
4523        Ok(relations)
4524    }
4525
4526    /// Return whether any accepted agent-authored purpose can affect navigation.
4527    ///
4528    /// # Errors
4529    ///
4530    /// Returns an error when the bounded indexed lookup fails.
4531    pub fn has_agent_approved_purpose(&self) -> DbResult<bool> {
4532        self.connection
4533            .query_row(
4534                "SELECT EXISTS(
4535                    SELECT 1
4536                    FROM purposes INDEXED BY idx_purposes_status
4537                    WHERE status = 'approved' AND source = 'agent'
4538                    LIMIT 1
4539                )",
4540                [],
4541                |row| row.get(0),
4542            )
4543            .map_err(Into::into)
4544    }
4545
4546    /// Persist a purpose for a path.
4547    ///
4548    /// # Errors
4549    ///
4550    /// Returns an error if the path does not exist or persistence fails.
4551    pub fn set_purpose(&self, path: &str, purpose: &str, source: PurposeSource) -> DbResult<()> {
4552        self.with_validated_write(|connection| {
4553            let node_id = self.node_id_for_path(path)?;
4554            let changed = connection
4555                .prepare_cached(
4556                    "
4557            INSERT INTO purposes(node_id, purpose, source, status, updated_at)
4558            VALUES(?1, ?2, ?3, 'approved', CURRENT_TIMESTAMP)
4559            ON CONFLICT(node_id) DO UPDATE SET
4560                purpose = excluded.purpose,
4561                source = excluded.source,
4562                status = 'approved',
4563                updated_at = CURRENT_TIMESTAMP
4564            WHERE purposes.purpose <> excluded.purpose
4565               OR purposes.source <> excluded.source
4566               OR purposes.status <> 'approved'
4567            ",
4568                )?
4569                .execute(params![node_id, purpose, source.to_string()])?;
4570            if changed > 0 {
4571                advance_authored_purpose_revision(connection)?;
4572            }
4573            Ok(())
4574        })
4575    }
4576
4577    /// Return the accepted authored-purpose revision captured by this connection.
4578    ///
4579    /// Databases created before the revision contract have revision zero until
4580    /// their first accepted purpose mutation.
4581    ///
4582    /// # Errors
4583    ///
4584    /// Returns an error when the metadata value is corrupt.
4585    pub fn authored_purpose_revision(&self) -> DbResult<u64> {
4586        load_authored_purpose_revision(&self.connection)
4587    }
4588
4589    /// Approve one purpose only while its curator work and unapproved row are current.
4590    ///
4591    /// This path never changes an accepted purpose. Call [`Self::set_purpose`]
4592    /// for a deliberate correction after an agent or user identifies one.
4593    ///
4594    /// # Errors
4595    ///
4596    /// Returns an error for invalid task input, binding/schema failure, or a
4597    /// failed atomic `SQLite` transaction. Stale and accepted rows are returned
4598    /// as typed non-error states.
4599    pub fn conditionally_set_purpose(
4600        &self,
4601        task: &str,
4602        path: &str,
4603        work_key: &str,
4604        state_token: &str,
4605        purpose: &str,
4606    ) -> DbResult<PurposeConditionalApplyState> {
4607        let request = PurposeConditionalApplyRequest {
4608            task: task.to_string(),
4609            path: path.to_string(),
4610            work_key: work_key.to_string(),
4611            state_token: state_token.to_string(),
4612            purpose: purpose.to_string(),
4613        };
4614        let mut results = self.conditionally_set_purposes(&[request])?;
4615        Ok(results
4616            .pop()
4617            .map_or(PurposeConditionalApplyState::PathUnavailable, |result| {
4618                result.state
4619            }))
4620    }
4621
4622    /// Apply a bounded stale-safe purpose-review batch in one writer transaction.
4623    ///
4624    /// Current-row lookup and conditional-update statements are cached and
4625    /// reused for every item. A stale item leaves independent matching rows
4626    /// eligible within the same host-owned batch.
4627    ///
4628    /// # Errors
4629    ///
4630    /// Returns an error for invalid task input, oversized input, binding/schema
4631    /// failure, or any failed statement/commit. A database error rolls back the
4632    /// complete batch.
4633    pub fn conditionally_set_purposes(
4634        &self,
4635        requests: &[PurposeConditionalApplyRequest],
4636    ) -> DbResult<Vec<PurposeConditionalApplyResult>> {
4637        if requests.is_empty() {
4638            return Ok(Vec::new());
4639        }
4640        if requests.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
4641            return Err(DbError::PurposeCurationBatchTooLarge {
4642                requested: requests.len(),
4643                maximum: MAX_PURPOSE_CURATION_BATCH_ROWS,
4644            });
4645        }
4646        let normalized = requests
4647            .iter()
4648            .map(|request| {
4649                normalize_purpose_curation_task(&request.task).map(|task| {
4650                    PurposeConditionalApplyRequest {
4651                        task,
4652                        path: request.path.clone(),
4653                        work_key: request.work_key.clone(),
4654                        state_token: request.state_token.clone(),
4655                        purpose: request.purpose.clone(),
4656                    }
4657                })
4658            })
4659            .collect::<DbResult<Vec<_>>>()?;
4660        self.with_validated_write(|connection| {
4661            let (project_instance_id, active_generation) =
4662                self.purpose_curation_context(connection)?;
4663            let results = normalized
4664                .iter()
4665                .map(|request| {
4666                    apply_conditional_purpose(
4667                        connection,
4668                        project_instance_id,
4669                        active_generation,
4670                        request,
4671                    )
4672                })
4673                .collect::<DbResult<Vec<_>>>()?;
4674            if results
4675                .iter()
4676                .any(|result| matches!(result.state, PurposeConditionalApplyState::Applied))
4677            {
4678                advance_authored_purpose_revision(connection)?;
4679            }
4680            Ok(results)
4681        })
4682    }
4683
4684    /// Persist a non-approved purpose suggestion for a path.
4685    ///
4686    /// # Errors
4687    ///
4688    /// Returns an error if the path does not exist or persistence fails.
4689    pub fn set_suggested_purpose(&self, path: &str, purpose: &str) -> DbResult<()> {
4690        self.with_validated_write(|connection| {
4691            let node_id = self.node_id_for_path(path)?;
4692            connection
4693                .prepare_cached(
4694                    "
4695            INSERT INTO purposes(node_id, purpose, source, status, updated_at)
4696            VALUES(?1, ?2, ?3, ?4, CURRENT_TIMESTAMP)
4697            ON CONFLICT(node_id) DO UPDATE SET
4698                purpose = excluded.purpose,
4699                source = excluded.source,
4700                status = excluded.status,
4701                updated_at = CURRENT_TIMESTAMP
4702            WHERE purposes.status IN (?5, ?6)
4703            ",
4704                )?
4705                .execute(params![
4706                    node_id,
4707                    purpose,
4708                    PurposeSource::Generated.to_string(),
4709                    PurposeStatus::Suggested.as_str(),
4710                    PurposeStatus::Missing.as_str(),
4711                    PurposeStatus::Suggested.as_str(),
4712                ])?;
4713            Ok(())
4714        })
4715    }
4716
4717    /// Load a node id for a repository path.
4718    fn node_id_for_path(&self, path: &str) -> DbResult<i64> {
4719        self.connection
4720            .prepare_cached("SELECT id FROM nodes WHERE path = ?1 AND exists_now = 1")?
4721            .query_row([path], |row| row.get::<_, i64>(0))
4722            .optional()?
4723            .ok_or_else(|| DbError::PathNotIndexed {
4724                path: path.to_string(),
4725            })
4726    }
4727
4728    /// Visit live source-derived nodes in path order without loading authored metadata.
4729    ///
4730    /// The cursor retains one row at a time and observes the caller's current read
4731    /// snapshot. The visitor must discard its accumulated result if this method
4732    /// fails; it must not publish partial comparison state or mutate this store.
4733    ///
4734    /// # Errors
4735    ///
4736    /// Returns an error on cancellation, invalid source fields, a database read
4737    /// failure, or a visitor failure.
4738    pub fn visit_source_nodes_controlled(
4739        &self,
4740        control: &IndexWorkControl,
4741        mut visit: impl FnMut(Node) -> DbResult<()>,
4742    ) -> DbResult<()> {
4743        with_sqlite_read_progress(
4744            &self.connection,
4745            Some(control),
4746            IndexWorkStage::RepositoryTraversal,
4747            || {
4748                let mut statement = self.connection.prepare_cached(
4749                    "SELECT n.path, n.kind, n.parent_path, n.extension, n.language,
4750                            n.size_bytes, n.mtime_ns, n.content_hash
4751                     FROM nodes n JOIN purposes p ON p.node_id = n.id
4752                     WHERE n.exists_now = 1 ORDER BY n.path",
4753                )?;
4754                let mut rows = statement.query([])?;
4755                while let Some(row) = rows.next()? {
4756                    control.check(IndexWorkStage::RepositoryTraversal)?;
4757                    let kind_value: String = row.get(1)?;
4758                    let kind =
4759                        NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
4760                            field: "kind",
4761                            value: kind_value,
4762                        })?;
4763                    visit(Node {
4764                        path: row.get(0)?,
4765                        kind,
4766                        parent_path: row.get(2)?,
4767                        extension: row.get(3)?,
4768                        language: row.get(4)?,
4769                        size_bytes: option_u64_from_sql(row, 5)?,
4770                        mtime_ns: row.get(6)?,
4771                        content_hash: row.get(7)?,
4772                    })?;
4773                }
4774                Ok(())
4775            },
4776        )
4777    }
4778
4779    /// Load existing nodes with purpose state.
4780    ///
4781    /// # Errors
4782    ///
4783    /// Returns an error if reading or enum conversion fails.
4784    pub fn load_nodes(&self) -> DbResult<Vec<IndexedNode>> {
4785        let mut statement = self.connection.prepare(
4786            "
4787            SELECT
4788                n.path,
4789                n.kind,
4790                n.parent_path,
4791                n.extension,
4792                n.language,
4793                n.size_bytes,
4794                n.mtime_ns,
4795                n.content_hash,
4796                p.purpose,
4797                p.source,
4798                p.status,
4799                s.summary
4800            FROM nodes n
4801            JOIN purposes p ON p.node_id = n.id
4802            LEFT JOIN summaries s ON s.node_id = n.id
4803                AND s.summary_level = 'node'
4804                AND s.subject = ''
4805            WHERE n.exists_now = 1
4806            ORDER BY n.path
4807            ",
4808        )?;
4809        let rows = statement.query_map([], |row| {
4810            let kind_value: String = row.get(1)?;
4811            let source_value: String = row.get(9)?;
4812            let status_value: String = row.get(10)?;
4813            Ok((
4814                row.get::<_, String>(0)?,
4815                kind_value,
4816                row.get::<_, Option<String>>(2)?,
4817                row.get::<_, Option<String>>(3)?,
4818                row.get::<_, Option<String>>(4)?,
4819                option_u64_from_sql(row, 5)?,
4820                row.get::<_, Option<i64>>(6)?,
4821                row.get::<_, Option<String>>(7)?,
4822                row.get::<_, Option<String>>(8)?,
4823                source_value,
4824                status_value,
4825                row.get::<_, Option<String>>(11)?,
4826            ))
4827        })?;
4828        let mut nodes = Vec::new();
4829        for row in rows {
4830            let (
4831                path,
4832                kind_value,
4833                parent_path,
4834                extension,
4835                language,
4836                size_bytes,
4837                mtime_ns,
4838                content_hash,
4839                purpose,
4840                source_value,
4841                status_value,
4842                summary,
4843            ) = row?;
4844            let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
4845                field: "kind",
4846                value: kind_value,
4847            })?;
4848            let source = parse_source(&source_value)?;
4849            let status =
4850                PurposeStatus::from_db(&status_value).ok_or_else(|| DbError::InvalidEnum {
4851                    field: "status",
4852                    value: status_value,
4853                })?;
4854            nodes.push(IndexedNode {
4855                node: Node {
4856                    path: path.clone(),
4857                    kind,
4858                    parent_path,
4859                    extension,
4860                    language,
4861                    size_bytes,
4862                    mtime_ns,
4863                    content_hash,
4864                },
4865                purpose: Purpose {
4866                    path,
4867                    purpose,
4868                    source,
4869                    status,
4870                },
4871                summary,
4872            });
4873        }
4874        Ok(nodes)
4875    }
4876
4877    /// Load a bounded ranked node list directly from `SQLite`.
4878    ///
4879    /// This is the hot path for agent orientation commands. It keeps large
4880    /// repositories from materializing every indexed path just to answer a
4881    /// top-N folder or file query.
4882    ///
4883    /// # Errors
4884    ///
4885    /// Returns an error if reading or enum conversion fails.
4886    pub fn load_ranked_nodes(
4887        &self,
4888        query: &str,
4889        kind: NodeKind,
4890        folder: Option<&str>,
4891        limit: usize,
4892        offset: usize,
4893    ) -> DbResult<Vec<IndexedNode>> {
4894        let terms = normalize_query_terms(query);
4895        let exact_query = normalize_exact_ranked_query(query);
4896        let exact_name_enabled = !exact_query.is_empty() && !exact_query.contains('/');
4897        let exact_name_pattern = format!("%/{}", sqlite_like_escape(&exact_query));
4898        let reviewed_match_expression = reviewed_purpose_match_expression(terms.len());
4899        let score_expression = ranked_score_expression(terms.len());
4900        let mut sql = format!(
4901            "
4902            SELECT path, kind, parent_path, extension, language, size_bytes, mtime_ns,
4903                   content_hash, purpose, source, status, summary
4904            FROM (
4905                SELECT
4906                    n.path,
4907                    n.kind,
4908                    n.parent_path,
4909                    n.extension,
4910                    n.language,
4911                    n.size_bytes,
4912                    n.mtime_ns,
4913                    n.content_hash,
4914                    p.purpose,
4915                    p.source,
4916                    p.status,
4917                    s.summary,
4918                    CASE WHEN lower(n.path) = ? THEN 1 ELSE 0 END AS exact_path,
4919                    CASE WHEN ? = 1
4920                              AND (lower(n.path) = ? OR lower(n.path) LIKE ? ESCAPE '\\')
4921                         THEN 1 ELSE 0 END AS exact_name,
4922                    {reviewed_match_expression} AS reviewed_purpose,
4923                    {score_expression} AS score
4924                FROM nodes n
4925                JOIN purposes p ON p.node_id = n.id
4926                LEFT JOIN summaries s ON s.node_id = n.id
4927                    AND s.summary_level = 'node'
4928                    AND s.subject = ''
4929                LEFT JOIN summaries symbol_summaries ON symbol_summaries.node_id = n.id
4930                    AND symbol_summaries.summary_level = 'search'
4931                    AND symbol_summaries.subject = 'symbols'
4932                WHERE n.exists_now = 1
4933                  AND n.kind = ?
4934            "
4935        );
4936        let mut values = vec![
4937            Value::from(exact_query.clone()),
4938            Value::from(i64::from(exact_name_enabled)),
4939            Value::from(exact_query),
4940            Value::from(exact_name_pattern),
4941        ];
4942        for term in &terms {
4943            values.push(Value::from(sqlite_like_pattern(term)));
4944        }
4945        for term in &terms {
4946            let pattern = sqlite_like_pattern(term);
4947            values.push(Value::from(pattern.clone()));
4948            values.push(Value::from(pattern.clone()));
4949            values.push(Value::from(pattern.clone()));
4950            values.push(Value::from(pattern.clone()));
4951            values.push(Value::from(pattern));
4952        }
4953        values.push(Value::from(kind.to_string()));
4954        if kind == NodeKind::File
4955            && let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".")
4956        {
4957            sql.push_str(" AND (n.parent_path = ? OR n.parent_path LIKE ? ESCAPE '\\')");
4958            values.push(Value::from(folder.to_string()));
4959            values.push(Value::from(sqlite_descendant_pattern(folder)));
4960        }
4961        sql.push_str(
4962            "
4963            )
4964            WHERE score > 0 OR exact_path > 0 OR exact_name > 0 OR reviewed_purpose > 0
4965            ORDER BY exact_path DESC, exact_name DESC, reviewed_purpose DESC, score DESC, path
4966            LIMIT ?
4967            OFFSET ?
4968            ",
4969        );
4970        values.push(Value::from(usize_to_i64(limit.max(1))));
4971        values.push(Value::from(usize_to_i64(offset)));
4972
4973        let mut statement = self.connection.prepare(&sql)?;
4974        let mut rows = statement.query(params_from_iter(values))?;
4975        let mut nodes = Vec::new();
4976        while let Some(row) = rows.next()? {
4977            nodes.push(indexed_node_from_sql_row(row)?);
4978        }
4979        Ok(nodes)
4980    }
4981
4982    /// Sum indexed source bytes represented by file nodes.
4983    ///
4984    /// # Errors
4985    ///
4986    /// Returns an error if reading fails or the aggregate cannot fit in `usize`.
4987    pub fn source_file_byte_count(&self, folder: Option<&str>) -> DbResult<usize> {
4988        let mut sql = String::from(
4989            "
4990            SELECT COALESCE(SUM(COALESCE(size_bytes, 0)), 0)
4991            FROM nodes
4992            WHERE exists_now = 1
4993              AND kind = 'file'
4994            ",
4995        );
4996        let mut values = Vec::new();
4997        if let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".") {
4998            sql.push_str(" AND (parent_path = ? OR parent_path LIKE ? ESCAPE '\\')");
4999            values.push(Value::from(folder.to_string()));
5000            values.push(Value::from(sqlite_descendant_pattern(folder)));
5001        }
5002        let count = self
5003            .connection
5004            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5005        count_to_usize("source_file_bytes", count)
5006    }
5007
5008    /// Visit indexed file paths and source sizes for exact token baselines.
5009    ///
5010    /// # Errors
5011    ///
5012    /// Returns an error if reading fails, stored counts are invalid, or the
5013    /// visitor returns an error.
5014    pub fn visit_file_token_estimates<F>(
5015        &self,
5016        folder: Option<&str>,
5017        mut visitor: F,
5018    ) -> DbResult<()>
5019    where
5020        F: FnMut(String, Option<u64>) -> DbResult<bool>,
5021    {
5022        let mut sql = String::from(
5023            "
5024            SELECT path, size_bytes
5025            FROM nodes
5026            WHERE exists_now = 1
5027              AND kind = 'file'
5028            ",
5029        );
5030        let mut values = Vec::new();
5031        if let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".") {
5032            sql.push_str(" AND (parent_path = ? OR parent_path LIKE ? ESCAPE '\\')");
5033            values.push(Value::from(folder.to_string()));
5034            values.push(Value::from(sqlite_descendant_pattern(folder)));
5035        }
5036        sql.push_str(" ORDER BY path");
5037        let mut statement = self.connection.prepare(&sql)?;
5038        let mut rows = statement.query(params_from_iter(values))?;
5039        while let Some(row) = rows.next()? {
5040            if !visitor(row.get::<_, String>(0)?, option_u64_from_sql(row, 1)?)? {
5041                return Ok(());
5042            }
5043        }
5044        Ok(())
5045    }
5046
5047    /// Build unresolved health findings without loading the full node table.
5048    ///
5049    /// # Errors
5050    ///
5051    /// Returns an error if reading fails or stored enum values are invalid.
5052    pub fn unresolved_health_findings(
5053        &self,
5054        resolved_ids: &[String],
5055    ) -> DbResult<Vec<HealthFinding>> {
5056        let mut findings = Vec::new();
5057        self.visit_unresolved_health_findings(resolved_ids, |finding| {
5058            findings.push(finding);
5059            Ok(true)
5060        })?;
5061        Ok(findings)
5062    }
5063
5064    /// Build a bounded unresolved health findings page.
5065    ///
5066    /// # Errors
5067    ///
5068    /// Returns an error if reading fails or stored enum values are invalid.
5069    pub fn unresolved_health_findings_page(
5070        &self,
5071        resolved_ids: &[String],
5072        query: &HealthQuery,
5073    ) -> DbResult<HealthFindingsPage> {
5074        self.unresolved_health_findings_page_with_filter(
5075            HealthResolutionFilter::Explicit(resolved_ids),
5076            query,
5077        )
5078    }
5079
5080    /// Build a bounded page filtered by this store's durable resolutions.
5081    ///
5082    /// # Errors
5083    ///
5084    /// Returns an error if reading fails or stored enum values are invalid.
5085    pub fn unresolved_health_findings_page_current(
5086        &self,
5087        query: &HealthQuery,
5088    ) -> DbResult<HealthFindingsPage> {
5089        self.unresolved_health_findings_page_with_filter(HealthResolutionFilter::Stored, query)
5090    }
5091
5092    /// Build the actionable missing/suggested purpose queue for one low-scope request.
5093    ///
5094    /// Accepted and legacy-stale purposes are excluded; deliberate accepted-purpose
5095    /// correction remains owned by the explicit purpose-set/review path.
5096    ///
5097    /// # Errors
5098    ///
5099    /// Returns an error if counting, paging, or stored-state conversion fails.
5100    pub fn purpose_curation_findings_page_current(
5101        &self,
5102        query: &HealthQuery,
5103    ) -> DbResult<HealthFindingsPage> {
5104        let specs = &PURPOSE_HEALTH_SPECS[..2];
5105        let unfiltered_total = specs.iter().try_fold(0_usize, |total, spec| {
5106            self.count_purpose_status_findings(
5107                *spec,
5108                None,
5109                HealthResolutionFilter::Stored,
5110                HealthScope::all(),
5111            )
5112            .map(|count| total + count)
5113        })?;
5114        if query
5115            .severity
5116            .is_some_and(|severity| severity != Severity::Warning)
5117        {
5118            return Ok(HealthFindingsPage {
5119                total: 0,
5120                unfiltered_total,
5121                returned: 0,
5122                start_index: query.start_index,
5123                limit: query.limit,
5124                findings: Vec::new(),
5125            });
5126        }
5127
5128        let matching_specs = query.category.as_deref().map_or(specs, |category| {
5129            specs
5130                .iter()
5131                .find(|spec| spec.category == category)
5132                .map_or(&[][..], std::slice::from_ref)
5133        });
5134        let total = self.count_purpose_lifecycle_findings(
5135            matching_specs,
5136            query.path_prefix.as_deref(),
5137            HealthResolutionFilter::Stored,
5138            query.scope,
5139        )?;
5140        let findings = if query.summary_only {
5141            Vec::new()
5142        } else {
5143            self.load_purpose_lifecycle_findings_page(
5144                matching_specs,
5145                query.path_prefix.as_deref(),
5146                HealthResolutionFilter::Stored,
5147                query.scope,
5148                query.start_index,
5149                query.limit,
5150            )?
5151        };
5152        Ok(HealthFindingsPage {
5153            total,
5154            unfiltered_total,
5155            returned: findings.len(),
5156            start_index: query.start_index,
5157            limit: query.limit,
5158            findings,
5159        })
5160    }
5161
5162    /// Count all unresolved findings without materializing finding rows or resolution ids.
5163    ///
5164    /// # Errors
5165    ///
5166    /// Returns an error if reading fails or stored enum values are invalid.
5167    pub fn unresolved_health_finding_count_current(&self) -> DbResult<usize> {
5168        self.unresolved_health_findings_page_current(&HealthQuery {
5169            start_index: 0,
5170            limit: 0,
5171            category: None,
5172            severity: None,
5173            path_prefix: None,
5174            summary_only: true,
5175            scope: HealthScope::all(),
5176        })
5177        .map(|page| page.total)
5178    }
5179
5180    /// Build a bounded unresolved page with caller-owned or store-owned filtering.
5181    fn unresolved_health_findings_page_with_filter(
5182        &self,
5183        resolution_filter: HealthResolutionFilter<'_>,
5184        query: &HealthQuery,
5185    ) -> DbResult<HealthFindingsPage> {
5186        let mut unfiltered_total = 0_usize;
5187        let mut total = 0_usize;
5188        let mut findings = Vec::new();
5189
5190        for spec in PURPOSE_HEALTH_SPECS {
5191            unfiltered_total += self.count_purpose_status_findings(
5192                spec,
5193                None,
5194                resolution_filter,
5195                HealthScope::all(),
5196            )?;
5197        }
5198
5199        let scope = query.scope;
5200        if scope.high_impact_queue() && query.category.is_none() {
5201            let matching_count = if query
5202                .severity
5203                .is_none_or(|severity| severity == Severity::Warning)
5204            {
5205                self.count_purpose_lifecycle_findings(
5206                    &PURPOSE_HEALTH_SPECS,
5207                    query.path_prefix.as_deref(),
5208                    resolution_filter,
5209                    scope,
5210                )?
5211            } else {
5212                0
5213            };
5214            if !query.summary_only
5215                && findings.len() < query.limit
5216                && total + matching_count > query.start_index
5217            {
5218                let local_start = query.start_index.saturating_sub(total);
5219                let local_limit = query.limit - findings.len();
5220                findings.extend(self.load_purpose_lifecycle_findings_page(
5221                    &PURPOSE_HEALTH_SPECS,
5222                    query.path_prefix.as_deref(),
5223                    resolution_filter,
5224                    scope,
5225                    local_start,
5226                    local_limit,
5227                )?);
5228            }
5229            total += matching_count;
5230        } else {
5231            for spec in PURPOSE_HEALTH_SPECS {
5232                if !purpose_health_spec_matches_query(spec, query) {
5233                    continue;
5234                }
5235
5236                let matching_count = self.count_purpose_status_findings(
5237                    spec,
5238                    query.path_prefix.as_deref(),
5239                    resolution_filter,
5240                    scope,
5241                )?;
5242                if !query.summary_only
5243                    && findings.len() < query.limit
5244                    && total + matching_count > query.start_index
5245                {
5246                    let local_start = query.start_index.saturating_sub(total);
5247                    let local_limit = query.limit - findings.len();
5248                    findings.extend(self.load_purpose_status_findings_page(
5249                        spec,
5250                        query.path_prefix.as_deref(),
5251                        resolution_filter,
5252                        scope,
5253                        local_start,
5254                        local_limit,
5255                    )?);
5256                }
5257                total += matching_count;
5258            }
5259        }
5260
5261        for category in STRUCTURAL_HEALTH_CATEGORIES {
5262            let unfiltered_scope = if category == CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED {
5263                HealthScope::purpose_strict()
5264            } else {
5265                HealthScope::all()
5266            };
5267            let unfiltered_count = self.count_structural_health_findings(
5268                category,
5269                None,
5270                resolution_filter,
5271                unfiltered_scope,
5272            )?;
5273            unfiltered_total += unfiltered_count;
5274            if !health_category_matches_query(category, Severity::Warning, query) {
5275                continue;
5276            }
5277            let matching_count = self.count_structural_health_findings(
5278                category,
5279                query.path_prefix.as_deref(),
5280                resolution_filter,
5281                scope,
5282            )?;
5283            if !query.summary_only
5284                && findings.len() < query.limit
5285                && total + matching_count > query.start_index
5286            {
5287                let local_start = query.start_index.saturating_sub(total);
5288                let local_limit = query.limit - findings.len();
5289                findings.extend(self.load_structural_health_findings_page(
5290                    category,
5291                    query.path_prefix.as_deref(),
5292                    resolution_filter,
5293                    scope,
5294                    local_start,
5295                    local_limit,
5296                )?);
5297            }
5298            total += matching_count;
5299        }
5300        Ok(HealthFindingsPage {
5301            total,
5302            unfiltered_total,
5303            returned: findings.len(),
5304            start_index: query.start_index,
5305            limit: query.limit,
5306            findings,
5307        })
5308    }
5309
5310    /// Visit unresolved health findings without materializing the full table.
5311    fn visit_unresolved_health_findings<F>(
5312        &self,
5313        resolved_ids: &[String],
5314        mut visitor: F,
5315    ) -> DbResult<()>
5316    where
5317        F: FnMut(HealthFinding) -> DbResult<bool>,
5318    {
5319        let resolved = resolved_ids.iter().cloned().collect::<HashSet<_>>();
5320        if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[0], &resolved, &mut visitor)? {
5321            return Ok(());
5322        }
5323        if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[1], &resolved, &mut visitor)? {
5324            return Ok(());
5325        }
5326        if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[2], &resolved, &mut visitor)? {
5327            return Ok(());
5328        }
5329        if !self.visit_agent_review_required_findings(&resolved, &mut visitor)? {
5330            return Ok(());
5331        }
5332        self.visit_structural_health_findings(&resolved, &mut visitor)
5333    }
5334
5335    /// Visit structural health findings that are not simple purpose statuses.
5336    fn visit_structural_health_findings<F>(
5337        &self,
5338        resolved_ids: &HashSet<String>,
5339        mut visitor: F,
5340    ) -> DbResult<()>
5341    where
5342        F: FnMut(HealthFinding) -> DbResult<bool>,
5343    {
5344        if !self.visit_duplicate_purpose_findings(resolved_ids, &mut visitor)? {
5345            return Ok(());
5346        }
5347        if !self.visit_repeated_temp_folder_findings(resolved_ids, &mut visitor)? {
5348            return Ok(());
5349        }
5350        Ok(())
5351    }
5352
5353    /// Build findings for one purpose lifecycle status.
5354    fn visit_purpose_status_findings<F>(
5355        &self,
5356        spec: PurposeHealthSpec,
5357        resolved_ids: &HashSet<String>,
5358        visitor: &mut F,
5359    ) -> DbResult<bool>
5360    where
5361        F: FnMut(HealthFinding) -> DbResult<bool>,
5362    {
5363        let mut statement = self.connection.prepare(
5364            "
5365            SELECT n.path
5366            FROM nodes n
5367            JOIN purposes p ON p.node_id = n.id
5368            WHERE n.exists_now = 1
5369              AND p.status = ?1
5370            ORDER BY n.path
5371            ",
5372        )?;
5373        let rows = statement.query_map([spec.status], |row| row.get::<_, String>(0))?;
5374        for row in rows {
5375            let path = row?;
5376            let finding = HealthFinding {
5377                id: finding_id(spec.category, &path, None),
5378                severity: Severity::Warning,
5379                category: spec.category.to_string(),
5380                path,
5381                related_path: None,
5382                message: spec.message.to_string(),
5383                recommendation: spec.recommendation.to_string(),
5384            };
5385            if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
5386                return Ok(false);
5387            }
5388        }
5389        Ok(true)
5390    }
5391
5392    /// Count unresolved purpose lifecycle findings directly in `SQLite`.
5393    fn count_purpose_lifecycle_findings(
5394        &self,
5395        specs: &[PurposeHealthSpec],
5396        path_prefix: Option<&str>,
5397        resolution_filter: HealthResolutionFilter<'_>,
5398        scope: HealthScope,
5399    ) -> DbResult<usize> {
5400        if specs.is_empty() {
5401            return Ok(0);
5402        }
5403        let (where_clause, values) =
5404            purpose_lifecycle_where_clause(specs, path_prefix, resolution_filter, scope);
5405        let sql = format!(
5406            "
5407            SELECT COUNT(*)
5408            FROM nodes n
5409            JOIN purposes p ON p.node_id = n.id
5410            WHERE {where_clause}
5411            "
5412        );
5413        let count = self
5414            .connection
5415            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5416        count_to_usize("health_purpose_lifecycle_count", count)
5417    }
5418
5419    /// Load one globally ordered purpose lifecycle page directly from `SQLite`.
5420    fn load_purpose_lifecycle_findings_page(
5421        &self,
5422        specs: &[PurposeHealthSpec],
5423        path_prefix: Option<&str>,
5424        resolution_filter: HealthResolutionFilter<'_>,
5425        scope: HealthScope,
5426        start_index: usize,
5427        limit: usize,
5428    ) -> DbResult<Vec<HealthFinding>> {
5429        if limit == 0 || specs.is_empty() {
5430            return Ok(Vec::new());
5431        }
5432        let (where_clause, mut values) =
5433            purpose_lifecycle_where_clause(specs, path_prefix, resolution_filter, scope);
5434        let limit_placeholder = values.len() + 1;
5435        let offset_placeholder = values.len() + 2;
5436        values.push(Value::from(usize_to_i64(limit)));
5437        values.push(Value::from(usize_to_i64(start_index)));
5438        let order_by = purpose_default_queue_order_expression("n", "p");
5439        let sql = format!(
5440            "
5441            SELECT n.path, p.status
5442            FROM nodes n
5443            JOIN purposes p ON p.node_id = n.id
5444            WHERE {where_clause}
5445            ORDER BY {order_by}
5446            LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5447            "
5448        );
5449        let mut statement = self.connection.prepare(&sql)?;
5450        let rows = statement.query_map(params_from_iter(values), |row| {
5451            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5452        })?;
5453        let mut findings = Vec::new();
5454        for row in rows {
5455            let (path, status) = row?;
5456            let spec = purpose_health_spec_for_status(&status)?;
5457            findings.push(HealthFinding {
5458                id: finding_id(spec.category, &path, None),
5459                severity: Severity::Warning,
5460                category: spec.category.to_string(),
5461                path,
5462                related_path: None,
5463                message: spec.message.to_string(),
5464                recommendation: spec.recommendation.to_string(),
5465            });
5466        }
5467        Ok(findings)
5468    }
5469
5470    /// Count unresolved purpose lifecycle findings directly in `SQLite`.
5471    fn count_purpose_status_findings(
5472        &self,
5473        spec: PurposeHealthSpec,
5474        path_prefix: Option<&str>,
5475        resolution_filter: HealthResolutionFilter<'_>,
5476        scope: HealthScope,
5477    ) -> DbResult<usize> {
5478        let (where_clause, values) =
5479            purpose_status_where_clause(spec, path_prefix, resolution_filter, scope);
5480        let sql = format!(
5481            "
5482            SELECT COUNT(*)
5483            FROM nodes n
5484            JOIN purposes p ON p.node_id = n.id
5485            WHERE {where_clause}
5486            "
5487        );
5488        let count = self
5489            .connection
5490            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5491        count_to_usize("health_purpose_status_count", count)
5492    }
5493
5494    /// Load one bounded unresolved purpose lifecycle page directly from `SQLite`.
5495    fn load_purpose_status_findings_page(
5496        &self,
5497        spec: PurposeHealthSpec,
5498        path_prefix: Option<&str>,
5499        resolution_filter: HealthResolutionFilter<'_>,
5500        scope: HealthScope,
5501        start_index: usize,
5502        limit: usize,
5503    ) -> DbResult<Vec<HealthFinding>> {
5504        if limit == 0 {
5505            return Ok(Vec::new());
5506        }
5507        let (where_clause, mut values) =
5508            purpose_status_where_clause(spec, path_prefix, resolution_filter, scope);
5509        let limit_placeholder = values.len() + 1;
5510        let offset_placeholder = values.len() + 2;
5511        values.push(Value::from(usize_to_i64(limit)));
5512        values.push(Value::from(usize_to_i64(start_index)));
5513        let order_by = if scope.high_impact_queue() {
5514            purpose_default_queue_order_expression("n", "p")
5515        } else {
5516            "n.path".to_string()
5517        };
5518        let sql = format!(
5519            "
5520            SELECT n.path
5521            FROM nodes n
5522            JOIN purposes p ON p.node_id = n.id
5523            WHERE {where_clause}
5524            ORDER BY {order_by}
5525            LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5526            "
5527        );
5528        let mut statement = self.connection.prepare(&sql)?;
5529        let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(0))?;
5530        let mut findings = Vec::new();
5531        for row in rows {
5532            let path = row?;
5533            findings.push(HealthFinding {
5534                id: finding_id(spec.category, &path, None),
5535                severity: Severity::Warning,
5536                category: spec.category.to_string(),
5537                path,
5538                related_path: None,
5539                message: spec.message.to_string(),
5540                recommendation: spec.recommendation.to_string(),
5541            });
5542        }
5543        Ok(findings)
5544    }
5545
5546    /// Count unresolved structural health findings directly in `SQLite`.
5547    fn count_structural_health_findings(
5548        &self,
5549        category: &str,
5550        path_prefix: Option<&str>,
5551        resolution_filter: HealthResolutionFilter<'_>,
5552        scope: HealthScope,
5553    ) -> DbResult<usize> {
5554        match category {
5555            CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => {
5556                self.count_agent_review_required_findings(path_prefix, resolution_filter, scope)
5557            }
5558            CATEGORY_DUPLICATE_PURPOSE => {
5559                self.count_duplicate_purpose_findings(path_prefix, resolution_filter, scope)
5560            }
5561            CATEGORY_REPEATED_TEMPORARY_FOLDER => {
5562                self.count_repeated_temp_folder_findings(path_prefix, resolution_filter, scope)
5563            }
5564            _ => Ok(0),
5565        }
5566    }
5567
5568    /// Load a bounded unresolved structural health page directly from `SQLite`.
5569    fn load_structural_health_findings_page(
5570        &self,
5571        category: &str,
5572        path_prefix: Option<&str>,
5573        resolution_filter: HealthResolutionFilter<'_>,
5574        scope: HealthScope,
5575        start_index: usize,
5576        limit: usize,
5577    ) -> DbResult<Vec<HealthFinding>> {
5578        match category {
5579            CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => self
5580                .load_agent_review_required_findings_page(
5581                    path_prefix,
5582                    resolution_filter,
5583                    scope,
5584                    start_index,
5585                    limit,
5586                ),
5587            CATEGORY_DUPLICATE_PURPOSE => self.load_duplicate_purpose_findings_page(
5588                path_prefix,
5589                resolution_filter,
5590                scope,
5591                start_index,
5592                limit,
5593            ),
5594            CATEGORY_REPEATED_TEMPORARY_FOLDER => self.load_repeated_temp_folder_findings_page(
5595                path_prefix,
5596                resolution_filter,
5597                scope,
5598                start_index,
5599                limit,
5600            ),
5601            _ => Ok(Vec::new()),
5602        }
5603    }
5604
5605    /// Visit approved navigation-critical purposes that still need agent review.
5606    fn visit_agent_review_required_findings<F>(
5607        &self,
5608        resolved_ids: &HashSet<String>,
5609        visitor: &mut F,
5610    ) -> DbResult<bool>
5611    where
5612        F: FnMut(HealthFinding) -> DbResult<bool>,
5613    {
5614        let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
5615        let high_impact = high_impact_file_path_expression("lower(n.path)");
5616        let approved_status = PurposeStatus::Approved.as_str();
5617        let sql = format!(
5618            "
5619            SELECT n.path
5620            FROM nodes n
5621            JOIN purposes p ON p.node_id = n.id
5622            WHERE n.exists_now = 1
5623              AND p.status = '{approved_status}'
5624              AND p.source NOT IN ({reviewed_sources})
5625              AND (n.kind = 'folder' OR (n.kind = 'file' AND {high_impact}))
5626            ORDER BY CASE WHEN n.kind = 'folder' THEN 0 ELSE 1 END, n.path
5627            "
5628        );
5629        let mut statement = self.connection.prepare(&sql)?;
5630        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
5631        for row in rows {
5632            let finding = agent_review_required_finding(row?);
5633            if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
5634                return Ok(false);
5635            }
5636        }
5637        Ok(true)
5638    }
5639
5640    /// Count approved navigation-critical purposes that still need agent review.
5641    fn count_agent_review_required_findings(
5642        &self,
5643        path_prefix: Option<&str>,
5644        resolution_filter: HealthResolutionFilter<'_>,
5645        scope: HealthScope,
5646    ) -> DbResult<usize> {
5647        let (where_clause, values) = structural_finding_where_clause(
5648            CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
5649            path_prefix,
5650            resolution_filter,
5651            scope,
5652            1,
5653        );
5654        let source_relevant = source_relevant_node_expression("n");
5655        let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
5656        let review_candidate = purpose_review_candidate_expression("n", scope);
5657        let approved_status = PurposeStatus::Approved.as_str();
5658        let sql = format!(
5659            "
5660            WITH findings AS (
5661                SELECT n.path,
5662                       n.kind,
5663                       n.language,
5664                       '' AS related_path,
5665                       {source_relevant} AS source_relevant
5666                FROM nodes n
5667                JOIN purposes p ON p.node_id = n.id
5668                WHERE n.exists_now = 1
5669                  AND p.status = '{approved_status}'
5670                  AND p.source NOT IN ({reviewed_sources})
5671                  AND {review_candidate}
5672            )
5673            SELECT COUNT(*)
5674            FROM findings
5675            {where_clause}
5676            "
5677        );
5678        let count = self
5679            .connection
5680            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5681        count_to_usize("health_agent_review_required_count", count)
5682    }
5683
5684    /// Load approved navigation-critical purposes that still need agent review.
5685    fn load_agent_review_required_findings_page(
5686        &self,
5687        path_prefix: Option<&str>,
5688        resolution_filter: HealthResolutionFilter<'_>,
5689        scope: HealthScope,
5690        start_index: usize,
5691        limit: usize,
5692    ) -> DbResult<Vec<HealthFinding>> {
5693        if limit == 0 {
5694            return Ok(Vec::new());
5695        }
5696        let (where_clause, mut values) = structural_finding_where_clause(
5697            CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
5698            path_prefix,
5699            resolution_filter,
5700            scope,
5701            1,
5702        );
5703        let source_relevant = source_relevant_node_expression("n");
5704        let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
5705        let review_candidate = purpose_review_candidate_expression("n", scope);
5706        let approved_status = PurposeStatus::Approved.as_str();
5707        let limit_placeholder = values.len() + 1;
5708        let offset_placeholder = values.len() + 2;
5709        values.push(Value::from(usize_to_i64(limit)));
5710        values.push(Value::from(usize_to_i64(start_index)));
5711        let sql = format!(
5712            "
5713            WITH findings AS (
5714                SELECT n.path,
5715                       n.kind,
5716                       n.language,
5717                       '' AS related_path,
5718                       {source_relevant} AS source_relevant
5719                FROM nodes n
5720                JOIN purposes p ON p.node_id = n.id
5721                WHERE n.exists_now = 1
5722                  AND p.status = '{approved_status}'
5723                  AND p.source NOT IN ({reviewed_sources})
5724                  AND {review_candidate}
5725            )
5726            SELECT path
5727            FROM findings
5728            {where_clause}
5729            ORDER BY CASE WHEN kind = 'folder' THEN 0 ELSE 1 END, path
5730            LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5731            "
5732        );
5733        let mut statement = self.connection.prepare(&sql)?;
5734        let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(0))?;
5735        let mut findings = Vec::new();
5736        for row in rows {
5737            findings.push(agent_review_required_finding(row?));
5738        }
5739        Ok(findings)
5740    }
5741
5742    /// Count duplicate-purpose findings directly in `SQLite`.
5743    fn count_duplicate_purpose_findings(
5744        &self,
5745        path_prefix: Option<&str>,
5746        resolution_filter: HealthResolutionFilter<'_>,
5747        scope: HealthScope,
5748    ) -> DbResult<usize> {
5749        let (where_clause, values) = structural_finding_where_clause(
5750            CATEGORY_DUPLICATE_PURPOSE,
5751            path_prefix,
5752            resolution_filter,
5753            scope,
5754            1,
5755        );
5756        let source_relevant = source_relevant_node_expression("n");
5757        let duplicate_scope =
5758            "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5759        let approved_status = PurposeStatus::Approved.as_str();
5760        let sql = format!(
5761            "
5762            WITH duplicate_rows AS (
5763                SELECT n.path,
5764                       n.kind,
5765                       n.language,
5766                       p.purpose,
5767                       {source_relevant} AS source_relevant,
5768                       FIRST_VALUE(n.path) OVER (
5769                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5770                           ORDER BY n.path
5771                       ) AS related_path,
5772                       ROW_NUMBER() OVER (
5773                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5774                           ORDER BY n.path
5775                       ) AS duplicate_rank,
5776                       COUNT(*) OVER (
5777                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5778                       ) AS duplicate_count
5779                FROM nodes n
5780                JOIN purposes p ON p.node_id = n.id
5781                WHERE n.exists_now = 1
5782                  AND p.status = '{approved_status}'
5783                  AND p.purpose IS NOT NULL
5784            ),
5785            findings AS (
5786                SELECT path, kind, language, purpose, related_path, source_relevant
5787                FROM duplicate_rows
5788                WHERE duplicate_count > 1
5789                  AND duplicate_rank > 1
5790            )
5791            SELECT COUNT(*)
5792            FROM findings
5793            {where_clause}
5794            "
5795        );
5796        let count = self
5797            .connection
5798            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5799        count_to_usize("health_duplicate_purpose_count", count)
5800    }
5801
5802    /// Load a bounded duplicate-purpose findings page directly in `SQLite`.
5803    fn load_duplicate_purpose_findings_page(
5804        &self,
5805        path_prefix: Option<&str>,
5806        resolution_filter: HealthResolutionFilter<'_>,
5807        scope: HealthScope,
5808        start_index: usize,
5809        limit: usize,
5810    ) -> DbResult<Vec<HealthFinding>> {
5811        if limit == 0 {
5812            return Ok(Vec::new());
5813        }
5814        let (where_clause, mut values) = structural_finding_where_clause(
5815            CATEGORY_DUPLICATE_PURPOSE,
5816            path_prefix,
5817            resolution_filter,
5818            scope,
5819            1,
5820        );
5821        let source_relevant = source_relevant_node_expression("n");
5822        let duplicate_scope =
5823            "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5824        let approved_status = PurposeStatus::Approved.as_str();
5825        let limit_placeholder = values.len() + 1;
5826        let offset_placeholder = values.len() + 2;
5827        values.push(Value::from(usize_to_i64(limit)));
5828        values.push(Value::from(usize_to_i64(start_index)));
5829        let sql = format!(
5830            "
5831            WITH duplicate_rows AS (
5832                SELECT n.path,
5833                       n.kind,
5834                       n.language,
5835                       p.purpose,
5836                       {source_relevant} AS source_relevant,
5837                       FIRST_VALUE(n.path) OVER (
5838                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5839                           ORDER BY n.path
5840                       ) AS related_path,
5841                       ROW_NUMBER() OVER (
5842                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5843                           ORDER BY n.path
5844                       ) AS duplicate_rank,
5845                       COUNT(*) OVER (
5846                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5847                       ) AS duplicate_count
5848                FROM nodes n
5849                JOIN purposes p ON p.node_id = n.id
5850                WHERE n.exists_now = 1
5851                  AND p.status = '{approved_status}'
5852                  AND p.purpose IS NOT NULL
5853            ),
5854            findings AS (
5855                SELECT path, kind, language, purpose, related_path, source_relevant
5856                FROM duplicate_rows
5857                WHERE duplicate_count > 1
5858                  AND duplicate_rank > 1
5859            )
5860            SELECT path, kind, related_path
5861            FROM findings
5862            {where_clause}
5863            ORDER BY kind, lower(purpose), path
5864            LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5865            "
5866        );
5867        let mut statement = self.connection.prepare(&sql)?;
5868        let rows = statement.query_map(params_from_iter(values), |row| {
5869            Ok((
5870                row.get::<_, String>(0)?,
5871                row.get::<_, String>(1)?,
5872                row.get::<_, String>(2)?,
5873            ))
5874        })?;
5875        let mut findings = Vec::new();
5876        for row in rows {
5877            let (path, kind_value, related_path) = row?;
5878            let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
5879                field: "kind",
5880                value: kind_value,
5881            })?;
5882            findings.push(HealthFinding {
5883                id: finding_id(CATEGORY_DUPLICATE_PURPOSE, &path, Some(&related_path)),
5884                severity: Severity::Warning,
5885                category: CATEGORY_DUPLICATE_PURPOSE.to_string(),
5886                path,
5887                related_path: Some(related_path),
5888                message: format!("Multiple {kind} nodes share the same purpose."),
5889                recommendation: RECOMMENDATION_DUPLICATE_PURPOSE.to_string(),
5890            });
5891        }
5892        Ok(findings)
5893    }
5894
5895    /// Count repeated temporary-folder findings directly in `SQLite`.
5896    fn count_repeated_temp_folder_findings(
5897        &self,
5898        path_prefix: Option<&str>,
5899        resolution_filter: HealthResolutionFilter<'_>,
5900        scope: HealthScope,
5901    ) -> DbResult<usize> {
5902        let mut total = 0_usize;
5903        for bucket in TEMP_FOLDER_BUCKETS {
5904            total += self.count_repeated_temp_folder_bucket_findings(
5905                bucket,
5906                path_prefix,
5907                resolution_filter,
5908                scope,
5909            )?;
5910        }
5911        Ok(total)
5912    }
5913
5914    /// Count one repeated temporary-folder bucket directly in `SQLite`.
5915    fn count_repeated_temp_folder_bucket_findings(
5916        &self,
5917        bucket: &str,
5918        path_prefix: Option<&str>,
5919        resolution_filter: HealthResolutionFilter<'_>,
5920        scope: HealthScope,
5921    ) -> DbResult<usize> {
5922        let exact = bucket.to_string();
5923        let suffix = format!("%/{bucket}");
5924        let (where_clause, mut filter_values) = structural_finding_where_clause(
5925            CATEGORY_REPEATED_TEMPORARY_FOLDER,
5926            path_prefix,
5927            resolution_filter,
5928            scope,
5929            3,
5930        );
5931        let mut values = vec![Value::from(exact), Value::from(suffix)];
5932        values.append(&mut filter_values);
5933        let source_relevant = source_relevant_node_expression("n");
5934        let sql = format!(
5935            "
5936            WITH bucket_rows AS (
5937                SELECT n.path,
5938                       n.kind,
5939                       n.language,
5940                       {source_relevant} AS source_relevant,
5941                       FIRST_VALUE(n.path) OVER (ORDER BY n.path) AS related_path,
5942                       ROW_NUMBER() OVER (ORDER BY path) AS duplicate_rank,
5943                       COUNT(*) OVER () AS duplicate_count
5944                FROM nodes n
5945                WHERE n.exists_now = 1
5946                  AND n.kind = 'folder'
5947                  AND (lower(n.path) = ?1 OR lower(n.path) LIKE ?2)
5948            ),
5949            findings AS (
5950                SELECT path, kind, language, related_path, source_relevant
5951                FROM bucket_rows
5952                WHERE duplicate_count > 1
5953                  AND duplicate_rank > 1
5954            )
5955            SELECT COUNT(*)
5956            FROM findings
5957            {where_clause}
5958            "
5959        );
5960        let count = self
5961            .connection
5962            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5963        count_to_usize("health_repeated_temp_count", count)
5964    }
5965
5966    /// Load a bounded repeated temporary-folder findings page directly in `SQLite`.
5967    fn load_repeated_temp_folder_findings_page(
5968        &self,
5969        path_prefix: Option<&str>,
5970        resolution_filter: HealthResolutionFilter<'_>,
5971        scope: HealthScope,
5972        start_index: usize,
5973        limit: usize,
5974    ) -> DbResult<Vec<HealthFinding>> {
5975        if limit == 0 {
5976            return Ok(Vec::new());
5977        }
5978        let mut total = 0_usize;
5979        let mut findings = Vec::new();
5980        for bucket in TEMP_FOLDER_BUCKETS {
5981            let matching_count = self.count_repeated_temp_folder_bucket_findings(
5982                bucket,
5983                path_prefix,
5984                resolution_filter,
5985                scope,
5986            )?;
5987            if findings.len() < limit && total + matching_count > start_index {
5988                let local_start = start_index.saturating_sub(total);
5989                let local_limit = limit - findings.len();
5990                findings.extend(self.load_repeated_temp_folder_bucket_findings_page(
5991                    bucket,
5992                    path_prefix,
5993                    resolution_filter,
5994                    scope,
5995                    local_start,
5996                    local_limit,
5997                )?);
5998            }
5999            total += matching_count;
6000            if findings.len() >= limit {
6001                break;
6002            }
6003        }
6004        Ok(findings)
6005    }
6006
6007    /// Load one repeated temporary-folder bucket directly in `SQLite`.
6008    fn load_repeated_temp_folder_bucket_findings_page(
6009        &self,
6010        bucket: &str,
6011        path_prefix: Option<&str>,
6012        resolution_filter: HealthResolutionFilter<'_>,
6013        scope: HealthScope,
6014        start_index: usize,
6015        limit: usize,
6016    ) -> DbResult<Vec<HealthFinding>> {
6017        if limit == 0 {
6018            return Ok(Vec::new());
6019        }
6020        let exact = bucket.to_string();
6021        let suffix = format!("%/{bucket}");
6022        let (where_clause, mut filter_values) = structural_finding_where_clause(
6023            CATEGORY_REPEATED_TEMPORARY_FOLDER,
6024            path_prefix,
6025            resolution_filter,
6026            scope,
6027            3,
6028        );
6029        let mut values = vec![Value::from(exact), Value::from(suffix)];
6030        values.append(&mut filter_values);
6031        let source_relevant = source_relevant_node_expression("n");
6032        let limit_placeholder = values.len() + 1;
6033        let offset_placeholder = values.len() + 2;
6034        values.push(Value::from(usize_to_i64(limit)));
6035        values.push(Value::from(usize_to_i64(start_index)));
6036        let sql = format!(
6037            "
6038            WITH bucket_rows AS (
6039                SELECT n.path,
6040                       n.kind,
6041                       n.language,
6042                       {source_relevant} AS source_relevant,
6043                       FIRST_VALUE(n.path) OVER (ORDER BY n.path) AS related_path,
6044                       ROW_NUMBER() OVER (ORDER BY n.path) AS duplicate_rank,
6045                       COUNT(*) OVER () AS duplicate_count
6046                FROM nodes n
6047                WHERE n.exists_now = 1
6048                  AND n.kind = 'folder'
6049                  AND (lower(n.path) = ?1 OR lower(n.path) LIKE ?2)
6050            ),
6051            findings AS (
6052                SELECT path, kind, language, related_path, source_relevant
6053                FROM bucket_rows
6054                WHERE duplicate_count > 1
6055                  AND duplicate_rank > 1
6056            )
6057            SELECT path, related_path
6058            FROM findings
6059            {where_clause}
6060            ORDER BY path
6061            LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
6062            "
6063        );
6064        let mut statement = self.connection.prepare(&sql)?;
6065        let rows = statement.query_map(params_from_iter(values), |row| {
6066            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6067        })?;
6068        let mut findings = Vec::new();
6069        for row in rows {
6070            let (path, related_path) = row?;
6071            findings.push(HealthFinding {
6072                id: finding_id(
6073                    CATEGORY_REPEATED_TEMPORARY_FOLDER,
6074                    &path,
6075                    Some(&related_path),
6076                ),
6077                severity: Severity::Warning,
6078                category: CATEGORY_REPEATED_TEMPORARY_FOLDER.to_string(),
6079                path,
6080                related_path: Some(related_path),
6081                message: format!("Repeated temporary/generated folder name `{bucket}` found."),
6082                recommendation: RECOMMENDATION_REPEATED_TEMPORARY_FOLDER.to_string(),
6083            });
6084        }
6085        Ok(findings)
6086    }
6087
6088    /// Visit duplicate-purpose health findings through grouped SQL candidates.
6089    fn visit_duplicate_purpose_findings<F>(
6090        &self,
6091        resolved_ids: &HashSet<String>,
6092        visitor: &mut F,
6093    ) -> DbResult<bool>
6094    where
6095        F: FnMut(HealthFinding) -> DbResult<bool>,
6096    {
6097        let duplicate_scope =
6098            "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
6099        let approved_status = PurposeStatus::Approved.as_str();
6100        let sql = format!(
6101            "
6102            WITH duplicate_rows AS (
6103                SELECT n.path,
6104                       n.kind,
6105                       p.purpose,
6106                       FIRST_VALUE(n.path) OVER (
6107                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
6108                           ORDER BY n.path
6109                       ) AS related_path,
6110                       ROW_NUMBER() OVER (
6111                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
6112                           ORDER BY n.path
6113                       ) AS duplicate_rank,
6114                       COUNT(*) OVER (
6115                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
6116                       ) AS duplicate_count
6117                FROM nodes n
6118                JOIN purposes p ON p.node_id = n.id
6119                WHERE n.exists_now = 1
6120                  AND p.status = '{approved_status}'
6121                  AND p.purpose IS NOT NULL
6122            )
6123            SELECT path, kind, purpose, related_path
6124            FROM duplicate_rows
6125            WHERE duplicate_count > 1
6126              AND duplicate_rank > 1
6127            ORDER BY kind, lower(purpose), path
6128            "
6129        );
6130        let mut statement = self.connection.prepare(&sql)?;
6131        let rows = statement.query_map([], |row| {
6132            Ok((
6133                row.get::<_, String>(0)?,
6134                row.get::<_, String>(1)?,
6135                row.get::<_, String>(2)?,
6136                row.get::<_, String>(3)?,
6137            ))
6138        })?;
6139        for row in rows {
6140            let (path, kind_value, _purpose, related_path) = row?;
6141            let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
6142                field: "kind",
6143                value: kind_value.clone(),
6144            })?;
6145            let finding = HealthFinding {
6146                id: finding_id(CATEGORY_DUPLICATE_PURPOSE, &path, Some(&related_path)),
6147                severity: Severity::Warning,
6148                category: CATEGORY_DUPLICATE_PURPOSE.to_string(),
6149                path,
6150                related_path: Some(related_path),
6151                message: format!("Multiple {kind} nodes share the same purpose."),
6152                recommendation: RECOMMENDATION_DUPLICATE_PURPOSE.to_string(),
6153            };
6154            if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
6155                return Ok(false);
6156            }
6157        }
6158        Ok(true)
6159    }
6160
6161    /// Visit repeated temporary/generated folder findings.
6162    fn visit_repeated_temp_folder_findings<F>(
6163        &self,
6164        resolved_ids: &HashSet<String>,
6165        visitor: &mut F,
6166    ) -> DbResult<bool>
6167    where
6168        F: FnMut(HealthFinding) -> DbResult<bool>,
6169    {
6170        for bucket in TEMP_FOLDER_BUCKETS {
6171            let exact = bucket.to_string();
6172            let suffix = format!("%/{bucket}");
6173            let mut statement = self.connection.prepare(
6174                "
6175                SELECT path
6176                FROM nodes
6177                WHERE exists_now = 1
6178                  AND kind = 'folder'
6179                  AND (lower(path) = ?1 OR lower(path) LIKE ?2)
6180                ORDER BY path
6181                ",
6182            )?;
6183            let rows =
6184                statement.query_map(params![exact, suffix], |row| row.get::<_, String>(0))?;
6185            let mut first_path = None;
6186            for row in rows {
6187                let path = row?;
6188                let Some(first_path) = first_path.as_ref() else {
6189                    first_path = Some(path);
6190                    continue;
6191                };
6192                let finding = HealthFinding {
6193                    id: finding_id(
6194                        CATEGORY_REPEATED_TEMPORARY_FOLDER,
6195                        &path,
6196                        Some(first_path.as_str()),
6197                    ),
6198                    severity: Severity::Warning,
6199                    category: CATEGORY_REPEATED_TEMPORARY_FOLDER.to_string(),
6200                    path,
6201                    related_path: Some(first_path.clone()),
6202                    message: format!("Repeated temporary/generated folder name `{bucket}` found."),
6203                    recommendation: RECOMMENDATION_REPEATED_TEMPORARY_FOLDER.to_string(),
6204                };
6205                if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
6206                    return Ok(false);
6207                }
6208            }
6209        }
6210        Ok(true)
6211    }
6212
6213    /// Compute an overview from the current index.
6214    ///
6215    /// # Errors
6216    ///
6217    /// Returns an error if the aggregate query fails or a count is invalid.
6218    pub fn overview(&self) -> DbResult<Overview> {
6219        let missing_status = PurposeStatus::Missing.as_str();
6220        let stale_status = PurposeStatus::Stale.as_str();
6221        let approved_status = PurposeStatus::Approved.as_str();
6222        let suggested_status = PurposeStatus::Suggested.as_str();
6223        let sql = format!(
6224            "
6225            SELECT
6226                COALESCE(SUM(CASE WHEN n.kind = 'file' THEN 1 ELSE 0 END), 0),
6227                COALESCE(SUM(CASE WHEN n.kind = 'folder' THEN 1 ELSE 0 END), 0),
6228                COALESCE(SUM(CASE WHEN p.status = '{missing_status}' THEN 1 ELSE 0 END), 0),
6229                COALESCE(SUM(CASE WHEN p.status = '{stale_status}' THEN 1 ELSE 0 END), 0),
6230                COALESCE(SUM(CASE WHEN p.status = '{approved_status}' THEN 1 ELSE 0 END), 0),
6231                COALESCE(SUM(CASE WHEN p.status = '{suggested_status}' THEN 1 ELSE 0 END), 0)
6232            FROM nodes n
6233            JOIN purposes p ON p.node_id = n.id
6234            WHERE n.exists_now = 1
6235            "
6236        );
6237        let counts = self.connection.query_row(&sql, [], |row| {
6238            Ok((
6239                row.get::<_, i64>(0)?,
6240                row.get::<_, i64>(1)?,
6241                row.get::<_, i64>(2)?,
6242                row.get::<_, i64>(3)?,
6243                row.get::<_, i64>(4)?,
6244                row.get::<_, i64>(5)?,
6245            ))
6246        })?;
6247        Ok(Overview {
6248            files: count_to_usize("files", counts.0)?,
6249            folders: count_to_usize("folders", counts.1)?,
6250            missing_purposes: count_to_usize("missing_purposes", counts.2)?,
6251            stale_purposes: count_to_usize("stale_purposes", counts.3)?,
6252            approved_purposes: count_to_usize("approved_purposes", counts.4)?,
6253            suggested_purposes: count_to_usize("suggested_purposes", counts.5)?,
6254        })
6255    }
6256
6257    /// Record a usage event.
6258    ///
6259    /// # Errors
6260    ///
6261    /// Returns an error if persistence fails.
6262    pub fn record_usage(&self, event: &UsageEvent) -> DbResult<()> {
6263        telemetry::validate_event(event, TelemetryRetentionPolicy::default())?;
6264        self.validated_project_root_identity
6265            .as_ref()
6266            .ok_or(DbError::ProjectRootIdentityMissing)?;
6267        let instance_id = self.library_usage_instance(&event.session_id, false)?;
6268        match self.record_usage_for_instance(
6269            instance_id,
6270            UsageInstanceOwner::LibraryHandle,
6271            event,
6272            false,
6273        ) {
6274            Err(DbError::TelemetryInstanceInactive) => {
6275                let replacement = self.library_usage_instance(&event.session_id, true)?;
6276                self.record_usage_for_instance(
6277                    replacement,
6278                    UsageInstanceOwner::LibraryHandle,
6279                    event,
6280                    false,
6281                )
6282            }
6283            Err(DbError::TelemetryBaselineCapacity) => {
6284                let replacement = telemetry::generate_usage_instance_id()?;
6285                self.seal_usage_instance(instance_id)?;
6286                self.library_usage_instances
6287                    .borrow_mut()
6288                    .insert(event.session_id.clone(), Some(replacement));
6289                self.record_usage_for_instance(
6290                    replacement,
6291                    UsageInstanceOwner::LibraryHandle,
6292                    event,
6293                    false,
6294                )
6295            }
6296            result => result,
6297        }
6298    }
6299
6300    /// Return or rotate one bounded direct-library runtime instance per caller label.
6301    fn library_usage_instance(
6302        &self,
6303        caller_label: &str,
6304        rotate: bool,
6305    ) -> DbResult<UsageInstanceId> {
6306        let mut instances = self.library_usage_instances.borrow_mut();
6307        if !rotate && let Some(instance) = instances.get(caller_label) {
6308            return instance.ok_or(DbError::TelemetryIdentityUnavailable);
6309        }
6310        if !instances.contains_key(caller_label)
6311            && instances.len() >= TelemetryRetentionPolicy::default().max_retained_labels
6312        {
6313            return Err(DbError::TelemetryInstanceCapacity);
6314        }
6315        let instance = telemetry::generate_usage_instance_id().ok();
6316        instances.insert(caller_label.to_string(), instance);
6317        instance.ok_or(DbError::TelemetryIdentityUnavailable)
6318    }
6319
6320    /// Record a usage event for one bounded runtime or invocation instance.
6321    ///
6322    /// The internal instance is deliberately separate from the optional
6323    /// caller-visible label carried by [`UsageEvent::session_id`].
6324    ///
6325    /// # Errors
6326    ///
6327    /// Returns an error when the selected binding changes, the instance is
6328    /// inactive, a retention bound rejects the event, or `SQLite` cannot commit
6329    /// the complete telemetry transaction.
6330    pub fn record_usage_for_instance(
6331        &self,
6332        instance_id: UsageInstanceId,
6333        owner: UsageInstanceOwner,
6334        event: &UsageEvent,
6335        seal_after_record: bool,
6336    ) -> DbResult<()> {
6337        self.record_usage_for_instance_origin(instance_id, owner, None, event, seal_after_record)
6338    }
6339
6340    /// Record one centrally routed event for a captured worktree registration.
6341    ///
6342    /// # Errors
6343    ///
6344    /// Returns an error when the selected control binding changes, the
6345    /// registration or runtime origin conflicts, a retention bound rejects the
6346    /// event, or `SQLite` cannot commit the complete telemetry transaction.
6347    pub fn record_usage_for_worktree_instance(
6348        &self,
6349        instance_id: UsageInstanceId,
6350        owner: UsageInstanceOwner,
6351        registration_id: i64,
6352        event: &UsageEvent,
6353        seal_after_record: bool,
6354    ) -> DbResult<()> {
6355        self.record_usage_for_instance_origin(
6356            instance_id,
6357            owner,
6358            Some(registration_id),
6359            event,
6360            seal_after_record,
6361        )
6362    }
6363
6364    /// Record one event with an optional captured worktree origin.
6365    fn record_usage_for_instance_origin(
6366        &self,
6367        instance_id: UsageInstanceId,
6368        owner: UsageInstanceOwner,
6369        registration_id: Option<i64>,
6370        event: &UsageEvent,
6371        seal_after_record: bool,
6372    ) -> DbResult<()> {
6373        let policy = TelemetryRetentionPolicy::default();
6374        let project_instance_id = self
6375            .validated_project_instance_id
6376            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
6377        let project_root_identity = self
6378            .validated_project_root_identity
6379            .as_ref()
6380            .ok_or(DbError::ProjectRootIdentityMissing)?;
6381        self.with_telemetry_connection(|connection| {
6382            with_validated_native_write_transaction(
6383                connection,
6384                Some(project_root_identity),
6385                Some(project_instance_id),
6386                |transaction| {
6387                    telemetry::record_usage_for_project(
6388                        transaction,
6389                        project_instance_id,
6390                        instance_id,
6391                        owner,
6392                        registration_id,
6393                        event,
6394                        policy,
6395                        seal_after_record,
6396                    )
6397                },
6398            )?;
6399            // The event is already committed. Passive maintenance remains
6400            // observable through retention state and must never make callers
6401            // retry a successfully persisted event.
6402            drop(telemetry::maintain_after_commit_for_native_project(
6403                connection,
6404                Some(project_root_identity),
6405                project_instance_id,
6406                policy,
6407            ));
6408            Ok(())
6409        })
6410    }
6411
6412    /// Seal one cleanly completed runtime or invocation instance.
6413    ///
6414    /// # Errors
6415    ///
6416    /// Returns an error when the selected binding changed, the instance is
6417    /// already inactive, or `SQLite` cannot commit the state transition.
6418    pub fn seal_usage_instance(&self, instance_id: UsageInstanceId) -> DbResult<()> {
6419        let project_root_identity = self
6420            .validated_project_root_identity
6421            .as_ref()
6422            .ok_or(DbError::ProjectRootIdentityMissing)?;
6423        self.with_telemetry_connection(|connection| {
6424            with_validated_native_write_transaction(
6425                connection,
6426                Some(project_root_identity),
6427                self.validated_project_instance_id,
6428                |transaction| telemetry::seal_usage_instance(transaction, instance_id),
6429            )
6430        })
6431    }
6432
6433    /// Return content-free bounded telemetry retention and maintenance state.
6434    ///
6435    /// # Errors
6436    ///
6437    /// Returns an error when persisted state is missing, corrupt, or cannot be read.
6438    pub fn telemetry_retention_state(&self) -> DbResult<TelemetryRetentionState> {
6439        telemetry::retention_state(&self.connection)
6440    }
6441
6442    /// Load usage events.
6443    ///
6444    /// # Errors
6445    ///
6446    /// Returns an error if loading fails.
6447    pub fn usage_events(&self, session_id: Option<&str>) -> DbResult<Vec<UsageEvent>> {
6448        telemetry::usage_events(&self.connection, session_id)
6449    }
6450
6451    /// Build a token overview.
6452    ///
6453    /// # Errors
6454    ///
6455    /// Returns an error if loading events fails.
6456    pub fn token_overview(&self, session_id: Option<&str>) -> DbResult<TokenOverview> {
6457        telemetry::token_overview(&self.connection, session_id)
6458    }
6459
6460    /// Build the control atlas's combined native-main and synchronized-worktree overview.
6461    ///
6462    /// # Errors
6463    ///
6464    /// Returns an error when the selected binding or aggregate state is invalid,
6465    /// arithmetic overflows, or `SQLite` cannot complete the bounded read.
6466    pub fn repository_token_overview(&self) -> DbResult<TokenOverview> {
6467        telemetry::repository_token_overview(&self.connection)
6468    }
6469
6470    /// Build exact retained routed plus synchronized totals for one active alias.
6471    ///
6472    /// # Errors
6473    ///
6474    /// Returns an error when the alias is absent, aggregate state is invalid,
6475    /// arithmetic overflows, or `SQLite` cannot complete the bounded read.
6476    pub fn registered_worktree_token_overview(
6477        &self,
6478        alias: &WorktreeAlias,
6479    ) -> DbResult<TokenOverview> {
6480        let registration = self.worktree_registration(alias)?;
6481        telemetry::worktree_token_overview(&self.connection, registration.registration_id)
6482    }
6483
6484    /// Build token trend aggregates grouped by day, week, month, or year.
6485    ///
6486    /// # Errors
6487    ///
6488    /// Returns an error if the window is unsupported or loading events fails.
6489    pub fn token_trends(
6490        &self,
6491        session_id: Option<&str>,
6492        window: TokenTrendWindow,
6493    ) -> DbResult<TokenTrendReport> {
6494        telemetry::token_trends(&self.connection, session_id, window)
6495    }
6496
6497    /// Build combined native-main and synchronized-worktree token trends.
6498    ///
6499    /// # Errors
6500    ///
6501    /// Returns an error for an unsupported window, invalid aggregate state,
6502    /// arithmetic overflow, or a bounded `SQLite` read failure.
6503    pub fn repository_token_trends(&self, window: TokenTrendWindow) -> DbResult<TokenTrendReport> {
6504        telemetry::repository_token_trends(&self.connection, window)
6505    }
6506
6507    /// Build exact retained routed plus synchronized trends for one active alias.
6508    ///
6509    /// # Errors
6510    ///
6511    /// Returns an error when the alias is absent, the window or aggregate state
6512    /// is invalid, arithmetic overflows, or `SQLite` cannot complete the read.
6513    pub fn registered_worktree_token_trends(
6514        &self,
6515        alias: &WorktreeAlias,
6516        window: TokenTrendWindow,
6517    ) -> DbResult<TokenTrendReport> {
6518        let registration = self.worktree_registration(alias)?;
6519        telemetry::worktree_token_trends(&self.connection, registration.registration_id, window)
6520    }
6521
6522    /// Mark a deterministic health finding as agent-resolved.
6523    ///
6524    /// # Errors
6525    ///
6526    /// Returns an error if the finding is not active or persistence fails.
6527    pub fn resolve_health_finding(&self, resolution: &HealthResolution) -> DbResult<()> {
6528        self.with_validated_write(|connection| {
6529            if !self.active_health_finding_matches(resolution)? {
6530                return Err(DbError::HealthFindingNotActive {
6531                    finding_id: resolution.finding_id.clone(),
6532                    category: resolution.category.clone(),
6533                    path: resolution.path.clone(),
6534                });
6535            }
6536            connection.execute(
6537                "
6538            INSERT INTO health_resolutions(
6539                finding_id,
6540                category,
6541                path,
6542                related_path,
6543                rationale,
6544                resolved_by,
6545                resolved_at
6546            )
6547            VALUES(?1, ?2, ?3, ?4, ?5, 'agent', CURRENT_TIMESTAMP)
6548            ON CONFLICT(finding_id) DO UPDATE SET
6549                category = excluded.category,
6550                path = excluded.path,
6551                related_path = excluded.related_path,
6552                rationale = excluded.rationale,
6553                resolved_by = 'agent',
6554                resolved_at = CURRENT_TIMESTAMP
6555            ",
6556                params![
6557                    resolution.finding_id,
6558                    resolution.category,
6559                    resolution.path,
6560                    resolution.related_path,
6561                    resolution.rationale,
6562                ],
6563            )?;
6564            Ok(())
6565        })
6566    }
6567
6568    /// Return whether the visible SQL health surface contains the exact finding.
6569    fn active_health_finding_matches(&self, resolution: &HealthResolution) -> DbResult<bool> {
6570        const PAGE_SIZE: usize = 256;
6571        let mut start_index = 0_usize;
6572        loop {
6573            let page = self.unresolved_health_findings_page_current(&HealthQuery {
6574                start_index,
6575                limit: PAGE_SIZE,
6576                category: Some(resolution.category.clone()),
6577                severity: Some(Severity::Warning),
6578                path_prefix: Some(resolution.path.clone()),
6579                summary_only: false,
6580                scope: HealthScope::all(),
6581            })?;
6582            if page.findings.iter().any(|finding| {
6583                finding.id == resolution.finding_id
6584                    && finding.category == resolution.category
6585                    && finding.path == resolution.path
6586                    && finding.related_path == resolution.related_path
6587            }) {
6588                return Ok(true);
6589            }
6590            if page.returned == 0 || start_index + page.returned >= page.total {
6591                return Ok(false);
6592            }
6593            start_index += page.returned;
6594        }
6595    }
6596
6597    /// Load resolved health finding ids.
6598    ///
6599    /// # Errors
6600    ///
6601    /// Returns an error if reading fails.
6602    pub fn resolved_health_ids(&self) -> DbResult<Vec<String>> {
6603        let mut statement = self
6604            .connection
6605            .prepare("SELECT finding_id FROM health_resolutions ORDER BY finding_id")?;
6606        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
6607        let mut ids = Vec::new();
6608        for row in rows {
6609            ids.push(row?);
6610        }
6611        Ok(ids)
6612    }
6613}
6614
6615/// Acquire the publication writer without waiting after exact input validation.
6616fn begin_immediate_publication(connection: &Connection) -> DbResult<()> {
6617    connection.busy_timeout(SQLITE_PUBLICATION_ACQUIRE_TIMEOUT)?;
6618    let begin_result = connection.execute_batch("BEGIN IMMEDIATE");
6619    let restore_result = connection.busy_timeout(SQLITE_BUSY_TIMEOUT);
6620    match (begin_result, restore_result) {
6621        (Ok(()), Ok(())) => Ok(()),
6622        (Err(error), Ok(())) => Err(error.into()),
6623        (Ok(()), Err(error)) => Err(schema::rollback_after_error(connection, error.into())),
6624        (Err(operation), Err(restore)) => Err(DbError::PublicationAcquirePolicyRestore {
6625            operation: Box::new(operation),
6626            restore: Box::new(restore),
6627        }),
6628    }
6629}
6630
6631/// Read the recorded project root without creating or migrating a database.
6632///
6633/// # Errors
6634///
6635/// Returns an error if `SQLite` cannot open or query the database read-only.
6636pub fn read_project_root_read_only(path: &Path) -> DbResult<Option<String>> {
6637    schema::read_project_root(path)
6638}
6639
6640/// Read an untrusted predecessor root candidate without creating, migrating,
6641/// or repairing a database.
6642///
6643/// This is recovery evidence for selecting a filesystem candidate only. It is
6644/// never an authoritative project identity and callers must canonicalize the
6645/// candidate through the live filesystem before opening a project database.
6646/// Current databases intentionally return no candidate because their typed
6647/// native identity is the only authority. A predecessor candidate is returned
6648/// only when the schema-owned policy can establish that its legacy projection
6649/// is usable; otherwise the typed native-identity error is returned.
6650///
6651/// # Errors
6652///
6653/// Returns an error when the database cannot be inspected read-only or its
6654/// predecessor schema is incompatible or malformed.
6655pub fn read_legacy_project_root_candidate_read_only(path: &Path) -> DbResult<Option<String>> {
6656    let (preflight, _) = schema::inspect_compatibility(path, None)?;
6657    if preflight.state != SchemaState::UpgradeRequired {
6658        return Ok(None);
6659    }
6660    if schema::legacy_root_requires_native_authority(preflight.project_root.as_deref()) {
6661        return Err(DbError::ProjectRootIdentityMissing);
6662    }
6663    Ok(preflight.project_root)
6664}
6665
6666/// Validate one existing project binding without creating, migrating, or repairing it.
6667///
6668/// A fresh or absent database is admitted for a later initializer. Existing current
6669/// databases use their native identity, while supported predecessors use the same
6670/// read-only native-equivalence proof as the storage writer. No SQLite write
6671/// connection or WAL state is opened by this check.
6672///
6673/// # Errors
6674///
6675/// Returns an error when the database is malformed, incompatible, missing an
6676/// unambiguous predecessor binding, or belongs to another project root.
6677pub fn preflight_project_binding_read_only(path: &Path, root: &Path) -> DbResult<()> {
6678    let expected_identity = CanonicalProjectRoot::from_path(root)?;
6679    schema::preflight_for_project(path, &expected_identity).map(|_| ())
6680}
6681
6682/// Read the authoritative native project-root identity without mutation.
6683///
6684/// # Errors
6685///
6686/// Returns an error if the database cannot be opened read-only or the identity
6687/// row contains an invalid versioned codec payload.
6688pub fn read_project_root_identity_read_only(path: &Path) -> DbResult<Option<CanonicalProjectRoot>> {
6689    let location = sqlite_profile::inspect_database_location(path)?;
6690    if !location.database_exists {
6691        return Ok(None);
6692    }
6693    let (preflight, _) = schema::inspect_compatibility(path, None)?;
6694    if preflight.state != schema::SchemaState::Current {
6695        return Ok(None);
6696    }
6697    let connection = schema::open_current_read_only(path, None)?.0;
6698    let identity = project_identity::load_project_root_identity(&connection)?;
6699    connection.execute_batch("ROLLBACK")?;
6700    Ok(identity)
6701}
6702
6703/// Verify the current project database schema, identity, and full integrity read-only.
6704///
6705/// # Errors
6706///
6707/// Returns an error when the database is missing, incompatible, corrupt, or belongs to another
6708/// project root.
6709pub fn verify_project_database(path: &Path, project_root: &Path) -> DbResult<()> {
6710    let identity = CanonicalProjectRoot::from_path(project_root)?;
6711    schema::verify_current_integrity_for_project(path, &identity)
6712}
6713
6714/// Normalize a filesystem path stored in `SQLite` metadata.
6715fn normalize_metadata_path(path: &Path) -> String {
6716    normalize_native_path_display(path)
6717}
6718
6719/// Upsert one metadata value through the caller's active connection/transaction.
6720fn set_metadata(connection: &Connection, key: &str, value: &str) -> DbResult<()> {
6721    connection.execute(
6722        "
6723        INSERT INTO metadata(key, value)
6724        VALUES(?1, ?2)
6725        ON CONFLICT(key) DO UPDATE SET value = excluded.value
6726        ",
6727        [key, value],
6728    )?;
6729    Ok(())
6730}
6731
6732/// Load the accepted authored-purpose revision without scanning purpose rows.
6733fn load_authored_purpose_revision(connection: &Connection) -> DbResult<u64> {
6734    let value = connection
6735        .prepare_cached("SELECT value FROM metadata WHERE key = ?1")?
6736        .query_row([AUTHORED_PURPOSE_REVISION_KEY], |row| {
6737            row.get::<_, String>(0)
6738        })
6739        .optional()?;
6740    value.map_or(Ok(0), |value| {
6741        value
6742            .parse::<u64>()
6743            .map_err(|source| DbError::InvalidInteger {
6744                field: AUTHORED_PURPOSE_REVISION_KEY,
6745                value,
6746                source,
6747            })
6748    })
6749}
6750
6751/// Advance the accepted authored-purpose revision in the caller's transaction.
6752fn advance_authored_purpose_revision(connection: &Connection) -> DbResult<u64> {
6753    let current = load_authored_purpose_revision(connection)?;
6754    let revision = current
6755        .checked_add(1)
6756        .ok_or(DbError::IntegerMetadataOverflow {
6757            field: AUTHORED_PURPOSE_REVISION_KEY,
6758            value: current,
6759        })?;
6760    set_metadata(
6761        connection,
6762        AUTHORED_PURPOSE_REVISION_KEY,
6763        &revision.to_string(),
6764    )?;
6765    Ok(revision)
6766}
6767
6768/// Load the authoritative and projection revisions without scanning text rows.
6769fn load_file_text_fts_revisions(connection: &Connection) -> DbResult<Option<(u64, u64)>> {
6770    let (source, projection) = connection.query_row(
6771        "
6772        SELECT
6773            (SELECT value FROM metadata WHERE key = ?1),
6774            (SELECT value FROM metadata WHERE key = ?2)
6775        ",
6776        params![
6777            FILE_TEXT_FTS_SOURCE_REVISION_KEY,
6778            FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
6779        ],
6780        |row| {
6781            Ok((
6782                row.get::<_, Option<String>>(0)?,
6783                row.get::<_, Option<String>>(1)?,
6784            ))
6785        },
6786    )?;
6787    if source.is_none() && projection.is_none() {
6788        return Ok(None);
6789    }
6790    let (Some(source), Some(projection)) = (source, projection) else {
6791        return Err(DbError::FileTextFtsStateInvalid {
6792            reason: "only one FTS revision is present",
6793        });
6794    };
6795    let source_revision =
6796        source
6797            .parse::<u64>()
6798            .map_err(|source_error| DbError::InvalidInteger {
6799                field: FILE_TEXT_FTS_SOURCE_REVISION_KEY,
6800                value: source,
6801                source: source_error,
6802            })?;
6803    let projection_revision =
6804        projection
6805            .parse::<u64>()
6806            .map_err(|source_error| DbError::InvalidInteger {
6807                field: FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
6808                value: projection,
6809                source: source_error,
6810            })?;
6811    Ok(Some((source_revision, projection_revision)))
6812}
6813
6814/// Mark an authoritative text mutation before changing its FTS projection.
6815fn begin_file_text_fts_update(connection: &Connection) -> DbResult<u64> {
6816    let (source, projection) =
6817        load_file_text_fts_revisions(connection)?.ok_or(DbError::FileTextFtsStateInvalid {
6818            reason: "FTS revisions are missing",
6819        })?;
6820    if source != projection {
6821        return Err(DbError::FileTextFtsStateInvalid {
6822            reason: "an earlier FTS revision is incomplete",
6823        });
6824    }
6825    let revision = source
6826        .checked_add(1)
6827        .ok_or(DbError::FileTextFtsStateInvalid {
6828            reason: "FTS revision overflowed",
6829        })?;
6830    set_metadata(
6831        connection,
6832        FILE_TEXT_FTS_SOURCE_REVISION_KEY,
6833        &revision.to_string(),
6834    )?;
6835    Ok(revision)
6836}
6837
6838/// Publish the matching projection revision after every FTS mutation succeeds.
6839fn complete_file_text_fts_update(connection: &Connection, revision: u64) -> DbResult<()> {
6840    let (source, projection) =
6841        load_file_text_fts_revisions(connection)?.ok_or(DbError::FileTextFtsStateInvalid {
6842            reason: "FTS revisions disappeared during an update",
6843        })?;
6844    if source != revision || projection.checked_add(1) != Some(revision) {
6845        return Err(DbError::FileTextFtsStateInvalid {
6846            reason: "FTS revisions changed during an update",
6847        });
6848    }
6849    set_metadata(
6850        connection,
6851        FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
6852        &revision.to_string(),
6853    )
6854}
6855
6856/// Persist one usage event in the immutable released schema-8 fixture shape.
6857#[cfg(test)]
6858fn record_released_schema_eight_usage_event(
6859    connection: &Connection,
6860    event: &UsageEvent,
6861) -> DbResult<()> {
6862    connection.execute(
6863        "
6864        INSERT INTO usage_events(
6865            session_id,
6866            command,
6867            path,
6868            query,
6869            estimated_tokens_without_projectatlas,
6870            estimated_tokens_with_projectatlas,
6871            estimated_tokens_saved,
6872            token_savings_bucket,
6873            provider,
6874            model,
6875            tokenizer_backend,
6876            accuracy,
6877            baseline_kind,
6878            confidence,
6879            calculation_trace,
6880            accounting_layer,
6881            estimate_method,
6882            denominator_kind,
6883            baseline_identity,
6884            baseline_fingerprint,
6885            dedupe_scope,
6886            created_at
6887        )
6888        VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, CURRENT_TIMESTAMP)
6889        ",
6890        params![
6891            event.session_id,
6892            event.command,
6893            event.path,
6894            event.query,
6895            option_usize_to_i64(
6896                "estimated_tokens_without_projectatlas",
6897                event.estimated_tokens_without_projectatlas,
6898            )?,
6899            option_usize_to_i64(
6900                "estimated_tokens_with_projectatlas",
6901                event.estimated_tokens_with_projectatlas,
6902            )?,
6903            event.estimated_tokens_saved,
6904            event.token_savings_bucket,
6905            event.provider,
6906            event.model,
6907            event.tokenizer_backend,
6908            event.accuracy,
6909            event.baseline_kind,
6910            event.confidence,
6911            event.calculation_trace,
6912            event.accounting_layer,
6913            event.estimate_method,
6914            event.denominator_kind,
6915            event.baseline_identity,
6916            event.baseline_fingerprint,
6917            event.dedupe_scope
6918        ],
6919    )?;
6920    Ok(())
6921}
6922
6923/// Load durable publication metadata from one connection snapshot.
6924fn load_index_publication(connection: &Connection) -> DbResult<Option<IndexPublication>> {
6925    let row = connection
6926        .query_row(
6927            "
6928            SELECT state.value, fingerprint.value, generation.value
6929            FROM metadata AS state
6930            LEFT JOIN metadata AS fingerprint ON fingerprint.key = ?2
6931            LEFT JOIN metadata AS generation ON generation.key = ?3
6932            WHERE state.key = ?1
6933            ",
6934            params![
6935                INDEX_PUBLICATION_STATE_KEY,
6936                INDEX_PUBLICATION_FINGERPRINT_KEY,
6937                INDEX_PUBLICATION_GENERATION_KEY,
6938            ],
6939            |row| {
6940                Ok((
6941                    row.get::<_, String>(0)?,
6942                    row.get::<_, Option<String>>(1)?,
6943                    row.get::<_, Option<String>>(2)?,
6944                ))
6945            },
6946        )
6947        .optional()?;
6948    let Some((state, contract_fingerprint, generation)) = row else {
6949        return Ok(None);
6950    };
6951    let generation = generation.map_or(Ok(IndexGeneration::ZERO), |value| {
6952        value
6953            .parse::<u64>()
6954            .map(IndexGeneration::new)
6955            .map_err(|source| DbError::InvalidInteger {
6956                field: INDEX_PUBLICATION_GENERATION_KEY,
6957                value,
6958                source,
6959            })
6960    })?;
6961    Ok(Some(IndexPublication {
6962        state: IndexPublicationState::from_db(state)?,
6963        contract_fingerprint,
6964        generation,
6965    }))
6966}
6967
6968/// Mark every indexed node absent before a complete scan replacement.
6969fn mark_all_scan_nodes_absent(connection: &Connection) -> DbResult<()> {
6970    connection.execute("UPDATE nodes SET exists_now = 0", [])?;
6971    Ok(())
6972}
6973
6974/// Delete derived rows whose owning scan node remained absent.
6975fn delete_absent_scan_projections(connection: &Connection) -> DbResult<()> {
6976    content_classification::delete_absent_file_content_classifications(connection)?;
6977    let fts_revision = begin_file_text_fts_update(connection)?;
6978    connection.execute(
6979        "DELETE FROM symbol_relations WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6980        [],
6981    )?;
6982    connection.execute(
6983        "DELETE FROM symbols WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6984        [],
6985    )?;
6986    connection.execute(
6987        "DELETE FROM source_parse_metadata WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6988        [],
6989    )?;
6990    connection.execute(
6991        "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
6992         SELECT 'delete', rowid, content FROM file_texts \
6993         WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6994        [],
6995    )?;
6996    connection.execute(
6997        "DELETE FROM file_texts WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6998        [],
6999    )?;
7000    complete_file_text_fts_update(connection, fts_revision)?;
7001    Ok(())
7002}
7003
7004/// Upsert scanned nodes through transaction-owned prepared statements.
7005fn upsert_nodes(connection: &Connection, nodes: &[Node]) -> DbResult<()> {
7006    content_classification::delete_non_file_content_classifications(connection, nodes)?;
7007    let mut select_existing = connection.prepare_cached(
7008        "
7009        SELECT content_hash
7010        FROM nodes
7011        WHERE path = ?1
7012        ",
7013    )?;
7014    let mut upsert_node = connection.prepare_cached(
7015        "
7016        INSERT INTO nodes(path, kind, parent_path, extension, language, size_bytes, mtime_ns, content_hash, exists_now)
7017        VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)
7018        ON CONFLICT(path) DO UPDATE SET
7019            kind = excluded.kind,
7020            parent_path = excluded.parent_path,
7021            extension = excluded.extension,
7022            language = excluded.language,
7023            size_bytes = excluded.size_bytes,
7024            mtime_ns = excluded.mtime_ns,
7025            content_hash = excluded.content_hash,
7026            exists_now = 1,
7027            last_seen_at = CURRENT_TIMESTAMP,
7028            last_indexed_at = CURRENT_TIMESTAMP
7029        ",
7030    )?;
7031    let mut ensure_file_classification = connection.prepare_cached(
7032        "INSERT INTO file_content_classifications(path, classification)
7033         SELECT ?1, 'opaque'
7034          WHERE ?2 = 'file'
7035         ON CONFLICT(path) DO NOTHING",
7036    )?;
7037    let mut select_node_id = connection.prepare_cached("SELECT id FROM nodes WHERE path = ?1")?;
7038    let mut ensure_purpose = connection.prepare_cached(
7039        "
7040        INSERT INTO purposes(node_id, purpose, source, status)
7041        VALUES(?1, NULL, 'missing', 'missing')
7042        ON CONFLICT(node_id) DO NOTHING
7043        ",
7044    )?;
7045    let mut upsert_summary = connection.prepare_cached(
7046        "
7047        INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
7048        VALUES(?1, 'node', '', ?2, CURRENT_TIMESTAMP)
7049        ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
7050            summary = CASE WHEN ?3 THEN excluded.summary ELSE summaries.summary END,
7051            updated_at = CURRENT_TIMESTAMP
7052        ",
7053    )?;
7054    for node in nodes {
7055        let size_bytes = node
7056            .size_bytes
7057            .map(|value| {
7058                i64::try_from(value).map_err(|_source| DbError::GraphCountOverflow {
7059                    field: "nodes.size_bytes",
7060                    value,
7061                })
7062            })
7063            .transpose()?;
7064        let existing = select_existing
7065            .query_row([&node.path], |row| row.get::<_, Option<String>>(0))
7066            .optional()?;
7067        let content_changed = existing.as_ref().is_some_and(|old_hash| {
7068            node.kind == NodeKind::File
7069                && old_hash.is_some()
7070                && node.content_hash.is_some()
7071                && old_hash != &node.content_hash
7072        });
7073        upsert_node.execute(params![
7074            node.path,
7075            node.kind.to_string(),
7076            node.parent_path,
7077            node.extension,
7078            node.language,
7079            size_bytes,
7080            node.mtime_ns,
7081            node.content_hash
7082        ])?;
7083        ensure_file_classification.execute(params![node.path, node.kind.to_string()])?;
7084        let node_id = select_node_id.query_row([&node.path], |row| row.get::<_, i64>(0))?;
7085        ensure_purpose.execute([node_id])?;
7086        upsert_summary.execute(params![
7087            node_id,
7088            generate_node_summary(node),
7089            content_changed
7090        ])?;
7091    }
7092    Ok(())
7093}
7094
7095/// Read one persisted indexed text row.
7096fn file_text_from_row(row: &rusqlite::Row<'_>) -> DbResult<IndexedFileText> {
7097    let byte_count = count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?;
7098    let line_count = count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?;
7099    let text = IndexedFileText {
7100        path: row.get(0)?,
7101        content_hash: row.get(1)?,
7102        byte_count,
7103        line_count,
7104        content: row.get(4)?,
7105    };
7106    validate_indexed_file_text(&text)?;
7107    Ok(text)
7108}
7109
7110/// Verify that persisted byte/line accounting matches authoritative content.
7111fn validate_indexed_file_text(text: &IndexedFileText) -> DbResult<()> {
7112    let actual_bytes = text.content.len();
7113    if text.byte_count != actual_bytes {
7114        return Err(DbError::FileTextMetadataMismatch {
7115            path: text.path.clone(),
7116            field: "byte_count",
7117            recorded: text.byte_count,
7118            actual: actual_bytes,
7119        });
7120    }
7121    let actual_lines = text.content.lines().count();
7122    if text.line_count != actual_lines {
7123        return Err(DbError::FileTextMetadataMismatch {
7124            path: text.path.clone(),
7125            field: "line_count",
7126            recorded: text.line_count,
7127            actual: actual_lines,
7128        });
7129    }
7130    Ok(())
7131}
7132
7133/// Virtual-machine operations between bounded database-read progress checks.
7134const SQLITE_READ_PROGRESS_OPS: i32 = 1_000;
7135
7136/// Deterministic observation of production `SQLite` progress boundaries for downstream tests.
7137#[cfg(feature = "sqlite-progress-test-observer")]
7138pub mod sqlite_progress_test_observer {
7139    use projectatlas_core::IndexWorkStage;
7140    use std::cell::RefCell;
7141
7142    /// One observable event emitted by the production `SQLite` read-progress boundary.
7143    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
7144    pub enum SqliteReadProgressEvent {
7145        /// A repository relation-family query entered after its live-control precheck.
7146        RepositoryRelationFamilyQueryEntered,
7147        /// A repository relation-family query returned from `SQLite`.
7148        RepositoryRelationFamilyQueryExited,
7149        /// `SQLite` invoked the production progress callback.
7150        CallbackEntered {
7151            /// Typed work stage owned by the guarded query.
7152            stage: IndexWorkStage,
7153        },
7154        /// The production progress callback evaluated its control.
7155        CallbackEvaluated {
7156            /// Typed work stage owned by the guarded query.
7157            stage: IndexWorkStage,
7158            /// Whether the callback requested `SQLite` interruption.
7159            interrupted: bool,
7160        },
7161    }
7162
7163    /// Thread-local callback used only while a downstream test owns observation.
7164    type Observer = Box<dyn FnMut(SqliteReadProgressEvent)>;
7165
7166    thread_local! {
7167        /// Observer scoped to the synchronous test thread running the SQLite connection.
7168        static OBSERVER: RefCell<Option<Observer>> = RefCell::new(None);
7169    }
7170
7171    /// Restores a prior nested observer on every return or unwind path.
7172    struct ObserverGuard {
7173        /// Observer replaced for the current scope.
7174        previous: Option<Observer>,
7175    }
7176
7177    impl Drop for ObserverGuard {
7178        fn drop(&mut self) {
7179            OBSERVER.with(|slot| {
7180                drop(slot.replace(self.previous.take()));
7181            });
7182        }
7183    }
7184
7185    /// Run one operation while observing its synchronous `SQLite` progress events.
7186    pub fn observe_sqlite_read_progress<T>(
7187        observer: impl FnMut(SqliteReadProgressEvent) + 'static,
7188        operation: impl FnOnce() -> T,
7189    ) -> T {
7190        let previous = OBSERVER.with(|slot| slot.replace(Some(Box::new(observer))));
7191        let _guard = ObserverGuard { previous };
7192        operation()
7193    }
7194
7195    /// Notify the current thread-local observer when one is installed.
7196    pub(crate) fn notify(event: SqliteReadProgressEvent) {
7197        OBSERVER.with(|slot| {
7198            if let Some(observer) = slot.borrow_mut().as_mut() {
7199                observer(event);
7200            }
7201        });
7202    }
7203}
7204
7205/// Clears a connection-local `SQLite` progress handler on every exit path.
7206pub(crate) struct SqliteReadProgressGuard<'connection> {
7207    /// Connection whose temporary progress callback is armed.
7208    connection: &'connection Connection,
7209    /// Whether this guard installed a callback that must be removed.
7210    armed: bool,
7211}
7212
7213impl<'connection> SqliteReadProgressGuard<'connection> {
7214    /// Install one cooperative progress callback for the owning work stage.
7215    pub(crate) fn new(
7216        connection: &'connection Connection,
7217        control: Option<&IndexWorkControl>,
7218        stage: IndexWorkStage,
7219    ) -> DbResult<Self> {
7220        let armed = if let Some(control) = control {
7221            control.check(stage)?;
7222            let progress_control = control.clone();
7223            connection.progress_handler(
7224                SQLITE_READ_PROGRESS_OPS,
7225                Some(move || {
7226                    #[cfg(feature = "sqlite-progress-test-observer")]
7227                    sqlite_progress_test_observer::notify(
7228                        sqlite_progress_test_observer::SqliteReadProgressEvent::CallbackEntered {
7229                            stage,
7230                        },
7231                    );
7232                    let interrupted = progress_control.check(stage).is_err();
7233                    #[cfg(feature = "sqlite-progress-test-observer")]
7234                    sqlite_progress_test_observer::notify(
7235                        sqlite_progress_test_observer::SqliteReadProgressEvent::CallbackEvaluated {
7236                            stage,
7237                            interrupted,
7238                        },
7239                    );
7240                    interrupted
7241                }),
7242            )?;
7243            true
7244        } else {
7245            false
7246        };
7247        Ok(Self { connection, armed })
7248    }
7249}
7250
7251impl Drop for SqliteReadProgressGuard<'_> {
7252    fn drop(&mut self) {
7253        if self.armed {
7254            drop(self.connection.progress_handler(0, None::<fn() -> bool>));
7255        }
7256    }
7257}
7258
7259/// Run one read with a temporary cooperative progress handler.
7260pub(crate) fn with_sqlite_read_progress<T>(
7261    connection: &Connection,
7262    control: Option<&IndexWorkControl>,
7263    stage: IndexWorkStage,
7264    operation: impl FnOnce() -> DbResult<T>,
7265) -> DbResult<T> {
7266    let guard = SqliteReadProgressGuard::new(connection, control, stage)?;
7267    let result = operation();
7268    drop(guard);
7269    if result.as_ref().is_err_and(|error| {
7270        matches!(
7271            error,
7272            DbError::Sqlite(sqlite)
7273                if sqlite.sqlite_error_code() == Some(ErrorCode::OperationInterrupted)
7274        )
7275    }) && let Some(control) = control
7276    {
7277        control.check(stage)?;
7278    }
7279    result
7280}
7281
7282/// Run one text-index read through the shared bounded database-read guard.
7283fn with_file_text_progress<T>(
7284    connection: &Connection,
7285    control: Option<&IndexWorkControl>,
7286    operation: impl FnOnce() -> DbResult<T>,
7287) -> DbResult<T> {
7288    with_sqlite_read_progress(connection, control, IndexWorkStage::TextIndex, operation)
7289}
7290
7291/// Enforce the single safe-token contract used by FTS candidate lookup.
7292fn validate_file_text_fts_token(token: &str) -> DbResult<()> {
7293    if token.len() < 3 {
7294        return Err(DbError::FileTextFtsTokenUnsafe {
7295            reason: "token is shorter than three ASCII bytes",
7296        });
7297    }
7298    if !token.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
7299        return Err(DbError::FileTextFtsTokenUnsafe {
7300            reason: "token is not exclusively ASCII alphanumeric",
7301        });
7302    }
7303    Ok(())
7304}
7305
7306/// Normalize only the root/trailing-separator cases of an already-relative scope.
7307fn normalized_file_text_path_prefix(path_prefix: Option<&str>) -> Option<&str> {
7308    path_prefix
7309        .map(|prefix| prefix.trim_end_matches('/'))
7310        .filter(|prefix| !prefix.is_empty() && *prefix != ".")
7311}
7312
7313/// Build one binary-collation range containing exactly a path's descendants.
7314fn file_text_descendant_range(path_prefix: &str) -> (String, String) {
7315    (format!("{path_prefix}/"), format!("{path_prefix}0"))
7316}
7317
7318/// Decode one bounded page of FTS metadata candidates without source text.
7319fn collect_file_text_fts_candidates(
7320    rows: &mut rusqlite::Rows<'_>,
7321    control: Option<&IndexWorkControl>,
7322    candidates: &mut Vec<FileTextFtsCandidate>,
7323) -> DbResult<()> {
7324    while let Some(row) = rows.next()? {
7325        if let Some(control) = control {
7326            control.check(IndexWorkStage::TextIndex)?;
7327        }
7328        let path = row.get::<_, String>(0)?;
7329        let bm25 = row.get::<_, f64>(4)?;
7330        if !bm25.is_finite() {
7331            return Err(DbError::FileTextFtsScoreInvalid { path });
7332        }
7333        candidates.push(FileTextFtsCandidate {
7334            path,
7335            content_hash: row.get(1)?,
7336            byte_count: count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?,
7337            line_count: count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?,
7338            bm25,
7339        });
7340    }
7341    Ok(())
7342}
7343
7344/// Decode persisted file metadata before the potentially large source column.
7345fn file_text_metadata_from_row(row: &rusqlite::Row<'_>) -> DbResult<FileTextMetadata> {
7346    let path = row.get::<_, String>(0)?;
7347    let classification = row
7348        .get::<_, Option<String>>(4)?
7349        .ok_or_else(|| DbError::FileContentClassificationMissing { path: path.clone() })?;
7350    Ok(FileTextMetadata {
7351        path,
7352        content_hash: row.get(1)?,
7353        byte_count: count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?,
7354        line_count: count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?,
7355        classification: ContentClassification::from_db(&classification).ok_or(
7356            DbError::InvalidEnum {
7357                field: "file_content_classifications.classification",
7358                value: classification,
7359            },
7360        )?,
7361    })
7362}
7363
7364/// Visit fallback rows with metadata-first admission and cooperative stops.
7365fn visit_file_text_fallback_rows<A, V>(
7366    rows: &mut rusqlite::Rows<'_>,
7367    content_statement: &mut rusqlite::CachedStatement<'_>,
7368    control: Option<&IndexWorkControl>,
7369    admit: &mut A,
7370    visitor: &mut V,
7371) -> DbResult<()>
7372where
7373    A: FnMut(&FileTextMetadata) -> DbResult<FileTextAdmission>,
7374    V: FnMut(IndexedFileText) -> DbResult<bool>,
7375{
7376    while let Some(row) = rows.next()? {
7377        if let Some(control) = control {
7378            control.check(IndexWorkStage::TextIndex)?;
7379        }
7380        let metadata = file_text_metadata_from_row(row)?;
7381        match admit(&metadata)? {
7382            FileTextAdmission::Skip => continue,
7383            FileTextAdmission::Stop => return Ok(()),
7384            FileTextAdmission::Read => {}
7385        }
7386        let content =
7387            content_statement.query_row([&metadata.path], |content_row| content_row.get(0))?;
7388        let text = IndexedFileText {
7389            path: metadata.path,
7390            content_hash: metadata.content_hash,
7391            byte_count: metadata.byte_count,
7392            line_count: metadata.line_count,
7393            content,
7394        };
7395        validate_indexed_file_text(&text)?;
7396        if !visitor(text)? {
7397            return Ok(());
7398        }
7399    }
7400    Ok(())
7401}
7402
7403/// Raw standard node columns retained until typed enum validation succeeds.
7404type IndexedNodeParts = (
7405    String,
7406    String,
7407    Option<String>,
7408    Option<String>,
7409    Option<String>,
7410    Option<u64>,
7411    Option<i64>,
7412    Option<String>,
7413    Option<String>,
7414    String,
7415    String,
7416    Option<String>,
7417);
7418
7419/// Decode the standard node select column order without interpreting enums.
7420fn indexed_node_parts_from_sql_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<IndexedNodeParts> {
7421    let kind_value: String = row.get(1)?;
7422    let source_value: String = row.get(9)?;
7423    let status_value: String = row.get(10)?;
7424    Ok((
7425        row.get::<_, String>(0)?,
7426        kind_value,
7427        row.get::<_, Option<String>>(2)?,
7428        row.get::<_, Option<String>>(3)?,
7429        row.get::<_, Option<String>>(4)?,
7430        option_u64_from_sql(row, 5)?,
7431        row.get::<_, Option<i64>>(6)?,
7432        row.get::<_, Option<String>>(7)?,
7433        row.get::<_, Option<String>>(8)?,
7434        source_value,
7435        status_value,
7436        row.get::<_, Option<String>>(11)?,
7437    ))
7438}
7439
7440/// Build an indexed node from the standard node select column order.
7441fn indexed_node_from_sql_row(row: &rusqlite::Row<'_>) -> DbResult<IndexedNode> {
7442    indexed_node_from_parts(indexed_node_parts_from_sql_row(row)?)
7443}
7444
7445/// Count exact variable-width payload and fixed scalar slots for one node row.
7446fn indexed_node_parts_decoded_bytes(row: &IndexedNodeParts) -> DbResult<u64> {
7447    let lengths = [
7448        row.0.len(),
7449        row.1.len(),
7450        row.2.as_ref().map_or(0, String::len),
7451        row.3.as_ref().map_or(0, String::len),
7452        row.4.as_ref().map_or(0, String::len),
7453        row.7.as_ref().map_or(0, String::len),
7454        row.8.as_ref().map_or(0, String::len),
7455        row.9.len(),
7456        row.10.len(),
7457        row.11.as_ref().map_or(0, String::len),
7458    ];
7459    let mut bytes = 16_u64;
7460    for length in lengths {
7461        let length =
7462            u64::try_from(length).map_err(|_source| GraphContractError::InvalidLimits {
7463                reason: "purpose-owner decoded field length overflowed",
7464            })?;
7465        bytes = bytes
7466            .checked_add(length)
7467            .ok_or(GraphContractError::InvalidLimits {
7468                reason: "purpose-owner decoded row size overflowed",
7469            })?;
7470    }
7471    Ok(bytes)
7472}
7473
7474/// Build an indexed node from database row parts.
7475fn indexed_node_from_parts(row: IndexedNodeParts) -> DbResult<IndexedNode> {
7476    let (
7477        path,
7478        kind_value,
7479        parent_path,
7480        extension,
7481        language,
7482        size_bytes,
7483        mtime_ns,
7484        content_hash,
7485        purpose,
7486        source_value,
7487        status_value,
7488        summary,
7489    ) = row;
7490    let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
7491        field: "kind",
7492        value: kind_value,
7493    })?;
7494    let source = parse_source(&source_value)?;
7495    let status = PurposeStatus::from_db(&status_value).ok_or_else(|| DbError::InvalidEnum {
7496        field: "status",
7497        value: status_value,
7498    })?;
7499    Ok(IndexedNode {
7500        node: Node {
7501            path: path.clone(),
7502            kind,
7503            parent_path,
7504            extension,
7505            language,
7506            size_bytes,
7507            mtime_ns,
7508            content_hash,
7509        },
7510        purpose: Purpose {
7511            path,
7512            purpose,
7513            source,
7514            status,
7515        },
7516        summary,
7517    })
7518}
7519
7520/// Split a user query into lowercase terms for SQL ranking.
7521fn normalize_query_terms(query: &str) -> Vec<String> {
7522    query
7523        .split(|character: char| !character.is_alphanumeric())
7524        .filter(|term| !term.is_empty())
7525        .map(str::to_lowercase)
7526        .collect()
7527}
7528
7529/// Normalize the complete query used by exact path and basename admission tiers.
7530fn normalize_exact_ranked_query(query: &str) -> String {
7531    query.trim().replace('\\', "/").to_lowercase()
7532}
7533
7534/// Build the reviewed-purpose admission predicate for normalized query terms.
7535fn reviewed_purpose_match_expression(term_count: usize) -> String {
7536    if term_count == 0 {
7537        return "0".to_string();
7538    }
7539    let matches = std::iter::repeat_n(
7540        "lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\'",
7541        term_count,
7542    )
7543    .collect::<Vec<_>>()
7544    .join(" OR ");
7545    format!(
7546        "CASE WHEN p.status = 'approved' AND p.source IN ('agent', 'human') \
7547                    AND ({matches}) THEN 1 ELSE 0 END"
7548    )
7549}
7550
7551/// Build the SQL score expression for ranked node lookup.
7552fn ranked_score_expression(term_count: usize) -> String {
7553    if term_count == 0 {
7554        return "1".to_string();
7555    }
7556    (0..term_count)
7557        .map(|_| {
7558            "(CASE WHEN lower(n.path) LIKE ? ESCAPE '\\' THEN 20 ELSE 0 END \
7559             + CASE WHEN p.status = 'approved' AND p.source IN ('agent', 'human') \
7560                          AND lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\' THEN 30 ELSE 0 END \
7561             + CASE WHEN NOT (p.status = 'approved' AND p.source IN ('agent', 'human')) \
7562                          AND lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\' THEN 2 ELSE 0 END \
7563             + CASE WHEN lower(COALESCE(s.summary, '')) LIKE ? ESCAPE '\\' THEN 10 ELSE 0 END \
7564             + CASE WHEN lower(COALESCE(symbol_summaries.summary, '')) LIKE ? ESCAPE '\\' THEN 25 ELSE 0 END)"
7565                .to_string()
7566        })
7567        .collect::<Vec<_>>()
7568        .join(" + ")
7569}
7570
7571/// Convert a normalized term into a `SQLite` LIKE pattern.
7572fn sqlite_like_pattern(term: &str) -> String {
7573    format!("%{}%", sqlite_like_escape(term))
7574}
7575
7576/// Build a `SQLite` LIKE descendant pattern for a repository path prefix.
7577fn sqlite_descendant_pattern(path: &str) -> String {
7578    format!("{}/%", sqlite_like_escape(path))
7579}
7580
7581/// Escape user or path text for `SQLite` LIKE patterns with backslash escaping.
7582fn sqlite_like_escape(value: &str) -> String {
7583    value
7584        .replace('\\', "\\\\")
7585        .replace('%', "\\%")
7586        .replace('_', "\\_")
7587}
7588
7589/// Replace the denormalized symbol-name search summary for one file node.
7590fn replace_symbol_search_summary(
7591    connection: &Connection,
7592    node_id: i64,
7593    summary: Option<&str>,
7594) -> DbResult<()> {
7595    if let Some(summary) = summary {
7596        connection
7597            .prepare_cached(
7598                "
7599                INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
7600                VALUES(?1, 'search', 'symbols', ?2, CURRENT_TIMESTAMP)
7601                ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
7602                    summary = excluded.summary,
7603                    updated_at = CURRENT_TIMESTAMP
7604                ",
7605            )?
7606            .execute(params![node_id, summary])?;
7607    } else {
7608        connection
7609            .prepare_cached(
7610                "
7611                DELETE FROM summaries
7612                WHERE node_id = ?1
7613                  AND summary_level = 'search'
7614                  AND subject = 'symbols'
7615                ",
7616            )?
7617            .execute([node_id])?;
7618    }
7619    Ok(())
7620}
7621
7622/// Build a bounded search-only summary from symbol names.
7623fn symbol_search_summary(graph: &SymbolGraph) -> Option<String> {
7624    let mut names = graph
7625        .symbols
7626        .iter()
7627        .filter(|symbol| !matches!(symbol.kind, SymbolKind::Import | SymbolKind::Unknown))
7628        .map(|symbol| symbol.name.trim())
7629        .filter(|name| !name.is_empty())
7630        .map(ToString::to_string)
7631        .collect::<Vec<_>>();
7632    names.sort();
7633    names.dedup();
7634    if names.is_empty() {
7635        return None;
7636    }
7637    let summary = format!("symbols {}", names.join(" "));
7638    Some(truncate_summary_chars(
7639        &summary,
7640        MAX_SYMBOL_SEARCH_SUMMARY_CHARS,
7641    ))
7642}
7643
7644/// Truncate a summary at a valid UTF-8 boundary.
7645fn truncate_summary_chars(value: &str, max_chars: usize) -> String {
7646    if value.chars().count() <= max_chars {
7647        return value.to_string();
7648    }
7649    value.chars().take(max_chars).collect()
7650}
7651
7652/// Parse a stored purpose source value into the domain enum.
7653fn parse_source(value: &str) -> DbResult<PurposeSource> {
7654    let source = match value {
7655        value if value == PurposeSource::Missing.as_str() => PurposeSource::Missing,
7656        value if value == PurposeSource::Imported.as_str() => PurposeSource::Imported,
7657        value if value == PurposeSource::Generated.as_str() => PurposeSource::Generated,
7658        // Older databases could contain `human`; ProjectAtlas now treats
7659        // explicit approval as agent-owned and serializes new writes as `agent`.
7660        value if value == PurposeSource::Agent.as_str() || value == LEGACY_HUMAN_PURPOSE_SOURCE => {
7661            PurposeSource::Agent
7662        }
7663        _ => {
7664            return Err(DbError::InvalidEnum {
7665                field: "source",
7666                value: value.to_string(),
7667            });
7668        }
7669    };
7670    Ok(source)
7671}
7672
7673/// Normalize a bounded host-owned task label used only for curator work identity.
7674fn normalize_purpose_curation_task(task: &str) -> DbResult<String> {
7675    let normalized = task.split_whitespace().collect::<Vec<_>>().join(" ");
7676    if normalized.is_empty() {
7677        return Err(DbError::PurposeCurationTaskInvalid {
7678            reason: "task must not be blank",
7679        });
7680    }
7681    if normalized.len() > MAX_PURPOSE_CURATION_TASK_BYTES {
7682        return Err(DbError::PurposeCurationTaskInvalid {
7683            reason: "task exceeds the UTF-8 byte limit",
7684        });
7685    }
7686    if normalized.chars().any(char::is_control) {
7687        return Err(DbError::PurposeCurationTaskInvalid {
7688            reason: "task contains control characters",
7689        });
7690    }
7691    Ok(normalized)
7692}
7693
7694/// Load one current purpose row inside the caller's read or write snapshot.
7695fn load_current_purpose_state(
7696    connection: &Connection,
7697    path: &str,
7698) -> DbResult<Option<(i64, Purpose)>> {
7699    let row = connection
7700        .prepare_cached(
7701            "
7702            SELECT n.id, p.purpose, p.source, p.status
7703            FROM nodes n
7704            JOIN purposes p ON p.node_id = n.id
7705            WHERE n.exists_now = 1 AND n.path = ?1
7706            ",
7707        )?
7708        .query_row([path], |row| {
7709            Ok((
7710                row.get::<_, i64>(0)?,
7711                row.get::<_, Option<String>>(1)?,
7712                row.get::<_, String>(2)?,
7713                row.get::<_, String>(3)?,
7714            ))
7715        })
7716        .optional()?;
7717    row.map(|(node_id, purpose, source, status)| {
7718        let source = parse_source(&source)?;
7719        let status = PurposeStatus::from_db(&status).ok_or_else(|| DbError::InvalidEnum {
7720            field: "purpose_status",
7721            value: status,
7722        })?;
7723        Ok((
7724            node_id,
7725            Purpose {
7726                path: path.to_string(),
7727                purpose,
7728                source,
7729                status,
7730            },
7731        ))
7732    })
7733    .transpose()
7734}
7735
7736/// Apply one queue item inside the caller's validated writer transaction.
7737fn apply_conditional_purpose(
7738    connection: &Connection,
7739    project: ProjectInstanceId,
7740    generation: IndexGeneration,
7741    request: &PurposeConditionalApplyRequest,
7742) -> DbResult<PurposeConditionalApplyResult> {
7743    let current = load_current_purpose_state(connection, &request.path)?;
7744    let Some((node_id, current_purpose)) = current else {
7745        return Ok(PurposeConditionalApplyResult {
7746            path: request.path.clone(),
7747            state: PurposeConditionalApplyState::PathUnavailable,
7748            current_purpose: None,
7749        });
7750    };
7751    if !matches!(
7752        current_purpose.status,
7753        PurposeStatus::Missing | PurposeStatus::Suggested
7754    ) {
7755        return Ok(PurposeConditionalApplyResult {
7756            path: request.path.clone(),
7757            state: PurposeConditionalApplyState::Accepted,
7758            current_purpose: Some(current_purpose),
7759        });
7760    }
7761    let current_work_key =
7762        purpose_curation_item_work_key(project, generation, &request.task, &request.path);
7763    let current_state_token = purpose_curation_state_token(&current_work_key, &current_purpose);
7764    if current_work_key != request.work_key || current_state_token != request.state_token {
7765        return Ok(PurposeConditionalApplyResult {
7766            path: request.path.clone(),
7767            state: PurposeConditionalApplyState::Stale,
7768            current_purpose: Some(current_purpose),
7769        });
7770    }
7771    let generation_sql =
7772        i64::try_from(generation.get()).map_err(|_source| DbError::GraphCountOverflow {
7773            field: "project_identity.active_generation",
7774            value: generation.get(),
7775        })?;
7776    let changed = connection
7777        .prepare_cached(
7778            "
7779            UPDATE purposes
7780            SET purpose = ?2,
7781                source = ?3,
7782                status = ?4,
7783                updated_at = CURRENT_TIMESTAMP
7784            WHERE node_id = ?1
7785              AND status = ?5
7786              AND source = ?6
7787              AND purpose IS ?7
7788              AND EXISTS (
7789                  SELECT 1
7790                  FROM nodes n
7791                  JOIN project_identity pi ON pi.singleton = 1
7792                  WHERE n.id = ?1
7793                    AND n.exists_now = 1
7794                    AND n.path = ?8
7795                    AND pi.project_instance_id = ?9
7796                    AND pi.active_generation = ?10
7797              )
7798            ",
7799        )?
7800        .execute(params![
7801            node_id,
7802            request.purpose,
7803            PurposeSource::Agent.as_str(),
7804            PurposeStatus::Approved.as_str(),
7805            current_purpose.status.as_str(),
7806            current_purpose.source.as_str(),
7807            current_purpose.purpose,
7808            request.path,
7809            &project.as_bytes()[..],
7810            generation_sql,
7811        ])?;
7812    if changed == 1 {
7813        Ok(PurposeConditionalApplyResult {
7814            path: request.path.clone(),
7815            state: PurposeConditionalApplyState::Applied,
7816            current_purpose: Some(Purpose {
7817                path: request.path.clone(),
7818                purpose: Some(request.purpose.clone()),
7819                source: PurposeSource::Agent,
7820                status: PurposeStatus::Approved,
7821            }),
7822        })
7823    } else {
7824        Ok(PurposeConditionalApplyResult {
7825            path: request.path.clone(),
7826            state: PurposeConditionalApplyState::Stale,
7827            current_purpose: Some(current_purpose),
7828        })
7829    }
7830}
7831
7832/// Construct one candidate with deterministic work and stale-state identities.
7833fn purpose_curation_candidate(
7834    project: ProjectInstanceId,
7835    generation: IndexGeneration,
7836    task: &str,
7837    node: IndexedNode,
7838) -> PurposeCurationCandidate {
7839    let work_key = purpose_curation_item_work_key(project, generation, task, &node.node.path);
7840    let state_token = purpose_curation_state_token(&work_key, &node.purpose);
7841    PurposeCurationCandidate {
7842        node,
7843        work_key,
7844        state_token,
7845    }
7846}
7847
7848/// Derive one stable project/generation/task/path work identity.
7849fn purpose_curation_item_work_key(
7850    project: ProjectInstanceId,
7851    generation: IndexGeneration,
7852    task: &str,
7853    path: &str,
7854) -> String {
7855    digest_fields(
7856        PURPOSE_CURATION_ITEM_KEY_DOMAIN,
7857        &[
7858            &project.as_bytes(),
7859            &generation.get().to_le_bytes(),
7860            task.as_bytes(),
7861            path.as_bytes(),
7862        ],
7863    )
7864}
7865
7866/// Bind conditional apply to the exact unapproved purpose row selected by the queue.
7867fn purpose_curation_state_token(work_key: &str, purpose: &Purpose) -> String {
7868    let purpose_presence = [u8::from(purpose.purpose.is_some())];
7869    digest_fields(
7870        PURPOSE_CURATION_STATE_TOKEN_DOMAIN,
7871        &[
7872            work_key.as_bytes(),
7873            &purpose_presence,
7874            purpose.purpose.as_deref().unwrap_or_default().as_bytes(),
7875            purpose.source.as_str().as_bytes(),
7876            purpose.status.as_str().as_bytes(),
7877        ],
7878    )
7879}
7880
7881/// Derive a deterministic identity for one complete returned candidate set.
7882fn purpose_curation_batch_work_key(
7883    project: ProjectInstanceId,
7884    generation: IndexGeneration,
7885    task: &str,
7886    items: &[PurposeCurationCandidate],
7887) -> String {
7888    let mut hasher = Hasher::new();
7889    digest_field(&mut hasher, PURPOSE_CURATION_BATCH_KEY_DOMAIN.as_bytes());
7890    digest_field(&mut hasher, &project.as_bytes());
7891    digest_field(&mut hasher, &generation.get().to_le_bytes());
7892    digest_field(&mut hasher, task.as_bytes());
7893    for item in items {
7894        digest_field(&mut hasher, item.work_key.as_bytes());
7895        digest_field(&mut hasher, item.state_token.as_bytes());
7896    }
7897    hasher.finalize().to_hex().to_string()
7898}
7899
7900/// Hash length-delimited fields under one stable domain separator.
7901fn digest_fields(domain: &str, fields: &[&[u8]]) -> String {
7902    let mut hasher = Hasher::new();
7903    digest_field(&mut hasher, domain.as_bytes());
7904    for field in fields {
7905        digest_field(&mut hasher, field);
7906    }
7907    hasher.finalize().to_hex().to_string()
7908}
7909
7910/// Append one unambiguous field to a deterministic digest.
7911fn digest_field(hasher: &mut Hasher, value: &[u8]) {
7912    let length = u64::try_from(value.len()).unwrap_or(u64::MAX);
7913    hasher.update(&length.to_le_bytes());
7914    hasher.update(value);
7915}
7916
7917/// Convert an aggregate database count into a platform `usize`.
7918fn count_to_usize(field: &'static str, value: i64) -> DbResult<usize> {
7919    usize::try_from(value).map_err(|source| DbError::InvalidCount {
7920        field,
7921        value,
7922        source,
7923    })
7924}
7925
7926/// Convert a usize to i64 with saturation for database storage.
7927fn usize_to_i64(value: usize) -> i64 {
7928    i64::try_from(value).unwrap_or(i64::MAX)
7929}
7930
7931/// Convert a non-negative i64 to usize for database reads.
7932fn i64_to_usize(value: i64) -> usize {
7933    usize::try_from(value.max(0)).unwrap_or(usize::MAX)
7934}
7935
7936/// Decode an optional nonnegative `SQLite` integer into the public unsigned size type.
7937fn option_u64_from_sql(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<Option<u64>> {
7938    row.get::<_, Option<i64>>(index)?
7939        .map(|value| {
7940            u64::try_from(value).map_err(|source| {
7941                rusqlite::Error::FromSqlConversionFailure(
7942                    index,
7943                    rusqlite::types::Type::Integer,
7944                    Box::new(source),
7945                )
7946            })
7947        })
7948        .transpose()
7949}
7950
7951/// Convert an optional token count into the exact `SQLite` integer range.
7952#[cfg(test)]
7953fn option_usize_to_i64(field: &'static str, value: Option<usize>) -> DbResult<Option<i64>> {
7954    value
7955        .map(|value| {
7956            i64::try_from(value).map_err(|_source| DbError::TelemetryIntegerOverflow { field })
7957        })
7958        .transpose()
7959}
7960
7961/// Build the classified symbol query from static clauses and bound caller values.
7962fn classified_symbols_sql(
7963    file: Option<&str>,
7964    query: Option<&str>,
7965    selection: ContentSelection,
7966    limit: usize,
7967) -> (String, Vec<Value>) {
7968    // Internal document coverage metadata is retained for graph rebuilds, not symbol navigation.
7969    let mut predicates = vec!["NOT (symbol.language IN ('docx', 'pdf') AND symbol.kind = 'unknown' AND symbol.name = 'document-text-coverage')".to_owned()];
7970    let mut bindings = Vec::new();
7971    if let Some(file) = file {
7972        bindings.push(Value::Text(file.to_string()));
7973        predicates.push(format!("symbol.path = ?{}", bindings.len()));
7974    }
7975    if let Some(query) = query {
7976        bindings.push(Value::Text(like_query(query)));
7977        let placeholder = bindings.len();
7978        let path_predicate = if file.is_some() {
7979            String::new()
7980        } else {
7981            format!(" OR symbol.path LIKE ?{placeholder}")
7982        };
7983        predicates.push(format!(
7984            "(symbol.name LIKE ?{placeholder} OR symbol.signature LIKE ?{placeholder} \
7985             OR symbol.documentation LIKE ?{placeholder}{path_predicate})"
7986        ));
7987    }
7988    match selection {
7989        ContentSelection::UnspecifiedLegacy => {}
7990        ContentSelection::Source => {
7991            bindings.push(Value::Text(
7992                ContentClassification::Source.as_str().to_string(),
7993            ));
7994            predicates.push(format!(
7995                "(classification.classification = ?{} OR classification.path IS NULL)",
7996                bindings.len()
7997            ));
7998        }
7999        ContentSelection::Documentation => {
8000            bindings.push(Value::Text(
8001                ContentClassification::Documentation.as_str().to_string(),
8002            ));
8003            predicates.push(format!(
8004                "(classification.classification = ?{} OR classification.path IS NULL)",
8005                bindings.len()
8006            ));
8007        }
8008        ContentSelection::Both => {
8009            bindings.push(Value::Text(
8010                ContentClassification::Source.as_str().to_string(),
8011            ));
8012            let source = bindings.len();
8013            bindings.push(Value::Text(
8014                ContentClassification::Documentation.as_str().to_string(),
8015            ));
8016            let documentation = bindings.len();
8017            predicates.push(format!(
8018                "(classification.classification IN (?{source}, ?{documentation}) \
8019                 OR classification.path IS NULL)"
8020            ));
8021        }
8022    }
8023    bindings.push(Value::Integer(usize_to_i64(limit.max(1))));
8024    let limit = bindings.len();
8025    let where_clause = if predicates.is_empty() {
8026        String::new()
8027    } else {
8028        format!("WHERE {}", predicates.join(" AND "))
8029    };
8030    let sql = format!(
8031        "SELECT symbol.path, symbol.language, symbol.name, symbol.kind,
8032                symbol.signature, symbol.line_start, symbol.line_end, symbol.parent,
8033                symbol.parser, symbol.detail, symbol.exported, symbol.documentation,
8034                symbol.source_byte_start, symbol.source_byte_end,
8035                symbol.source_column_start, symbol.source_column_end,
8036                classification.classification
8037           FROM symbols AS symbol INDEXED BY idx_symbols_path
8038           LEFT JOIN file_content_classifications AS classification
8039             ON classification.path = symbol.path
8040           {where_clause}
8041          ORDER BY symbol.path, symbol.line_start, symbol.name
8042          LIMIT ?{limit}"
8043    );
8044    (sql, bindings)
8045}
8046
8047/// Validate and narrow one parser-supplied source selector for `SQLite` storage.
8048fn symbol_source_selector_values(symbol: &CodeSymbol) -> DbResult<[Option<i64>; 4]> {
8049    let Some(selector) = symbol.source_selector else {
8050        return Ok([None; 4]);
8051    };
8052    if symbol.line_start == 0
8053        || symbol.line_end < symbol.line_start
8054        || selector.byte_end < selector.byte_start
8055        || (symbol.line_start == symbol.line_end && selector.column_end < selector.column_start)
8056    {
8057        return Err(DbError::SymbolGraphRowShape {
8058            path: symbol.path.clone(),
8059            reason: "symbol source selector range is invalid",
8060        });
8061    }
8062    let narrow = |value| match i64::try_from(value) {
8063        Ok(value) => Ok(Some(value)),
8064        Err(_) => Err(DbError::SymbolGraphRowShape {
8065            path: symbol.path.clone(),
8066            reason: "symbol source selector exceeds the SQLite integer range",
8067        }),
8068    };
8069    Ok([
8070        narrow(selector.byte_start)?,
8071        narrow(selector.byte_end)?,
8072        narrow(selector.column_start)?,
8073        narrow(selector.column_end)?,
8074    ])
8075}
8076
8077/// Decode one all-or-none persisted source selector without coercing corrupt ranges.
8078fn symbol_source_selector_from_row(
8079    row: &rusqlite::Row<'_>,
8080    line_start: usize,
8081    line_end: usize,
8082) -> rusqlite::Result<Option<SymbolSourceSelector>> {
8083    let raw = [
8084        row.get::<_, Option<i64>>(12)?,
8085        row.get::<_, Option<i64>>(13)?,
8086        row.get::<_, Option<i64>>(14)?,
8087        row.get::<_, Option<i64>>(15)?,
8088    ];
8089    let [
8090        Some(byte_start),
8091        Some(byte_end),
8092        Some(column_start),
8093        Some(column_end),
8094    ] = raw
8095    else {
8096        if raw.iter().all(Option::is_none) {
8097            return Ok(None);
8098        }
8099        return Err(invalid_symbol_source_selector(
8100            "symbol source selector columns are only partially populated",
8101        ));
8102    };
8103    if byte_start < 0
8104        || byte_end < byte_start
8105        || column_start < 0
8106        || column_end < 0
8107        || line_start == 0
8108        || line_end < line_start
8109        || (line_start == line_end && column_end < column_start)
8110    {
8111        return Err(invalid_symbol_source_selector(
8112            "symbol source selector range is invalid",
8113        ));
8114    }
8115    Ok(Some(SymbolSourceSelector {
8116        byte_start: symbol_source_selector_usize(byte_start)?,
8117        byte_end: symbol_source_selector_usize(byte_end)?,
8118        column_start: symbol_source_selector_usize(column_start)?,
8119        column_end: symbol_source_selector_usize(column_end)?,
8120    }))
8121}
8122
8123/// Convert one validated nonnegative selector integer without losing overflow detail.
8124fn symbol_source_selector_usize(value: i64) -> rusqlite::Result<usize> {
8125    usize::try_from(value).map_err(|source| {
8126        rusqlite::Error::FromSqlConversionFailure(
8127            12,
8128            rusqlite::types::Type::Integer,
8129            Box::new(source),
8130        )
8131    })
8132}
8133
8134/// Construct one fail-closed `SQLite` conversion error for a malformed selector row.
8135fn invalid_symbol_source_selector(message: &'static str) -> rusqlite::Error {
8136    rusqlite::Error::FromSqlConversionFailure(
8137        12,
8138        rusqlite::types::Type::Integer,
8139        Box::new(std::io::Error::other(message)),
8140    )
8141}
8142
8143/// Decode one persisted symbol row through the shared column contract.
8144fn code_symbol_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CodeSymbol> {
8145    let line_start = i64_to_usize(row.get::<_, i64>(5)?);
8146    let line_end = i64_to_usize(row.get::<_, i64>(6)?);
8147    Ok(CodeSymbol {
8148        path: row.get(0)?,
8149        language: row.get(1)?,
8150        name: row.get(2)?,
8151        kind: SymbolKind::from_db(row.get_ref(3)?.as_str()?),
8152        signature: row.get(4)?,
8153        line_start,
8154        line_end,
8155        source_selector: symbol_source_selector_from_row(row, line_start, line_end)?,
8156        parent: row.get(7)?,
8157        parser: ParserKind::from_db(row.get_ref(8)?.as_str()?),
8158        detail: row.get(9)?,
8159        exported: row.get::<_, i64>(10)? != 0,
8160        documentation: row.get(11)?,
8161    })
8162}
8163
8164/// Decode one joined symbol/classification row through both owning contracts.
8165fn classified_symbol_from_row(row: &rusqlite::Row<'_>) -> DbResult<ClassifiedSymbol> {
8166    let symbol = code_symbol_from_row(row)?;
8167    let classification = row.get::<_, Option<String>>(16)?.ok_or_else(|| {
8168        DbError::FileContentClassificationMissing {
8169            path: symbol.path.clone(),
8170        }
8171    })?;
8172    Ok(ClassifiedSymbol {
8173        symbol,
8174        classification: content_classification::parse_classification(classification)?,
8175    })
8176}
8177
8178/// Read the exact owned-string byte floor before hydrating one symbol row.
8179fn code_symbol_preflight_bytes(row: &rusqlite::Row<'_>) -> DbResult<u64> {
8180    let string_bytes = row.get::<_, i64>(16)?;
8181    let string_bytes = u64::try_from(string_bytes).map_err(|source| DbError::InvalidCount {
8182        field: "symbol persisted field bytes",
8183        value: string_bytes,
8184        source,
8185    })?;
8186    let row_bytes = u64::try_from(std::mem::size_of::<CodeSymbol>()).map_err(|source| {
8187        DbError::InvalidCount {
8188            field: "symbol persisted field bytes",
8189            value: i64::MAX,
8190            source,
8191        }
8192    })?;
8193    row_bytes.checked_add(string_bytes).ok_or_else(|| {
8194        GraphContractError::InvalidLimits {
8195            reason: "symbol persisted row bytes overflowed",
8196        }
8197        .into()
8198    })
8199}
8200
8201/// Decode the covering persisted import projection.
8202fn stored_import_relation_from_row(
8203    row: &rusqlite::Row<'_>,
8204) -> rusqlite::Result<StoredImportRelation> {
8205    Ok(StoredImportRelation {
8206        path: row.get(0)?,
8207        source_name: row.get(1)?,
8208        target_name: row.get(2)?,
8209        line: i64_to_usize(row.get::<_, i64>(3)?),
8210    })
8211}
8212
8213/// Count the retained Rust row plus its owned string allocation capacities.
8214fn code_symbol_decoded_bytes(symbol: &CodeSymbol) -> DbResult<u64> {
8215    let capacities = [
8216        symbol.path.capacity(),
8217        symbol.language.as_ref().map_or(0, String::capacity),
8218        symbol.name.capacity(),
8219        symbol.signature.capacity(),
8220        symbol.documentation.as_ref().map_or(0, String::capacity),
8221        symbol.parent.as_ref().map_or(0, String::capacity),
8222        symbol.detail.as_ref().map_or(0, String::capacity),
8223    ];
8224    let mut bytes = u64::try_from(std::mem::size_of::<CodeSymbol>()).map_err(|source| {
8225        DbError::InvalidCount {
8226            field: "symbol decoded field bytes",
8227            value: i64::MAX,
8228            source,
8229        }
8230    })?;
8231    for capacity in capacities {
8232        let capacity = u64::try_from(capacity).map_err(|source| DbError::InvalidCount {
8233            field: "symbol decoded field bytes",
8234            value: i64::MAX,
8235            source,
8236        })?;
8237        bytes = bytes
8238            .checked_add(capacity)
8239            .ok_or(GraphContractError::InvalidLimits {
8240                reason: "symbol decoded row bytes overflowed",
8241            })?;
8242    }
8243    Ok(bytes)
8244}
8245
8246/// Wrap a query string for a SQL LIKE expression.
8247fn like_query(query: &str) -> String {
8248    format!("%{query}%")
8249}
8250
8251/// Build the single set-oriented query used to hydrate exact purpose-work paths.
8252fn load_nodes_by_paths_sql(path_count: usize) -> String {
8253    let placeholders = numbered_placeholders(1, path_count);
8254    format!(
8255        "
8256        SELECT
8257            n.path,
8258            n.kind,
8259            n.parent_path,
8260            n.extension,
8261            n.language,
8262            n.size_bytes,
8263            n.mtime_ns,
8264            n.content_hash,
8265            p.purpose,
8266            p.source,
8267            p.status,
8268            s.summary
8269        FROM nodes n
8270        JOIN purposes p ON p.node_id = n.id
8271        LEFT JOIN summaries s ON s.node_id = n.id
8272            AND s.summary_level = 'node'
8273            AND s.subject = ''
8274        WHERE n.exists_now = 1 AND n.path IN ({placeholders})
8275        ORDER BY n.path
8276        "
8277    )
8278}
8279
8280/// Build numbered SQL placeholders starting at a caller-selected index.
8281fn numbered_placeholders(start: usize, count: usize) -> String {
8282    (start..start + count)
8283        .map(|index| format!("?{index}"))
8284        .collect::<Vec<_>>()
8285        .join(", ")
8286}
8287
8288/// Generate the durable node-level content summary.
8289fn generate_node_summary(node: &Node) -> String {
8290    match node.kind {
8291        NodeKind::Folder => format!("Folder for {}", path_label(&node.path)),
8292        NodeKind::File => file_summary(node),
8293    }
8294}
8295
8296/// Generate a one-line observed file summary from scan metadata.
8297fn file_summary(node: &Node) -> String {
8298    let language = node
8299        .language
8300        .as_deref()
8301        .or(node.extension.as_deref())
8302        .unwrap_or("unknown");
8303    let size = node.size_bytes.map_or_else(
8304        || "unknown size".to_string(),
8305        |bytes| format!("{bytes} bytes"),
8306    );
8307    format!("{language} file, {size}")
8308}
8309
8310/// Return a readable label for a repository-relative path.
8311fn path_label(path: &str) -> String {
8312    if path == "." {
8313        return "repository root".to_string();
8314    }
8315    path.rsplit('/')
8316        .next()
8317        .filter(|value| !value.is_empty())
8318        .unwrap_or(path)
8319        .replace(['-', '_'], " ")
8320}
8321
8322/// Emit a health finding when it has not already been resolved.
8323fn emit_unresolved_finding<F>(
8324    finding: HealthFinding,
8325    resolved_ids: &HashSet<String>,
8326    visitor: &mut F,
8327) -> DbResult<bool>
8328where
8329    F: FnMut(HealthFinding) -> DbResult<bool>,
8330{
8331    if resolved_ids.contains(&finding.id) {
8332        return Ok(true);
8333    }
8334    visitor(finding)
8335}
8336
8337/// Return whether a purpose-status source can match a bounded health query.
8338fn purpose_health_spec_matches_query(spec: PurposeHealthSpec, query: &HealthQuery) -> bool {
8339    health_category_matches_query(spec.category, Severity::Warning, query)
8340}
8341
8342/// Return whether a health category/severity can match a bounded query.
8343fn health_category_matches_query(category: &str, severity: Severity, query: &HealthQuery) -> bool {
8344    query
8345        .category
8346        .as_deref()
8347        .is_none_or(|requested| category.eq_ignore_ascii_case(requested))
8348        && query.severity.is_none_or(|requested| severity == requested)
8349}
8350
8351/// Return purpose health metadata for a stored purpose status.
8352fn purpose_health_spec_for_status(status: &str) -> DbResult<PurposeHealthSpec> {
8353    PURPOSE_HEALTH_SPECS
8354        .iter()
8355        .copied()
8356        .find(|spec| spec.status == status)
8357        .ok_or_else(|| DbError::InvalidEnum {
8358            field: "status",
8359            value: status.to_string(),
8360        })
8361}
8362
8363/// Build the health finding for an approved purpose that still needs agent review.
8364fn agent_review_required_finding(path: String) -> HealthFinding {
8365    HealthFinding {
8366        id: finding_id(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED, &path, None),
8367        severity: Severity::Warning,
8368        category: CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
8369        path,
8370        related_path: None,
8371        message: MESSAGE_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
8372        recommendation: RECOMMENDATION_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
8373    }
8374}
8375
8376/// Build the shared SQL filter for globally ordered purpose lifecycle findings.
8377fn purpose_lifecycle_where_clause(
8378    specs: &[PurposeHealthSpec],
8379    path_prefix: Option<&str>,
8380    resolution_filter: HealthResolutionFilter<'_>,
8381    scope: HealthScope,
8382) -> (String, Vec<Value>) {
8383    let statuses = specs
8384        .iter()
8385        .map(|spec| format!("'{}'", spec.status))
8386        .collect::<Vec<_>>()
8387        .join(", ");
8388    let mut clauses = vec![
8389        "n.exists_now = 1".to_string(),
8390        format!("p.status IN ({statuses})"),
8391    ];
8392    let mut values = Vec::new();
8393
8394    if source_filter_applies_before_queue(scope) {
8395        clauses.push(source_relevant_node_expression("n"));
8396    }
8397    if scope.high_impact_queue() {
8398        clauses.push(purpose_default_queue_node_expression("n", "p", scope));
8399    }
8400
8401    let normalized_prefix = path_prefix
8402        .map(normalize_repo_path_prefix)
8403        .filter(|prefix| prefix != ".");
8404    if let Some(prefix) = normalized_prefix {
8405        clauses.push(format!(
8406            "(n.path = ?{} OR n.path LIKE ?{} ESCAPE '\\')",
8407            values.len() + 1,
8408            values.len() + 2
8409        ));
8410        values.push(Value::from(prefix.clone()));
8411        values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8412    }
8413
8414    match resolution_filter {
8415        HealthResolutionFilter::Explicit(resolved_ids) => {
8416            for spec in specs {
8417                let resolved_paths = resolved_purpose_paths(resolved_ids, spec.category);
8418                if !resolved_paths.is_empty() {
8419                    clauses.push(format!(
8420                        "NOT (p.status = '{}' AND n.path IN ({}))",
8421                        spec.status,
8422                        numbered_placeholders(values.len() + 1, resolved_paths.len())
8423                    ));
8424                    values.extend(resolved_paths.into_iter().map(Value::from));
8425                }
8426            }
8427        }
8428        HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(
8429            &purpose_lifecycle_finding_id_expression(specs, "n", "p"),
8430        )),
8431    }
8432
8433    (clauses.join(" AND "), values)
8434}
8435
8436/// Build the shared SQL filter for purpose lifecycle health findings.
8437fn purpose_status_where_clause(
8438    spec: PurposeHealthSpec,
8439    path_prefix: Option<&str>,
8440    resolution_filter: HealthResolutionFilter<'_>,
8441    scope: HealthScope,
8442) -> (String, Vec<Value>) {
8443    let mut clauses = vec!["n.exists_now = 1".to_string(), "p.status = ?1".to_string()];
8444    let mut values = vec![Value::from(spec.status.to_string())];
8445
8446    if source_filter_applies_before_queue(scope) {
8447        clauses.push(source_relevant_node_expression("n"));
8448    }
8449    if scope.high_impact_queue() {
8450        clauses.push(purpose_default_queue_node_expression("n", "p", scope));
8451    }
8452
8453    let normalized_prefix = path_prefix
8454        .map(normalize_repo_path_prefix)
8455        .filter(|prefix| prefix != ".");
8456    if let Some(prefix) = normalized_prefix {
8457        clauses.push(format!(
8458            "(n.path = ?{} OR n.path LIKE ?{} ESCAPE '\\')",
8459            values.len() + 1,
8460            values.len() + 2
8461        ));
8462        values.push(Value::from(prefix.clone()));
8463        values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8464    }
8465
8466    match resolution_filter {
8467        HealthResolutionFilter::Explicit(resolved_ids) => {
8468            let resolved_paths = resolved_purpose_paths(resolved_ids, spec.category);
8469            if !resolved_paths.is_empty() {
8470                clauses.push(format!(
8471                    "n.path NOT IN ({})",
8472                    numbered_placeholders(values.len() + 1, resolved_paths.len())
8473                ));
8474                values.extend(resolved_paths.into_iter().map(Value::from));
8475            }
8476        }
8477        HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(&format!(
8478            "'{}:' || n.path || ':'",
8479            spec.category
8480        ))),
8481    }
8482
8483    (clauses.join(" AND "), values)
8484}
8485
8486/// Build a structural-health SQL filter over `findings` CTE columns.
8487fn structural_finding_where_clause(
8488    category: &str,
8489    path_prefix: Option<&str>,
8490    resolution_filter: HealthResolutionFilter<'_>,
8491    scope: HealthScope,
8492    first_placeholder: usize,
8493) -> (String, Vec<Value>) {
8494    let mut placeholder = first_placeholder;
8495    let mut clauses = Vec::new();
8496    let mut values = Vec::new();
8497
8498    if source_filter_applies_before_queue(scope) {
8499        clauses.push("source_relevant = 1".to_string());
8500    }
8501    if scope.high_impact_queue() {
8502        clauses.push(purpose_default_queue_finding_expression(scope));
8503    }
8504
8505    let normalized_prefix = path_prefix
8506        .map(normalize_repo_path_prefix)
8507        .filter(|prefix| prefix != ".");
8508    if let Some(prefix) = normalized_prefix {
8509        clauses.push(format!(
8510            "((path = ?{path_exact} OR path LIKE ?{path_descendant} ESCAPE '\\') \
8511              OR (related_path = ?{related_exact} OR related_path LIKE ?{related_descendant} ESCAPE '\\'))",
8512            path_exact = placeholder,
8513            path_descendant = placeholder + 1,
8514            related_exact = placeholder + 2,
8515            related_descendant = placeholder + 3
8516        ));
8517        values.push(Value::from(prefix.clone()));
8518        values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8519        values.push(Value::from(prefix.clone()));
8520        values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8521        placeholder += 4;
8522    }
8523
8524    match resolution_filter {
8525        HealthResolutionFilter::Explicit(resolved_ids) => {
8526            let resolved_ids = resolved_ids_for_category(resolved_ids, category);
8527            if !resolved_ids.is_empty() {
8528                clauses.push(format!(
8529                    "('{category}:' || path || ':' || related_path) NOT IN ({})",
8530                    numbered_placeholders(placeholder, resolved_ids.len())
8531                ));
8532                values.extend(resolved_ids.into_iter().map(Value::from));
8533            }
8534        }
8535        HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(&format!(
8536            "'{category}:' || path || ':' || related_path"
8537        ))),
8538    }
8539
8540    if clauses.is_empty() {
8541        (String::new(), values)
8542    } else {
8543        (format!("WHERE {}", clauses.join(" AND ")), values)
8544    }
8545}
8546
8547/// Build the exact stored finding-id expression for mixed purpose lifecycle rows.
8548fn purpose_lifecycle_finding_id_expression(
8549    specs: &[PurposeHealthSpec],
8550    node_alias: &str,
8551    purpose_alias: &str,
8552) -> String {
8553    let category_cases = specs
8554        .iter()
8555        .map(|spec| format!("WHEN '{}' THEN '{}:'", spec.status, spec.category))
8556        .collect::<Vec<_>>()
8557        .join(" ");
8558    format!(
8559        "(CASE {purpose_alias}.status {category_cases} ELSE '' END || {node_alias}.path || ':')"
8560    )
8561}
8562
8563/// Build an indexed anti-lookup against durable health resolutions.
8564fn stored_resolution_filter_clause(finding_id_expression: &str) -> String {
8565    format!(
8566        "NOT EXISTS (SELECT 1 FROM health_resolutions hr WHERE hr.finding_id = {finding_id_expression})"
8567    )
8568}
8569
8570/// SQL expression for approved purposes that need agent review at the requested scope.
8571fn purpose_review_candidate_expression(node_alias: &str, scope: HealthScope) -> String {
8572    let scope = match scope {
8573        HealthScope::All => HealthScope::PurposeStrict,
8574        other => other,
8575    };
8576    purpose_default_queue_node_expression(node_alias, "p", scope)
8577}
8578
8579/// SQL expression for paths that belong in the default purpose queue.
8580fn purpose_default_queue_node_expression(
8581    node_alias: &str,
8582    purpose_alias: &str,
8583    scope: HealthScope,
8584) -> String {
8585    let asset_clause = if scope.include_assets() {
8586        format!(
8587            " OR ({node_alias}.kind = 'file' AND NOT ({}))",
8588            source_relevant_node_expression(node_alias)
8589        )
8590    } else {
8591        String::new()
8592    };
8593    let source_file_clause = if scope.include_source_files() {
8594        format!(" OR ({node_alias}.kind = 'file' AND COALESCE({node_alias}.language, '') <> '')")
8595    } else {
8596        String::new()
8597    };
8598    let all_file_clause = if scope.include_all_files() {
8599        format!(" OR {node_alias}.kind = 'file'")
8600    } else {
8601        String::new()
8602    };
8603    let stale_queue_sources = sql_string_literals(STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES);
8604    format!(
8605        "({node_alias}.kind = 'folder' \
8606          OR ({node_alias}.kind = 'file' \
8607              AND {purpose_alias}.status = 'stale' \
8608              AND {purpose_alias}.source IN ({stale_queue_sources})) \
8609          OR ({node_alias}.kind = 'file' AND {}){source_file_clause}{all_file_clause}{asset_clause})",
8610        high_impact_file_path_expression(&format!("lower({node_alias}.path)")),
8611    )
8612}
8613
8614/// SQL expression for finding CTE columns that belong in the default purpose queue.
8615fn purpose_default_queue_finding_expression(scope: HealthScope) -> String {
8616    let asset_clause = if scope.include_assets() {
8617        " OR (kind = 'file' AND COALESCE(language, '') = '')"
8618    } else {
8619        ""
8620    };
8621    let source_file_clause = if scope.include_source_files() {
8622        " OR (kind = 'file' AND COALESCE(language, '') <> '')"
8623    } else {
8624        ""
8625    };
8626    let all_file_clause = if scope.include_all_files() {
8627        " OR kind = 'file'"
8628    } else {
8629        ""
8630    };
8631    format!(
8632        "(kind = 'folder' OR (kind = 'file' AND {}){source_file_clause}{all_file_clause}{asset_clause})",
8633        high_impact_file_path_expression("lower(path)")
8634    )
8635}
8636
8637/// SQL ORDER BY expression that keeps folder-purpose work ahead of file cleanup.
8638fn purpose_default_queue_order_expression(node_alias: &str, purpose_alias: &str) -> String {
8639    let stale_queue_sources = sql_string_literals(STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES);
8640    format!(
8641        "CASE \
8642            WHEN {node_alias}.kind = 'folder' THEN 0 \
8643            WHEN {node_alias}.kind = 'file' \
8644                AND {purpose_alias}.status = 'stale' \
8645                AND ({purpose_alias}.source IN ({stale_queue_sources}) OR {}) THEN 1 \
8646            WHEN {node_alias}.kind = 'file' AND {} THEN 2 \
8647            ELSE 3 \
8648        END, {node_alias}.path",
8649        high_impact_file_path_expression(&format!("lower({node_alias}.path)")),
8650        high_impact_file_path_expression(&format!("lower({node_alias}.path)"))
8651    )
8652}
8653
8654/// Purpose sources whose stale file purposes stay in the default queue regardless of path impact.
8655const STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES: &[&str] = &["human", "imported"];
8656
8657/// Return whether `source_only` should run before queue-specific folder/file selection.
8658fn source_filter_applies_before_queue(scope: HealthScope) -> bool {
8659    scope.source_only_filter() && !scope.high_impact_queue()
8660}
8661
8662/// Render trusted static strings as SQL string literals.
8663fn sql_string_literals(values: &[&str]) -> String {
8664    values
8665        .iter()
8666        .map(|value| format!("'{}'", value.replace('\'', "''")))
8667        .collect::<Vec<_>>()
8668        .join(", ")
8669}
8670
8671/// SQL expression mirroring the path-based high-impact file heuristic.
8672fn high_impact_file_path_expression(lower_path: &str) -> String {
8673    let name_matches = HIGH_IMPACT_FILE_NAMES
8674        .iter()
8675        .map(|name| format!("{lower_path} = '{name}' OR {lower_path} LIKE '%/{name}'"))
8676        .collect::<Vec<_>>()
8677        .join(" OR ");
8678    let prefix_matches = HIGH_IMPACT_PATH_PREFIXES
8679        .iter()
8680        .map(|prefix| format!("{lower_path} LIKE '{prefix}%'"))
8681        .collect::<Vec<_>>()
8682        .join(" OR ");
8683    let segment_matches = HIGH_IMPACT_PATH_SEGMENTS
8684        .iter()
8685        .map(|segment| format!("{lower_path} LIKE '%{segment}%'"))
8686        .collect::<Vec<_>>()
8687        .join(" OR ");
8688    format!("({name_matches} OR {prefix_matches} OR {segment_matches})")
8689}
8690
8691/// Return a SQL expression that treats source files and folders with source descendants as source-relevant.
8692fn source_relevant_node_expression(alias: &str) -> String {
8693    format!(
8694        "(({alias}.kind = 'file' AND COALESCE({alias}.language, '') <> '') \
8695          OR ({alias}.kind = 'folder' AND EXISTS (\
8696              SELECT 1 FROM nodes source_child \
8697              WHERE source_child.exists_now = 1 \
8698                AND source_child.kind = 'file' \
8699                AND COALESCE(source_child.language, '') <> '' \
8700                AND (\
8701                    {alias}.path = '.' \
8702                    OR source_child.parent_path = {alias}.path \
8703                    OR substr(source_child.parent_path, 1, length({alias}.path) + 1) = {alias}.path || '/'\
8704                )\
8705          )))"
8706    )
8707}
8708
8709/// Extract resolved primary paths for lifecycle categories without related paths.
8710fn resolved_purpose_paths(resolved_ids: &[String], category: &str) -> Vec<String> {
8711    let prefix = format!("{category}:");
8712    resolved_ids
8713        .iter()
8714        .filter_map(|id| {
8715            id.strip_prefix(&prefix)
8716                .and_then(|rest| rest.strip_suffix(':'))
8717                .filter(|path| !path.is_empty())
8718                .map(ToOwned::to_owned)
8719        })
8720        .collect()
8721}
8722
8723/// Extract resolved full ids for categories that include related paths.
8724fn resolved_ids_for_category(resolved_ids: &[String], category: &str) -> Vec<String> {
8725    let prefix = format!("{category}:");
8726    resolved_ids
8727        .iter()
8728        .filter(|id| id.starts_with(&prefix))
8729        .cloned()
8730        .collect()
8731}
8732
8733#[cfg(test)]
8734mod tests {
8735    use super::*;
8736    use projectatlas_core::telemetry::{
8737        READ_AVOIDANCE_CONFIDENCE_MODELED, READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED,
8738        READ_AVOIDANCE_CONFIDENCE_OBSERVED, TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
8739        TOKEN_ACCURACY_HEURISTIC, TOKEN_BASELINE_SELECTED_CANDIDATES,
8740        TOKEN_BUCKET_FULL_FILE_COMPRESSION, TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
8741        TOKEN_COMMAND_SEARCH, TOKEN_CONFIDENCE_INFERRED, TOKEN_DEDUPE_SCOPE_EVENT,
8742        usage_from_estimates, usage_from_estimates_with_accounting,
8743        usage_from_estimates_with_context, usage_from_text,
8744    };
8745    use projectatlas_core::{NodeKind, normalized_parent};
8746    use std::error::Error;
8747    use std::fmt::Debug;
8748    use std::fs;
8749    use std::io;
8750    use std::time::Instant;
8751
8752    #[test]
8753    fn unsupported_schema_versions_follow_the_migration_inventory() -> Result<(), Box<dyn Error>> {
8754        for found in schema::PREVIOUS_SCHEMA_VERSION..=schema::SCHEMA_VERSION {
8755            let error = DbError::SchemaVersion {
8756                found,
8757                expected: schema::SCHEMA_VERSION,
8758            };
8759            let expected_migration = if found < schema::SCHEMA_VERSION {
8760                Some((
8761                    found,
8762                    schema::SCHEMA_VERSION,
8763                    u32::try_from(schema::SCHEMA_VERSION - found)?,
8764                ))
8765            } else {
8766                None
8767            };
8768            require_eq(
8769                &error.supported_schema_migration(),
8770                &expected_migration,
8771                "admitted schema migration",
8772            )?;
8773            require_eq(
8774                &error.unsupported_schema_version(),
8775                &None,
8776                "admitted schema version",
8777            )?;
8778        }
8779        for found in [
8780            schema::PREVIOUS_SCHEMA_VERSION - 1,
8781            schema::SCHEMA_VERSION + 1,
8782        ] {
8783            let error = DbError::SchemaVersion {
8784                found,
8785                expected: schema::SCHEMA_VERSION,
8786            };
8787            require_eq(
8788                &error.unsupported_schema_version(),
8789                &Some((found, schema::SCHEMA_VERSION)),
8790                "unsupported schema version",
8791            )?;
8792            require_eq(
8793                &error.supported_schema_migration(),
8794                &None,
8795                "unsupported schema migration",
8796            )?;
8797        }
8798        require_eq(
8799            &DbError::SchemaVersionMissing.unsupported_schema_version(),
8800            &None,
8801            "unrelated database error",
8802        )?;
8803        require_eq(
8804            &DbError::SchemaVersionMissing.supported_schema_migration(),
8805            &None,
8806            "unrelated migration error",
8807        )?;
8808        Ok(())
8809    }
8810
8811    #[test]
8812    fn stores_nodes_and_overview() -> Result<(), Box<dyn Error>> {
8813        let mut store = AtlasStore::in_memory()?;
8814        let node = Node {
8815            path: "src/main.rs".to_string(),
8816            kind: NodeKind::File,
8817            parent_path: normalized_parent("src/main.rs"),
8818            extension: Some(".rs".to_string()),
8819            language: Some("rust".to_string()),
8820            size_bytes: Some(12),
8821            mtime_ns: Some(10),
8822            content_hash: Some("abc".to_string()),
8823        };
8824        store.replace_scan(&[node])?;
8825        let overview = store.overview()?;
8826        require_eq(&overview.files, &1, "file count")?;
8827        require_eq(&overview.missing_purposes, &1, "missing purpose count")?;
8828        let nodes = store.load_nodes()?;
8829        require_eq(
8830            &nodes[0].purpose.purpose,
8831            &None,
8832            "purpose remains separate from summary",
8833        )?;
8834        require_eq(
8835            &nodes[0].summary,
8836            &Some("rust file, 12 bytes".to_string()),
8837            "node-level summary",
8838        )?;
8839        let loaded = store
8840            .load_node_by_path("src/main.rs")?
8841            .ok_or_else(|| io::Error::other("indexed node was not found by path"))?;
8842        require_eq(
8843            &loaded.node.path,
8844            &"src/main.rs".to_string(),
8845            "targeted path lookup",
8846        )?;
8847        require_eq(
8848            &store.load_node_by_path("src/missing.rs")?.is_none(),
8849            &true,
8850            "missing targeted path lookup",
8851        )?;
8852        Ok(())
8853    }
8854
8855    #[test]
8856    fn source_node_cursor_omits_metadata_and_preserves_order_and_cancellation()
8857    -> Result<(), Box<dyn Error>> {
8858        let mut store = AtlasStore::in_memory()?;
8859        let expected = [
8860            test_file_node("src/a.rs", "a"),
8861            test_file_node("src/z.rs", "z"),
8862            test_file_node("src/é.rs", "unicode"),
8863        ];
8864        store.replace_scan(&expected)?;
8865        // Invalid unrelated authored state must not be read by source comparison.
8866        store
8867            .connection
8868            .execute("UPDATE purposes SET status = 'invalid'", [])?;
8869        let control = IndexWorkControl::new(projectatlas_core::IndexCancellation::new(), None);
8870        let mut visited = Vec::new();
8871        store.visit_source_nodes_controlled(&control, |node| {
8872            visited.push(node);
8873            Ok(())
8874        })?;
8875        require_eq(&visited, &expected.to_vec(), "ordered source-only rows")?;
8876        require(
8877            store.load_nodes().is_err(),
8878            "full rows still validate authored state",
8879        )?;
8880
8881        let cancellation = projectatlas_core::IndexCancellation::new();
8882        let control = IndexWorkControl::new(cancellation.clone(), None);
8883        let mut count = 0;
8884        let result = store.visit_source_nodes_controlled(&control, |_| {
8885            count += 1;
8886            cancellation.cancel();
8887            Ok(())
8888        });
8889        require_eq(&count, &1, "source rows consumed before cancellation")?;
8890        require(
8891            matches!(
8892                result,
8893                Err(DbError::IndexWork(IndexWorkFailure::Cancelled { .. }))
8894            ),
8895            "source cursor cancellation remains typed",
8896        )?;
8897        // Dropping the cancelled cursor also disarms its connection progress hook.
8898        store.visit_source_nodes_controlled(
8899            &IndexWorkControl::new(projectatlas_core::IndexCancellation::new(), None),
8900            |_| Ok(()),
8901        )?;
8902        Ok(())
8903    }
8904
8905    #[test]
8906    fn source_node_cursor_keeps_a_read_snapshot_across_writer_commit() -> Result<(), Box<dyn Error>>
8907    {
8908        let temp = tempfile::tempdir()?;
8909        let database = temp.path().join("projectatlas.db");
8910        let mut writer = AtlasStore::open(&database)?;
8911        let previous = test_file_node("lib.rs", "previous");
8912        writer.replace_scan(std::slice::from_ref(&previous))?;
8913        let reader = AtlasStore::open_read_only(&database)?;
8914        let control = IndexWorkControl::new(projectatlas_core::IndexCancellation::new(), None);
8915        let mut observed = Vec::new();
8916        reader.visit_source_nodes_controlled(&control, |node| {
8917            observed.push(node);
8918            Ok(())
8919        })?;
8920        writer.upsert_scan_nodes(&[test_file_node("lib.rs", "updated")])?;
8921        observed.clear();
8922        reader.visit_source_nodes_controlled(&control, |node| {
8923            observed.push(node);
8924            Ok(())
8925        })?;
8926        require_eq(
8927            &observed,
8928            &vec![previous],
8929            "cursor keeps captured source snapshot",
8930        )?;
8931        reader.finish_index_read_snapshot()?;
8932        observed.clear();
8933        let current = AtlasStore::open_read_only(&database)?;
8934        current.visit_source_nodes_controlled(&control, |node| {
8935            observed.push(node);
8936            Ok(())
8937        })?;
8938        require_eq(
8939            &observed,
8940            &vec![test_file_node("lib.rs", "updated")],
8941            "new snapshot sees committed source",
8942        )?;
8943        current.finish_index_read_snapshot()?;
8944        Ok(())
8945    }
8946
8947    #[test]
8948    fn source_node_cursor_rejects_corrupt_fields_and_uses_the_path_index()
8949    -> Result<(), Box<dyn Error>> {
8950        let mut store = AtlasStore::in_memory()?;
8951        store.replace_scan(&[test_file_node("src/a.rs", "a")])?;
8952        let plan = store
8953            .connection
8954            .prepare(
8955                "EXPLAIN QUERY PLAN SELECT n.path, n.kind, n.parent_path, n.extension, n.language,
8956                    n.size_bytes, n.mtime_ns, n.content_hash
8957             FROM nodes n JOIN purposes p ON p.node_id = n.id
8958             WHERE n.exists_now = 1 ORDER BY n.path",
8959            )?
8960            .query_map([], |row| row.get::<_, String>(3))?
8961            .collect::<Result<Vec<_>, _>>()?;
8962        require(
8963            plan.iter().any(|line| line.contains("USING INDEX"))
8964                && !plan.iter().any(|line| line.contains("TEMP B-TREE")),
8965            "source cursor streams path index order without a temporary sort",
8966        )?;
8967        store
8968            .connection
8969            .execute("UPDATE nodes SET size_bytes = -1", [])?;
8970        let control = IndexWorkControl::new(projectatlas_core::IndexCancellation::new(), None);
8971        let mut visited = 0;
8972        let result = store.visit_source_nodes_controlled(&control, |_| {
8973            visited += 1;
8974            Ok(())
8975        });
8976        require(result.is_err(), "invalid source integer is refused")?;
8977        require_eq(&visited, &0, "corrupt source row does not reach visitor")?;
8978        Ok(())
8979    }
8980
8981    #[test]
8982    fn load_nodes_rejects_negative_size_bytes_without_partial_page() -> Result<(), Box<dyn Error>> {
8983        let mut store = AtlasStore::in_memory()?;
8984        store.replace_scan(&[
8985            test_file_node("src/a.rs", "hash-a"),
8986            test_file_node("src/b.rs", "hash-b"),
8987        ])?;
8988        store.connection.execute(
8989            "UPDATE nodes SET size_bytes = -1 WHERE path = 'src/b.rs'",
8990            [],
8991        )?;
8992
8993        let Err(error) = store.load_nodes() else {
8994            return Err(io::Error::other("negative node size returned a partial page").into());
8995        };
8996        require(
8997            matches!(
8998                error,
8999                DbError::Sqlite(rusqlite::Error::FromSqlConversionFailure(
9000                    5,
9001                    rusqlite::types::Type::Integer,
9002                    _
9003                ))
9004            ),
9005            "negative node size returned the wrong conversion error",
9006        )?;
9007        Ok(())
9008    }
9009
9010    #[test]
9011    fn existing_project_open_refuses_fresh_storage_and_preserves_supported_state()
9012    -> Result<(), Box<dyn Error>> {
9013        let temp = tempfile::tempdir()?;
9014        let root = temp.path();
9015        let database = root.join("existing.db");
9016        require(
9017            matches!(
9018                AtlasStore::open_existing_for_project(&database, root),
9019                Err(DbError::InitializationRequired)
9020            ),
9021            "missing database was admitted",
9022        )?;
9023        require(
9024            !database.exists(),
9025            "existing open created an absent database",
9026        )?;
9027        fs::write(&database, [])?;
9028        require(
9029            matches!(
9030                AtlasStore::open_existing_for_project(&database, root),
9031                Err(DbError::InitializationRequired)
9032            ),
9033            "empty database was admitted",
9034        )?;
9035        require_eq(
9036            &fs::read(&database)?,
9037            &Vec::<u8>::new(),
9038            "empty database bytes unchanged",
9039        )?;
9040        require_eq(
9041            &fs::read_dir(root)?.count(),
9042            &1,
9043            "no fresh journal or sidecar",
9044        )?;
9045        {
9046            let connection = Connection::open(&database)?;
9047            require(
9048                matches!(
9049                    schema::migrate_existing_with_project_root(&connection, None, None),
9050                    Err(DbError::InitializationRequired)
9051                ),
9052                "migration admitted fresh replacement",
9053            )?;
9054            require_eq(
9055                &connection.query_row(
9056                    "SELECT count(*) FROM sqlite_schema WHERE type = 'table'",
9057                    [],
9058                    |row| row.get::<_, i64>(0),
9059                )?,
9060                &0,
9061                "migration did not create a schema",
9062            )?;
9063        }
9064        let identity = {
9065            let mut store = AtlasStore::open_for_project(&database, root)?;
9066            store.replace_scan(&[test_file_node("lib.rs", "source-hash")])?;
9067            store.set_purpose("lib.rs", "Preserve authored state", PurposeSource::Agent)?;
9068            store.project_instance_id()?
9069        };
9070        let current = AtlasStore::open_existing_for_project(&database, root)?;
9071        require_eq(
9072            &current.project_instance_id()?,
9073            &identity,
9074            "existing project identity retained",
9075        )?;
9076        require_eq(
9077            &current
9078                .load_node_by_path("lib.rs")?
9079                .and_then(|node| node.purpose.purpose),
9080            &Some("Preserve authored state".to_string()),
9081            "existing purpose retained",
9082        )?;
9083        schema::drop_worktree_native_identity_schema(&current.connection)?;
9084        current.connection.execute_batch(
9085            "DROP TABLE graph_identity_rejections;
9086             UPDATE metadata SET value = '20' WHERE key = 'schema_version';",
9087        )?;
9088        drop(current);
9089        let upgraded = AtlasStore::open_existing_for_project(&database, root)?;
9090        require_eq(
9091            &upgraded
9092                .load_node_by_path("lib.rs")?
9093                .and_then(|node| node.purpose.purpose),
9094            &Some("Preserve authored state".to_string()),
9095            "supported migration preserved purpose",
9096        )?;
9097        require_eq(
9098            &schema::preflight(&database, None)?.0.state,
9099            &SchemaState::Current,
9100            "supported migration completed",
9101        )?;
9102        Ok(())
9103    }
9104
9105    #[test]
9106    fn validated_existing_database_paths_are_never_recreated() -> Result<(), Box<dyn Error>> {
9107        let temp = tempfile::tempdir()?;
9108
9109        let current_path = temp.path().join("current.db");
9110        drop(AtlasStore::open(&current_path)?);
9111        let (current, current_location) = schema::preflight(&current_path, None)?;
9112        require_eq(
9113            &current.state,
9114            &SchemaState::Current,
9115            "current preflight state",
9116        )?;
9117        fs::remove_file(&current_path)?;
9118        if Connection::open_with_flags(
9119            &current_path,
9120            writable_open_flags(current.state, current_location.database_exists),
9121        )
9122        .is_ok()
9123        {
9124            return Err(io::Error::other("current database path was recreated").into());
9125        }
9126        require_eq(&current_path.exists(), &false, "current path stays absent")?;
9127
9128        let released_path = temp.path().join("released.db");
9129        let released_root = temp.path().join("released-root");
9130        write_released_schema_eight_compatibility_fixture(&released_path, &released_root)?;
9131        let (released, released_location) = schema::preflight(&released_path, None)?;
9132        require_eq(
9133            &released.state,
9134            &SchemaState::UpgradeRequired,
9135            "released preflight state",
9136        )?;
9137        fs::remove_file(&released_path)?;
9138        if Connection::open_with_flags(
9139            &released_path,
9140            writable_open_flags(released.state, released_location.database_exists),
9141        )
9142        .is_ok()
9143        {
9144            return Err(io::Error::other("released database path was recreated").into());
9145        }
9146        require_eq(
9147            &released_path.exists(),
9148            &false,
9149            "released path stays absent",
9150        )?;
9151
9152        let fresh_path = temp.path().join("fresh.db");
9153        let (fresh, _) = schema::preflight(&fresh_path, None)?;
9154        require_eq(&fresh.state, &SchemaState::Fresh, "fresh preflight state")?;
9155        drop(AtlasStore::open(&fresh_path)?);
9156        require_eq(&fresh_path.is_file(), &true, "fresh path is created")?;
9157        drop(AtlasStore::open_read_only(&fresh_path)?);
9158        Ok(())
9159    }
9160
9161    #[cfg(windows)]
9162    #[test]
9163    fn released_schema_layouts_upgrade_without_losing_local_state() -> Result<(), Box<dyn Error>> {
9164        let layouts: [(&str, fn(&Path, &Path) -> Result<(), Box<dyn Error>>); 2] = [
9165            (
9166                "fresh-v0.3.26",
9167                write_released_schema_eight_compatibility_fixture,
9168            ),
9169            (
9170                "evolved-v0.3.11-to-v0.3.26",
9171                write_evolved_released_schema_eight_compatibility_fixture,
9172            ),
9173        ];
9174        let mut previous_binding = None;
9175        for (label, write_fixture) in layouts {
9176            let binding =
9177                assert_released_schema_upgrade_preserves_local_state(label, write_fixture)?;
9178            if previous_binding
9179                .as_ref()
9180                .is_some_and(|previous| previous == &binding)
9181            {
9182                return Err(io::Error::other(
9183                    "independent released-schema databases shared one project binding",
9184                )
9185                .into());
9186            }
9187            previous_binding = Some(binding);
9188        }
9189        Ok(())
9190    }
9191
9192    /// Verify one released schema layout through migration, reopen, and publication.
9193    #[cfg(windows)]
9194    fn assert_released_schema_upgrade_preserves_local_state(
9195        label: &str,
9196        write_fixture: fn(&Path, &Path) -> Result<(), Box<dyn Error>>,
9197    ) -> Result<CapturedProjectBinding, Box<dyn Error>> {
9198        let temp = tempfile::tempdir()?;
9199        let db_path = temp.path().join("projectatlas.db");
9200        let root = temp.path().join("repository");
9201        write_fixture(&db_path, &root)?;
9202        fs::create_dir_all(&root)?;
9203        let database_before_read = fs::read(&db_path)?;
9204
9205        let Err(read_error) = AtlasStore::open_read_only(&db_path) else {
9206            return Err(io::Error::other("schema-8 read-only open unexpectedly succeeded").into());
9207        };
9208        require_eq(
9209            &matches!(
9210                read_error,
9211                DbError::SchemaVersion {
9212                    found: PREVIOUS_SCHEMA_VERSION,
9213                    expected: SCHEMA_VERSION,
9214                }
9215            ),
9216            &true,
9217            "schema-8 read-only rejection",
9218        )?;
9219        require_eq(
9220            &fs::read(&db_path)?,
9221            &database_before_read,
9222            "read-only rejection leaves database unchanged",
9223        )?;
9224
9225        let store = AtlasStore::open(&db_path)?;
9226        let stored_schema = store.connection.query_row(
9227            "SELECT value FROM metadata WHERE key = ?1",
9228            [SCHEMA_VERSION_KEY],
9229            |row| row.get::<_, String>(0),
9230        )?;
9231        require_eq(
9232            &stored_schema,
9233            &SCHEMA_VERSION.to_string(),
9234            "upgraded schema version",
9235        )?;
9236        let migrated_binding = store.captured_project_binding()?;
9237        drop(store);
9238        schema::verify_current_integrity(&db_path, Some(&normalize_native_path_display(&root)))?;
9239        let mut store = AtlasStore::open_for_project(&db_path, &root)?;
9240        require_eq(
9241            &store.captured_project_binding()?,
9242            &migrated_binding,
9243            &format!("{label} identity survives reopen"),
9244        )?;
9245        require_eq(
9246            &store.project_root()?,
9247            &Some(normalize_native_path_display(&root)),
9248            "upgraded project root",
9249        )?;
9250        let node = store
9251            .load_node_by_path("src/lib.rs")?
9252            .ok_or_else(|| io::Error::other("upgraded source node missing"))?;
9253        require_eq(
9254            &node.node.content_hash,
9255            &Some("hash-legacy".to_string()),
9256            "upgraded source row",
9257        )?;
9258        require_eq(
9259            &node.purpose.purpose,
9260            &Some("Schema compatibility source".to_string()),
9261            "upgraded purpose text",
9262        )?;
9263        require_eq(
9264            &node.purpose.source,
9265            &PurposeSource::Agent,
9266            "upgraded purpose source",
9267        )?;
9268        require_eq(
9269            &node.purpose.status,
9270            &PurposeStatus::Approved,
9271            "upgraded purpose review state",
9272        )?;
9273        require_eq(
9274            &store.resolved_health_ids()?,
9275            &vec!["schema-review".to_string()],
9276            "upgraded authored review",
9277        )?;
9278        let custom_setting = store.connection.query_row(
9279            "SELECT value FROM metadata WHERE key = 'custom_setting'",
9280            [],
9281            |row| row.get::<_, String>(0),
9282        )?;
9283        require_eq(
9284            &custom_setting,
9285            &"preserved".to_string(),
9286            "upgraded compatible metadata",
9287        )?;
9288        let telemetry = store.token_overview(Some("schema-session"))?;
9289        require_eq(&telemetry.calls, &1, "upgraded telemetry call count")?;
9290        require_eq(
9291            &telemetry.estimated_saved,
9292            &80,
9293            "upgraded telemetry savings",
9294        )?;
9295        require_eq(
9296            &store.index_publication()?,
9297            &None,
9298            "untrusted publication metadata invalidated",
9299        )?;
9300        let remaining_publication_keys = store.connection.query_row(
9301            "SELECT COUNT(*) FROM metadata WHERE key IN (?1, ?2, ?3)",
9302            params![
9303                INDEX_PUBLICATION_STATE_KEY,
9304                INDEX_PUBLICATION_FINGERPRINT_KEY,
9305                INDEX_PUBLICATION_GENERATION_KEY,
9306            ],
9307            |row| row.get::<_, i64>(0),
9308        )?;
9309        require_eq(
9310            &remaining_publication_keys,
9311            &0,
9312            "all untrusted publication keys removed",
9313        )?;
9314
9315        {
9316            let mut publication = store.begin_index_publication("schema-9-contract")?;
9317            write_test_projection(&mut publication, "fresh")?;
9318            publication.complete()?;
9319        }
9320        require_test_projection(&store, 1, "fresh")?;
9321        require_eq(
9322            &store
9323                .index_publication()?
9324                .and_then(|publication| publication.contract_fingerprint),
9325            &Some("schema-9-contract".to_string()),
9326            &format!("{label} fresh publication contract"),
9327        )?;
9328        Ok(migrated_binding)
9329    }
9330
9331    #[test]
9332    fn future_schema_rejection_preserves_source_and_authored_rows() -> Result<(), Box<dyn Error>> {
9333        let temp = tempfile::tempdir()?;
9334        let db_path = temp.path().join("projectatlas.db");
9335        let root = temp.path().join("repository");
9336        fs::create_dir(&root)?;
9337        let future_schema = SCHEMA_VERSION + 1;
9338        write_schema_compatibility_fixture(&db_path, &root, future_schema, "future")?;
9339        let database_before = fs::read(&db_path)?;
9340        let Err(open_error) = AtlasStore::open(&db_path) else {
9341            return Err(
9342                io::Error::other("future schema writable open unexpectedly succeeded").into(),
9343            );
9344        };
9345        require_eq(
9346            &matches!(
9347                open_error,
9348                DbError::SchemaVersion {
9349                    found,
9350                    expected: SCHEMA_VERSION,
9351                } if found == future_schema
9352            ),
9353            &true,
9354            "future schema rejection",
9355        )?;
9356        require_eq(
9357            &fs::read(&db_path)?,
9358            &database_before,
9359            "future schema bytes remain unchanged",
9360        )?;
9361        let connection = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
9362        let stored_schema = connection.query_row(
9363            "SELECT value FROM metadata WHERE key = ?1",
9364            [SCHEMA_VERSION_KEY],
9365            |row| row.get::<_, String>(0),
9366        )?;
9367        require_eq(
9368            &stored_schema,
9369            &future_schema.to_string(),
9370            "future schema remains unchanged",
9371        )?;
9372        let stored_root = connection.query_row(
9373            "SELECT value FROM metadata WHERE key = 'project_root'",
9374            [],
9375            |row| row.get::<_, String>(0),
9376        )?;
9377        require_eq(
9378            &stored_root,
9379            &normalize_native_path_display(&root),
9380            "future project root remains unchanged",
9381        )?;
9382        let source_state = connection.query_row(
9383            "
9384            SELECT n.content_hash, p.purpose, p.source, p.status
9385            FROM nodes AS n
9386            JOIN purposes AS p ON p.node_id = n.id
9387            WHERE n.path = 'src/lib.rs'
9388            ",
9389            [],
9390            |row| {
9391                Ok((
9392                    row.get::<_, Option<String>>(0)?,
9393                    row.get::<_, Option<String>>(1)?,
9394                    row.get::<_, String>(2)?,
9395                    row.get::<_, String>(3)?,
9396                ))
9397            },
9398        )?;
9399        require_eq(
9400            &source_state,
9401            &(
9402                Some("hash-future".to_string()),
9403                Some("Schema compatibility source".to_string()),
9404                PurposeSource::Agent.to_string(),
9405                PurposeStatus::Approved.as_str().to_string(),
9406            ),
9407            "future source and purpose rows remain unchanged",
9408        )?;
9409        let review_rationale = connection.query_row(
9410            "SELECT rationale FROM health_resolutions WHERE finding_id = 'schema-review'",
9411            [],
9412            |row| row.get::<_, String>(0),
9413        )?;
9414        require_eq(
9415            &review_rationale,
9416            &"Reviewed schema fixture".to_string(),
9417            "future authored review remains unchanged",
9418        )?;
9419        let telemetry_state = connection.query_row(
9420            "
9421            SELECT COUNT(*), SUM(e.estimated_tokens_saved)
9422            FROM usage_events AS e
9423            JOIN usage_instances AS i USING(instance_row_id)
9424            WHERE i.caller_label = 'schema-session'
9425            ",
9426            [],
9427            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<i64>>(1)?)),
9428        )?;
9429        require_eq(
9430            &telemetry_state,
9431            &(1, Some(80)),
9432            "future telemetry remains unchanged",
9433        )?;
9434        Ok(())
9435    }
9436
9437    #[test]
9438    fn projection_refresh_cannot_replace_publication_contract() -> Result<(), Box<dyn Error>> {
9439        let mut store = AtlasStore::in_memory()?;
9440        store.begin_index_publication("contract-a")?.complete()?;
9441
9442        store
9443            .begin_index_projection_refresh("contract-a")?
9444            .complete()?;
9445        require_eq(
9446            &store.index_publication()?,
9447            &Some(IndexPublication {
9448                state: IndexPublicationState::Complete,
9449                contract_fingerprint: Some("contract-a".to_string()),
9450                generation: IndexGeneration::new(2),
9451            }),
9452            "matching projection refresh",
9453        )?;
9454
9455        let publication = store.begin_index_projection_refresh("contract-a")?;
9456        set_metadata(
9457            &publication.connection,
9458            INDEX_PUBLICATION_FINGERPRINT_KEY,
9459            "contract-b",
9460        )?;
9461        let Err(mismatch) = publication.complete() else {
9462            return Err(io::Error::other(
9463                "projection refresh replaced the global publication contract",
9464            )
9465            .into());
9466        };
9467        if !matches!(mismatch, DbError::PublicationContractChanged) {
9468            return Err(io::Error::other(format!(
9469                "unexpected projection refresh mismatch: {mismatch}"
9470            ))
9471            .into());
9472        }
9473        require_eq(
9474            &store.index_publication()?,
9475            &Some(IndexPublication {
9476                state: IndexPublicationState::Complete,
9477                contract_fingerprint: Some("contract-a".to_string()),
9478                generation: IndexGeneration::new(2),
9479            }),
9480            "mismatched projection refresh rolls back",
9481        )?;
9482        Ok(())
9483    }
9484
9485    #[test]
9486    fn read_snapshots_expose_only_complete_publications() -> Result<(), Box<dyn Error>> {
9487        let temp = tempfile::tempdir()?;
9488        let db_path = temp.path().join("projectatlas.db");
9489        let independent_db_path = temp.path().join("independent.db");
9490        let mut writer_a = AtlasStore::open(&db_path)?;
9491        require_writable_connection_profile(&writer_a.connection)?;
9492        {
9493            let mut publication = writer_a.begin_index_publication("contract")?;
9494            write_test_projection(&mut publication, "old")?;
9495            publication.complete()?;
9496        }
9497        let mut writer_b = AtlasStore::open(&db_path)?;
9498        let mut independent_writer = AtlasStore::open(&independent_db_path)?;
9499        let old_reader = AtlasStore::open_read_only(&db_path)?;
9500        require_read_connection_profile(&old_reader.connection)?;
9501        if old_reader
9502            .connection
9503            .execute("DELETE FROM metadata", [])
9504            .is_ok()
9505        {
9506            return Err(io::Error::other("read-only connection accepted a mutation").into());
9507        }
9508        require_test_projection(&old_reader, 1, "old")?;
9509
9510        {
9511            let mut publication = writer_a.begin_index_publication("contract")?;
9512            write_test_projection(&mut publication, "new")?;
9513            require_test_projection(&old_reader, 1, "old")?;
9514
9515            let probe_started = std::time::Instant::now();
9516            let Err(probe_contention) = writer_b.probe_index_publication_writer() else {
9517                return Err(io::Error::other(
9518                    "writer availability probe entered an active publication transaction",
9519                )
9520                .into());
9521            };
9522            require_eq(
9523                &matches!(
9524                    probe_contention,
9525                    DbError::Sqlite(ref error)
9526                        if matches!(
9527                            error.sqlite_error_code(),
9528                            Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
9529                        )
9530                ),
9531                &true,
9532                "same-database writer availability probe",
9533            )?;
9534            require_eq(
9535                &(probe_started.elapsed() < Duration::from_secs(2)),
9536                &true,
9537                "fail-fast same-database writer availability probe",
9538            )?;
9539
9540            let started = std::time::Instant::now();
9541            let Err(contention) = writer_b.begin_index_publication("contract") else {
9542                return Err(io::Error::other(
9543                    "second writer entered an active publication transaction",
9544                )
9545                .into());
9546            };
9547            require_eq(
9548                &matches!(
9549                    contention,
9550                    DbError::Sqlite(ref error)
9551                        if matches!(
9552                            error.sqlite_error_code(),
9553                            Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
9554                        )
9555                ),
9556                &true,
9557                "same-database writer contention",
9558            )?;
9559            require_eq(
9560                &(started.elapsed() < Duration::from_secs(2)),
9561                &true,
9562                "fail-fast same-database writer acquisition",
9563            )?;
9564            let restored_busy_timeout =
9565                writer_b
9566                    .connection
9567                    .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, i64>(0))?;
9568            require_eq(
9569                &u128::try_from(restored_busy_timeout)?,
9570                &SQLITE_BUSY_TIMEOUT.as_millis(),
9571                "ordinary busy timeout after failed publication acquisition",
9572            )?;
9573
9574            independent_writer
9575                .begin_index_publication("independent-contract")?
9576                .complete()?;
9577            require_eq(
9578                &independent_writer
9579                    .index_publication()?
9580                    .ok_or_else(|| io::Error::other("independent publication missing"))?
9581                    .generation,
9582                &IndexGeneration::new(1),
9583                "independent database generation",
9584            )?;
9585
9586            drop(publication);
9587        }
9588        let rolled_back_reader = AtlasStore::open_read_only(&db_path)?;
9589        require_test_projection(&rolled_back_reader, 1, "old")?;
9590        rolled_back_reader.finish_index_read_snapshot()?;
9591
9592        {
9593            let mut publication = writer_a.begin_index_publication("contract")?;
9594            write_test_projection(&mut publication, "new")?;
9595            publication.complete()?;
9596        }
9597        let restored_busy_timeout =
9598            writer_a
9599                .connection
9600                .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, i64>(0))?;
9601        require_eq(
9602            &u128::try_from(restored_busy_timeout)?,
9603            &SQLITE_BUSY_TIMEOUT.as_millis(),
9604            "ordinary busy timeout after successful publication acquisition",
9605        )?;
9606        require_test_projection(&old_reader, 1, "old")?;
9607        let new_reader = AtlasStore::open_read_only(&db_path)?;
9608        require_test_projection(&new_reader, 2, "new")?;
9609        new_reader.finish_index_read_snapshot()?;
9610        old_reader.finish_index_read_snapshot()?;
9611        Ok(())
9612    }
9613
9614    #[test]
9615    fn authored_write_waits_for_the_existing_writer_before_validation() -> Result<(), Box<dyn Error>>
9616    {
9617        let temp = tempfile::tempdir()?;
9618        let root = temp.path().join("repository");
9619        fs::create_dir_all(&root)?;
9620        let db_path = temp.path().join("projectatlas.db");
9621        let mut store = AtlasStore::open_for_project(&db_path, &root)?;
9622        store.replace_scan(&[test_file_node("src/lib.rs", "initial")])?;
9623
9624        let blocking_writer = Connection::open(&db_path)?;
9625        blocking_writer.busy_timeout(SQLITE_BUSY_TIMEOUT)?;
9626        blocking_writer.execute_batch("BEGIN IMMEDIATE")?;
9627        let release = std::thread::spawn(move || {
9628            std::thread::sleep(Duration::from_millis(1_250));
9629            blocking_writer.execute_batch("ROLLBACK")
9630        });
9631
9632        let started = Instant::now();
9633        store.set_purpose(
9634            "src/lib.rs",
9635            "Own the library source.",
9636            PurposeSource::Agent,
9637        )?;
9638        let elapsed = started.elapsed();
9639        release
9640            .join()
9641            .map_err(|_panic| io::Error::other("blocking writer thread panicked"))??;
9642
9643        require_eq(
9644            &(elapsed >= Duration::from_secs(1)),
9645            &true,
9646            "authored write waited for the existing writer",
9647        )?;
9648        require_eq(
9649            &(elapsed < SQLITE_BUSY_TIMEOUT),
9650            &true,
9651            "authored write stayed within the ordinary busy timeout",
9652        )?;
9653        require_eq(
9654            &store
9655                .load_node_by_path("src/lib.rs")?
9656                .ok_or_else(|| io::Error::other("purpose node missing"))?
9657                .purpose
9658                .purpose,
9659            &Some("Own the library source.".to_string()),
9660            "purpose after bounded writer contention",
9661        )
9662    }
9663
9664    #[test]
9665    fn writable_mutators_reject_an_active_read_snapshot() -> Result<(), Box<dyn Error>> {
9666        let temp = tempfile::tempdir()?;
9667        let db_path = temp.path().join("projectatlas.db");
9668        let mut store = AtlasStore::open(&db_path)?;
9669        store.replace_scan(&[test_file_node("src/lib.rs", "initial")])?;
9670        store.begin_index_read_snapshot()?;
9671
9672        let Err(purpose_error) = store.set_purpose(
9673            "src/lib.rs",
9674            "Must not be written through a read snapshot.",
9675            PurposeSource::Agent,
9676        ) else {
9677            return Err(io::Error::other("purpose wrote through a read snapshot").into());
9678        };
9679        require_eq(
9680            &matches!(purpose_error, DbError::IndexReadSnapshotActive),
9681            &true,
9682            "read-snapshot purpose rejection",
9683        )?;
9684
9685        let Err(scan_error) = store.replace_scan(&[]) else {
9686            return Err(io::Error::other("scan wrote through a read snapshot").into());
9687        };
9688        require_eq(
9689            &matches!(scan_error, DbError::IndexReadSnapshotActive),
9690            &true,
9691            "read-snapshot scan rejection",
9692        )?;
9693
9694        store.finish_index_read_snapshot()?;
9695        require_eq(
9696            &store.load_node_by_path("src/lib.rs")?.is_some(),
9697            &true,
9698            "read-snapshot rejected writes preserved indexed state",
9699        )?;
9700        Ok(())
9701    }
9702
9703    #[test]
9704    fn stale_publication_base_is_rejected_before_batch_mutation() -> Result<(), Box<dyn Error>> {
9705        let temp = tempfile::tempdir()?;
9706        let db_path = temp.path().join("projectatlas.db");
9707        let mut writer_a = AtlasStore::open(&db_path)?;
9708        {
9709            let mut publication =
9710                writer_a.begin_index_publication_from("contract", IndexGeneration::ZERO)?;
9711            write_test_projection(&mut publication, "base")?;
9712            publication.complete()?;
9713        }
9714        let prepared_base = writer_a
9715            .index_publication()?
9716            .ok_or_else(|| io::Error::other("prepared base publication missing"))?
9717            .generation;
9718
9719        let incomplete_result = {
9720            let mut publication =
9721                writer_a.begin_index_publication_from("contract", prepared_base)?;
9722            publication.begin_scan_replacement()?;
9723            publication.complete()
9724        };
9725        if !matches!(incomplete_result, Err(DbError::ScanReplacementIncomplete)) {
9726            return Err(io::Error::other(
9727                "unfinished scan replacement was allowed to complete publication",
9728            )
9729            .into());
9730        }
9731        require_test_projection(&writer_a, 1, "base")?;
9732
9733        let mut writer_b = AtlasStore::open(&db_path)?;
9734        {
9735            let mut publication = writer_b.begin_index_publication("contract")?;
9736            write_test_projection(&mut publication, "winner")?;
9737            publication.complete()?;
9738        }
9739
9740        let Err(conflict) = writer_a.begin_index_publication_from("contract", prepared_base) else {
9741            return Err(io::Error::other("stale prepared publication was accepted").into());
9742        };
9743        require_eq(
9744            &matches!(
9745                conflict,
9746                DbError::PublicationBaseGenerationChanged { expected, found }
9747                    if expected == IndexGeneration::new(1)
9748                        && found == IndexGeneration::new(2)
9749            ),
9750            &true,
9751            "stale publication base conflict",
9752        )?;
9753        require_test_projection(&writer_a, 2, "winner")?;
9754        require_eq(
9755            &writer_a.load_symbols(Some("src/lib.rs"), None, 10)?.len(),
9756            &1,
9757            "rejected batch symbol row count",
9758        )?;
9759        require_eq(
9760            &writer_a
9761                .load_symbol_relations(Some("src/lib.rs"), None, 10)?
9762                .len(),
9763            &1,
9764            "rejected batch relation row count",
9765        )?;
9766
9767        let Err(zero_conflict) =
9768            writer_a.begin_index_publication_from("contract", IndexGeneration::ZERO)
9769        else {
9770            return Err(io::Error::other("zero base matched an initialized store").into());
9771        };
9772        require_eq(
9773            &matches!(
9774                zero_conflict,
9775                DbError::PublicationBaseGenerationChanged { expected, found }
9776                    if expected == IndexGeneration::ZERO
9777                        && found == IndexGeneration::new(2)
9778            ),
9779            &true,
9780            "zero publication base conflict",
9781        )?;
9782        require_test_projection(&writer_a, 2, "winner")?;
9783        Ok(())
9784    }
9785
9786    #[test]
9787    fn records_token_overview() -> Result<(), Box<dyn Error>> {
9788        let project = tempfile::tempdir()?;
9789        let mut store = AtlasStore::in_memory()?;
9790        store.set_project_root(project.path())?;
9791        let mut session_event = usage_from_estimates(
9792            "session",
9793            "outline",
9794            Some("src/main.rs".to_string()),
9795            None,
9796            100,
9797            20,
9798        );
9799        session_event.estimated_tokens_saved = Some(1);
9800        store.record_usage(&session_event)?;
9801        let mut unknown_event = usage_from_estimates("session", "unknown", None, None, 0, 0);
9802        unknown_event.estimated_tokens_without_projectatlas = None;
9803        unknown_event.estimated_tokens_with_projectatlas = None;
9804        unknown_event.estimated_tokens_saved = None;
9805        store.record_usage(&unknown_event)?;
9806        store.record_usage(&usage_from_estimates(
9807            "other-session",
9808            "outline",
9809            Some("src/lib.rs".to_string()),
9810            None,
9811            200,
9812            50,
9813        ))?;
9814        let overview = store.token_overview(Some("session"))?;
9815        require_eq(&overview.calls, &1, "usage call count")?;
9816        require_eq(&overview.estimated_saved, &80, "saved token count")?;
9817        require_eq(&overview.buckets.len(), &1, "usage bucket count")?;
9818        require_eq(
9819            &overview.buckets[0].accuracy,
9820            &TOKEN_ACCURACY_HEURISTIC.to_string(),
9821            "usage bucket accuracy",
9822        )?;
9823        let all_sessions = store.token_overview(None)?;
9824        require_eq(&all_sessions.calls, &2, "all-session usage call count")?;
9825        require_eq(
9826            &all_sessions.estimated_without_projectatlas,
9827            &300,
9828            "all-session baseline tokens",
9829        )?;
9830        require_eq(
9831            &all_sessions.estimated_with_projectatlas,
9832            &70,
9833            "all-session atlas tokens",
9834        )?;
9835        require_eq(
9836            &all_sessions.estimated_saved,
9837            &230,
9838            "all-session saved tokens",
9839        )?;
9840        require_eq(
9841            &all_sessions.likely_file_reads_avoided,
9842            &0,
9843            "non-search estimate events do not count as avoided file reads",
9844        )?;
9845        require_eq(
9846            &all_sessions.read_avoidance_confidence,
9847            &READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED.to_string(),
9848            "non-search estimate read avoidance confidence",
9849        )?;
9850
9851        store.record_usage(&usage_from_text(
9852            "bucketed",
9853            "summary",
9854            Some("src/main.rs".to_string()),
9855            None,
9856            "abcdefghijkl",
9857            "abcd",
9858        ))?;
9859        store.record_usage(&usage_from_estimates(
9860            "bucketed", "folders", None, None, 100, 20,
9861        ))?;
9862        let bucketed = store.token_overview(Some("bucketed"))?;
9863        require_eq(&bucketed.buckets.len(), &2, "bucketed overview count")?;
9864        require_eq(
9865            &bucketed.buckets[0].token_savings_bucket,
9866            &TOKEN_BUCKET_FULL_FILE_COMPRESSION.to_string(),
9867            "source compression bucket",
9868        )?;
9869        require_eq(
9870            &bucketed.buckets[1].token_savings_bucket,
9871            &TOKEN_BUCKET_NAVIGATION_AVOIDANCE.to_string(),
9872            "navigation bucket",
9873        )?;
9874        require_eq(
9875            &bucketed.observed_file_read_replacements,
9876            &1,
9877            "observed read replacement count",
9878        )?;
9879        require_eq(
9880            &bucketed.modeled_file_reads_avoided,
9881            &0,
9882            "folder navigation does not count as modeled file-read avoidance",
9883        )?;
9884        require_eq(
9885            &bucketed.likely_file_reads_avoided,
9886            &1,
9887            "observed-only likely file reads avoided",
9888        )?;
9889        require_eq(
9890            &bucketed.read_avoidance_confidence,
9891            &READ_AVOIDANCE_CONFIDENCE_OBSERVED.to_string(),
9892            "observed-only read avoidance confidence",
9893        )?;
9894
9895        store.record_usage(&usage_from_text(
9896            "deduped",
9897            "summary",
9898            Some("src/lib.rs".to_string()),
9899            None,
9900            "abcdabcd",
9901            "ab",
9902        ))?;
9903        store.record_usage(&usage_from_estimates(
9904            "deduped",
9905            TOKEN_COMMAND_SEARCH,
9906            None,
9907            Some("token".to_string()),
9908            400,
9909            40,
9910        ))?;
9911        store.record_usage(&usage_from_estimates(
9912            "deduped",
9913            TOKEN_COMMAND_SEARCH,
9914            None,
9915            Some("token".to_string()),
9916            400,
9917            30,
9918        ))?;
9919        let deduped = store.token_overview(Some("deduped"))?;
9920        require_eq(
9921            &deduped.legacy_gross_estimated_saved,
9922            &731,
9923            "legacy gross saved tokens remains available",
9924        )?;
9925        require_eq(
9926            &deduped.measured_tokens_saved,
9927            &1,
9928            "measured saved tokens remain separate",
9929        )?;
9930        require_eq(
9931            &deduped.gross_modeled_tokens_avoided,
9932            &730,
9933            "gross modeled avoided tokens remains available",
9934        )?;
9935        require_eq(
9936            &deduped.deduped_modeled_tokens_avoided,
9937            &330,
9938            "modeled avoided tokens are deduped by baseline",
9939        )?;
9940        require_eq(
9941            &deduped.tokens_avoided,
9942            &331,
9943            "headline avoided tokens use measured plus deduped modeled",
9944        )?;
9945        require_eq(
9946            &deduped.observed_file_read_replacements,
9947            &1,
9948            "deduped observed read replacements",
9949        )?;
9950        require_eq(
9951            &deduped.modeled_file_reads_avoided,
9952            &2,
9953            "deduped raw search events remain likely file reads avoided",
9954        )?;
9955        require_eq(
9956            &deduped.likely_file_reads_avoided,
9957            &3,
9958            "deduped likely file reads avoided",
9959        )?;
9960        require_eq(
9961            &deduped.read_avoidance_confidence,
9962            &READ_AVOIDANCE_CONFIDENCE_MODELED.to_string(),
9963            "deduped read avoidance confidence",
9964        )?;
9965
9966        store.record_usage(&usage_from_estimates_with_accounting(
9967            "event-scoped",
9968            "folders",
9969            None,
9970            Some("token".to_string()),
9971            400,
9972            40,
9973            TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
9974            TOKEN_BASELINE_SELECTED_CANDIDATES,
9975            TOKEN_CONFIDENCE_INFERRED,
9976            TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
9977            TOKEN_BASELINE_SELECTED_CANDIDATES,
9978            TOKEN_DEDUPE_SCOPE_EVENT,
9979        ))?;
9980        store.record_usage(&usage_from_estimates_with_accounting(
9981            "event-scoped",
9982            "folders",
9983            None,
9984            Some("token".to_string()),
9985            400,
9986            30,
9987            TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
9988            TOKEN_BASELINE_SELECTED_CANDIDATES,
9989            TOKEN_CONFIDENCE_INFERRED,
9990            TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
9991            TOKEN_BASELINE_SELECTED_CANDIDATES,
9992            TOKEN_DEDUPE_SCOPE_EVENT,
9993        ))?;
9994        let event_scoped = store.token_overview(Some("event-scoped"))?;
9995        require_eq(
9996            &event_scoped.gross_modeled_tokens_avoided,
9997            &730,
9998            "event-scoped gross modeled avoided tokens",
9999        )?;
10000        require_eq(
10001            &event_scoped.deduped_modeled_tokens_avoided,
10002            &730,
10003            "event-scoped modeled events are not collapsed",
10004        )?;
10005        require_eq(
10006            &event_scoped.repeated_baselines_deduped,
10007            &0,
10008            "event-scoped modeled events do not count as deduped repeats",
10009        )?;
10010        require_eq(
10011            &event_scoped.likely_file_reads_avoided,
10012            &0,
10013            "folder navigation events do not count as likely file reads avoided",
10014        )?;
10015        require_eq(
10016            &event_scoped.read_avoidance_confidence,
10017            &READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED.to_string(),
10018            "folder navigation read avoidance confidence",
10019        )?;
10020
10021        let mut negative_event = usage_from_estimates("negative", "outline", None, None, 20, 50);
10022        negative_event.estimated_tokens_saved = Some(999);
10023        store.record_usage(&negative_event)?;
10024        let negative = store.token_overview(Some("negative"))?;
10025        require_eq(&negative.calls, &1, "negative session call count")?;
10026        require_eq(
10027            &negative.estimated_saved,
10028            &-30,
10029            "negative session recomputed delta",
10030        )?;
10031        require_eq(
10032            &negative.savings_rate,
10033            &Some(-1.5),
10034            "negative session savings rate",
10035        )?;
10036
10037        let mut zero_event = usage_from_estimates("zero-baseline", "outline", None, None, 0, 12);
10038        zero_event.estimated_tokens_saved = Some(999);
10039        store.record_usage(&zero_event)?;
10040        let zero_baseline = store.token_overview(Some("zero-baseline"))?;
10041        require_eq(&zero_baseline.calls, &1, "zero baseline call count")?;
10042        require_eq(
10043            &zero_baseline.estimated_saved,
10044            &-12,
10045            "zero baseline recomputed delta",
10046        )?;
10047        require_eq(
10048            &zero_baseline.savings_rate,
10049            &None,
10050            "zero baseline savings rate",
10051        )?;
10052
10053        let large_project = tempfile::tempdir()?;
10054        let mut large_store = AtlasStore::in_memory()?;
10055        large_store.set_project_root(large_project.path())?;
10056        let maximum_estimate = usize::try_from(i64::MAX)?;
10057        large_store.record_usage(&usage_from_estimates(
10058            "large-primary",
10059            "large",
10060            None,
10061            None,
10062            maximum_estimate,
10063            0,
10064        ))?;
10065        let Err(overflow) = large_store.record_usage(&usage_from_estimates(
10066            "large-rejected",
10067            "large",
10068            None,
10069            None,
10070            maximum_estimate,
10071            0,
10072        )) else {
10073            return Err(io::Error::other("overflowing telemetry aggregate was committed").into());
10074        };
10075        require_eq(
10076            &matches!(overflow, DbError::TelemetryIntegerOverflow { .. }),
10077            &true,
10078            "overflowing telemetry aggregate error",
10079        )?;
10080        let large = large_store.token_overview(Some("large-primary"))?;
10081        require_eq(
10082            &large.estimated_saved,
10083            &isize::MAX,
10084            "largest accepted aggregate narrows at the public boundary",
10085        )?;
10086        require_eq(
10087            &large_store.token_overview(Some("large-rejected"))?.calls,
10088            &0,
10089            "rejected aggregate left no partial report",
10090        )?;
10091        Ok(())
10092    }
10093
10094    #[test]
10095    fn direct_library_usage_rotates_at_baseline_capacity_without_reopening_the_old_scope()
10096    -> Result<(), Box<dyn Error>> {
10097        let project = tempfile::tempdir()?;
10098        let mut store = AtlasStore::in_memory()?;
10099        store.set_project_root(project.path())?;
10100        let policy = TelemetryRetentionPolicy::default();
10101        let label = "bounded-library-label";
10102
10103        for index in 0..policy.max_baselines_per_instance {
10104            store.record_usage(&usage_from_estimates(
10105                label,
10106                "search",
10107                None,
10108                Some(format!("query-{index}")),
10109                100,
10110                20,
10111            ))?;
10112        }
10113        let old_instance = store
10114            .library_usage_instances
10115            .borrow()
10116            .get(label)
10117            .copied()
10118            .flatten()
10119            .ok_or_else(|| io::Error::other("library instance was not retained"))?;
10120
10121        let boundary_event = usage_from_estimates(
10122            label,
10123            "search",
10124            None,
10125            Some("capacity-boundary".to_string()),
10126            100,
10127            20,
10128        );
10129        store.record_usage(&boundary_event)?;
10130        let replacement = store
10131            .library_usage_instances
10132            .borrow()
10133            .get(label)
10134            .copied()
10135            .flatten()
10136            .ok_or_else(|| io::Error::other("replacement library instance was not retained"))?;
10137        require_eq(
10138            &(replacement != old_instance),
10139            &true,
10140            "capacity rotation created a new internal instance",
10141        )?;
10142        require_eq(
10143            &store.connection.query_row(
10144                "SELECT COUNT(*) FROM usage_instances WHERE state = 'sealed'",
10145                [],
10146                |row| row.get::<_, i64>(0),
10147            )?,
10148            &1,
10149            "sealed predecessor instances",
10150        )?;
10151        require_eq(
10152            &store.connection.query_row(
10153                "SELECT COUNT(*) FROM usage_instances WHERE state = 'active'",
10154                [],
10155                |row| row.get::<_, i64>(0),
10156            )?,
10157            &1,
10158            "active replacement instances",
10159        )?;
10160        require_eq(
10161            &store.connection.query_row(
10162                "SELECT COUNT(*) FROM usage_instance_baselines",
10163                [],
10164                |row| row.get::<_, i64>(0),
10165            )?,
10166            &1,
10167            "only replacement baseline witnesses remain",
10168        )?;
10169        require_eq(
10170            &store.token_overview(Some(label))?.calls,
10171            &(policy.max_baselines_per_instance + 1),
10172            "caller-label report spans both bounded instances",
10173        )?;
10174        require_eq(
10175            &store.token_overview(None)?.estimated_saved,
10176            &(isize::try_from(policy.max_baselines_per_instance + 1)? * 80),
10177            "global totals remain exact across rotation",
10178        )?;
10179        require_eq(
10180            &matches!(
10181                store.record_usage_for_instance(
10182                    old_instance,
10183                    UsageInstanceOwner::LibraryHandle,
10184                    &boundary_event,
10185                    false,
10186                ),
10187                Err(DbError::TelemetryInstanceInactive)
10188            ),
10189            &true,
10190            "sealed predecessor cannot be reopened",
10191        )?;
10192        Ok(())
10193    }
10194
10195    #[test]
10196    fn rejected_library_labels_do_not_consume_the_bounded_identity_map()
10197    -> Result<(), Box<dyn Error>> {
10198        let project = tempfile::tempdir()?;
10199        let mut store = AtlasStore::in_memory()?;
10200        store.set_project_root(project.path())?;
10201        let maximum = TelemetryRetentionPolicy::default().max_label_bytes;
10202
10203        for extra in 1..=4 {
10204            let event =
10205                usage_from_estimates(&"x".repeat(maximum + extra), "summary", None, None, 100, 20);
10206            require_eq(
10207                &matches!(
10208                    store.record_usage(&event),
10209                    Err(DbError::TelemetryFieldTooLarge {
10210                        field: "session_id",
10211                        ..
10212                    })
10213                ),
10214                &true,
10215                "oversized caller label rejection",
10216            )?;
10217        }
10218        require_eq(
10219            &store.library_usage_instances.borrow().len(),
10220            &0,
10221            "rejected labels retained no identity-map entries",
10222        )?;
10223        Ok(())
10224    }
10225
10226    #[test]
10227    fn token_trends_group_usage_by_period_and_bucket() -> Result<(), Box<dyn Error>> {
10228        let project = tempfile::tempdir()?;
10229        let mut store = AtlasStore::in_memory()?;
10230        store.set_project_root(project.path())?;
10231        for (session, bucket, baseline_kind, confidence, without, with) in [
10232            (
10233                "session",
10234                TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
10235                "selected_candidates",
10236                "inferred",
10237                100_usize,
10238                25_usize,
10239            ),
10240            (
10241                "session",
10242                TOKEN_BUCKET_FULL_FILE_COMPRESSION,
10243                "full_file",
10244                "observed",
10245                50_usize,
10246                10_usize,
10247            ),
10248            (
10249                "session",
10250                TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
10251                "selected_candidates",
10252                "inferred",
10253                80_usize,
10254                20_usize,
10255            ),
10256            (
10257                "other",
10258                TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
10259                "selected_candidates",
10260                "inferred",
10261                999_usize,
10262                1_usize,
10263            ),
10264        ] {
10265            store.record_usage(&usage_from_estimates_with_context(
10266                session,
10267                "trend",
10268                None,
10269                None,
10270                without,
10271                with,
10272                bucket,
10273                baseline_kind,
10274                confidence,
10275            ))?;
10276        }
10277
10278        let trends = store.token_trends(Some("session"), TokenTrendWindow::Day)?;
10279        require_eq(&trends.periods.len(), &1, "current daily period")?;
10280        require_eq(&trends.periods[0].calls, &3, "session trend call count")?;
10281        require_eq(
10282            &trends.periods[0].estimated_saved,
10283            &175,
10284            "session trend saved tokens",
10285        )?;
10286        require_eq(
10287            &trends.periods[0].buckets.len(),
10288            &2,
10289            "trend preserves evidence buckets",
10290        )?;
10291        require_eq(
10292            &trends.periods[0].buckets[0].token_savings_bucket,
10293            &TOKEN_BUCKET_FULL_FILE_COMPRESSION.to_string(),
10294            "full-file bucket remains visible",
10295        )?;
10296        require_eq(
10297            &trends.periods[0].buckets[0].confidence,
10298            &"observed".to_string(),
10299            "bucket confidence remains visible",
10300        )?;
10301        let all_labels = store.token_trends(None, TokenTrendWindow::Day)?;
10302        require_eq(&all_labels.periods.len(), &1, "all-label daily period")?;
10303        require_eq(&all_labels.periods[0].calls, &4, "all-label trend calls")?;
10304        Ok(())
10305    }
10306
10307    #[test]
10308    fn unsupported_legacy_schema_is_refused_without_mutation() -> Result<(), Box<dyn Error>> {
10309        let temp = tempfile::tempdir()?;
10310        let db_path = temp.path().join("legacy.db");
10311        {
10312            let connection = Connection::open(&db_path)?;
10313            connection.execute_batch(
10314                "
10315                CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL);
10316                INSERT INTO metadata(key, value) VALUES('schema_version', '7');
10317                CREATE TABLE usage_events (
10318                    id INTEGER PRIMARY KEY,
10319                    session_id TEXT NOT NULL,
10320                    command TEXT NOT NULL,
10321                    path TEXT,
10322                    query TEXT,
10323                    estimated_tokens_without_projectatlas INTEGER,
10324                    estimated_tokens_with_projectatlas INTEGER,
10325                    estimated_tokens_saved INTEGER,
10326                    token_savings_bucket TEXT NOT NULL DEFAULT 'navigation_avoidance',
10327                    provider TEXT NOT NULL DEFAULT 'heuristic',
10328                    model TEXT NOT NULL DEFAULT 'unknown',
10329                    tokenizer_backend TEXT NOT NULL DEFAULT 'chars_div_4',
10330                    accuracy TEXT NOT NULL DEFAULT 'heuristic_estimate',
10331                    baseline_kind TEXT NOT NULL DEFAULT 'selected_candidates',
10332                    confidence TEXT NOT NULL DEFAULT 'inferred',
10333                    calculation_trace TEXT NOT NULL DEFAULT 'heuristic=ceil(chars_or_bytes/4)'
10334                );
10335                INSERT INTO usage_events(
10336                    session_id,
10337                    command,
10338                    estimated_tokens_without_projectatlas,
10339                    estimated_tokens_with_projectatlas,
10340                    estimated_tokens_saved
10341                )
10342                VALUES('legacy-session', 'legacy', 100, 20, 80);
10343                ",
10344            )?;
10345        }
10346
10347        let database_before = fs::read(&db_path)?;
10348        let Err(open_error) = AtlasStore::open(&db_path) else {
10349            return Err(io::Error::other("unsupported schema unexpectedly opened").into());
10350        };
10351        require_eq(
10352            &matches!(
10353                open_error,
10354                DbError::SchemaVersion {
10355                    found: 7,
10356                    expected: SCHEMA_VERSION,
10357                }
10358            ),
10359            &true,
10360            "unsupported schema rejection",
10361        )?;
10362        require_eq(
10363            &fs::read(&db_path)?,
10364            &database_before,
10365            "unsupported schema bytes remain unchanged",
10366        )?;
10367        Ok(())
10368    }
10369
10370    #[test]
10371    fn set_project_root_requires_existing_root_and_preserves_binding() -> Result<(), Box<dyn Error>>
10372    {
10373        let temp = tempfile::tempdir()?;
10374        let mut store = AtlasStore::in_memory()?;
10375        let missing = temp.path().join("missing-root");
10376        let Err(missing_error) = store.set_project_root(&missing) else {
10377            return Err(io::Error::other("missing project root was accepted").into());
10378        };
10379        require(
10380            matches!(missing_error, DbError::ProjectRootIdentity(_)),
10381            "missing project root returned the wrong error",
10382        )?;
10383        require_eq(
10384            &store.project_root()?,
10385            &None,
10386            "missing-root rejection changed metadata",
10387        )?;
10388        require_eq(
10389            &store.project_root_identity()?,
10390            &None,
10391            "missing-root rejection changed native identity",
10392        )?;
10393        require_eq(
10394            &store.project_instance_id()?,
10395            &None,
10396            "missing-root rejection changed project identity",
10397        )?;
10398
10399        let root = temp.path().join("workspace").join("example");
10400        fs::create_dir_all(&root)?;
10401        store.set_project_root(&root)?;
10402        require_eq(
10403            &store.project_root()?,
10404            &Some(normalize_native_path_display(&root)),
10405            "project root metadata",
10406        )?;
10407
10408        #[cfg(windows)]
10409        let extended = PathBuf::from(format!(r"\\?\{}", root.display()));
10410        #[cfg(windows)]
10411        store.set_project_root(&extended)?;
10412        #[cfg(windows)]
10413        require_eq(
10414            &store.project_root()?,
10415            &Some(normalize_native_path_display(&root)),
10416            "windows extended project root metadata",
10417        )?;
10418        let other = temp.path().join("other");
10419        fs::create_dir(&other)?;
10420        let Err(rebind_error) = store.set_project_root(&other) else {
10421            return Err(io::Error::other("project identity was rebound implicitly").into());
10422        };
10423        require_eq(
10424            &matches!(rebind_error, DbError::ProjectRootMismatch { .. }),
10425            &true,
10426            "project identity rebind rejection",
10427        )?;
10428        Ok(())
10429    }
10430
10431    #[test]
10432    fn ordinary_root_admission_rejects_missing_and_regular_paths_without_mutation()
10433    -> Result<(), Box<dyn Error>> {
10434        let temp = tempfile::tempdir()?;
10435        let root = temp.path().join("root");
10436        let missing = temp.path().join("missing-root");
10437        let regular = temp.path().join("regular-root");
10438        fs::create_dir(&root)?;
10439        fs::write(&regular, b"not a directory")?;
10440        let database = temp.path().join("projectatlas.db");
10441        drop(AtlasStore::open_for_project(&database, &root)?);
10442
10443        for (label, candidate) in [
10444            ("missing", missing.as_path()),
10445            ("regular", regular.as_path()),
10446        ] {
10447            let before = fs::read(&database)?;
10448            if AtlasStore::open_for_project(&database, candidate).is_ok() {
10449                return Err(
10450                    io::Error::other(format!("ordinary {label} root admission succeeded")).into(),
10451                );
10452            }
10453            if fs::read(&database)? != before {
10454                return Err(io::Error::other(format!(
10455                    "ordinary {label} root admission mutated the database"
10456                ))
10457                .into());
10458            }
10459            if verify_project_database(&database, candidate).is_ok() {
10460                return Err(
10461                    io::Error::other(format!("verify admitted ordinary {label} root")).into(),
10462                );
10463            }
10464            if fs::read(&database)? != before {
10465                return Err(io::Error::other(format!(
10466                    "verify of ordinary {label} root mutated the database"
10467                ))
10468                .into());
10469            }
10470        }
10471        Ok(())
10472    }
10473
10474    #[test]
10475    fn malformed_project_root_identity_is_refused_without_mutation() -> Result<(), Box<dyn Error>> {
10476        let temp = tempfile::tempdir()?;
10477        let root = temp.path().join("root");
10478        fs::create_dir(&root)?;
10479        let database = temp.path().join("projectatlas.db");
10480        drop(AtlasStore::open_for_project(&database, &root)?);
10481        let connection = Connection::open(&database)?;
10482        connection.execute(
10483            "UPDATE project_root_identity SET root = ?1 WHERE singleton = 1",
10484            [vec![1_u8, 1_u8, b'x']],
10485        )?;
10486        drop(connection);
10487        let before = fs::read(&database)?;
10488
10489        if AtlasStore::open(&database).is_ok()
10490            || AtlasStore::open_for_project(&database, &root).is_ok()
10491            || verify_project_database(&database, &root).is_ok()
10492        {
10493            return Err(io::Error::other("malformed project-root identity was admitted").into());
10494        }
10495        if fs::read(&database)? != before {
10496            return Err(io::Error::other(
10497                "malformed project-root identity refusal mutated the database",
10498            )
10499            .into());
10500        }
10501        Ok(())
10502    }
10503
10504    #[test]
10505    fn read_project_root_read_only_does_not_change_database_bytes() -> Result<(), Box<dyn Error>> {
10506        let temp = tempfile::tempdir()?;
10507        let root = temp.path().join("repo with spaces");
10508        let atlas_dir = root.join(".projectatlas");
10509        fs::create_dir_all(&atlas_dir)?;
10510        let db_path = atlas_dir.join("projectatlas.db");
10511        {
10512            let mut store = AtlasStore::open(&db_path)?;
10513            store.set_project_root(&root)?;
10514            store
10515                .connection
10516                .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
10517        }
10518        let wal_path = db_path.with_extension("db-wal");
10519        let shm_path = db_path.with_extension("db-shm");
10520        for sidecar_path in [&wal_path, &shm_path] {
10521            match fs::remove_file(sidecar_path) {
10522                Ok(()) => {}
10523                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10524                Err(error) => return Err(error.into()),
10525            }
10526        }
10527        let database_before = fs::read(&db_path)?;
10528
10529        require_eq(
10530            &read_project_root_read_only(&db_path)?,
10531            &Some(normalize_native_path_display(&root)),
10532            "read-only project root",
10533        )?;
10534        require_eq(
10535            &read_legacy_project_root_candidate_read_only(&db_path)?,
10536            &None,
10537            "current schema has no legacy recovery candidate",
10538        )?;
10539        require_eq(
10540            &fs::read(&db_path)?,
10541            &database_before,
10542            "read-only project-root database bytes",
10543        )?;
10544        Ok(())
10545    }
10546
10547    #[test]
10548    fn read_project_root_read_only_observes_active_wal_state() -> Result<(), Box<dyn Error>> {
10549        let temp = tempfile::tempdir()?;
10550        let root = temp.path().join("repo-Δ");
10551        let atlas_dir = root.join(".projectatlas");
10552        fs::create_dir_all(&atlas_dir)?;
10553        let db_path = atlas_dir.join("projectatlas.db");
10554        let mut store = AtlasStore::open(&db_path)?;
10555        store
10556            .connection
10557            .execute_batch("PRAGMA wal_autocheckpoint = 0")?;
10558        store.set_project_root(&root)?;
10559
10560        require_eq(
10561            &sqlite_sidecar_path(&db_path, "-wal").exists(),
10562            &true,
10563            "active WAL exists",
10564        )?;
10565        require_eq(
10566            &read_project_root_read_only(&db_path)?,
10567            &Some(normalize_native_path_display(&root)),
10568            "WAL-aware read-only project root",
10569        )?;
10570        Ok(())
10571    }
10572
10573    #[test]
10574    fn validated_write_transaction_revalidates_before_operation_and_rolls_back()
10575    -> Result<(), Box<dyn Error>> {
10576        let temp = tempfile::tempdir()?;
10577        let root = temp.path().join("repository");
10578        fs::create_dir_all(&root)?;
10579        let db_path = temp.path().join("projectatlas.db");
10580        let store = AtlasStore::open_for_project(&db_path, &root)?;
10581        let expected_identity = store
10582            .validated_project_instance_id
10583            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
10584        let sentinel_key = "validated_write_transaction_test";
10585
10586        with_validated_write_transaction(
10587            &store.connection,
10588            store.validated_project_root.as_deref(),
10589            Some(expected_identity),
10590            |connection| set_metadata(connection, sentinel_key, "committed"),
10591        )?;
10592        require_eq(
10593            &store.connection.query_row(
10594                "SELECT value FROM metadata WHERE key = ?1",
10595                [sentinel_key],
10596                |row| row.get::<_, String>(0),
10597            )?,
10598            &"committed".to_string(),
10599            "validated write positive control",
10600        )?;
10601
10602        let Err(operation_error) = with_validated_write_transaction(
10603            &store.connection,
10604            store.validated_project_root.as_deref(),
10605            Some(expected_identity),
10606            |connection| -> DbResult<()> {
10607                set_metadata(connection, sentinel_key, "rolled-back")?;
10608                Err(DbError::TelemetryIdentityUnavailable)
10609            },
10610        ) else {
10611            return Err(io::Error::other("failing validated write unexpectedly committed").into());
10612        };
10613        require_eq(
10614            &matches!(operation_error, DbError::TelemetryIdentityUnavailable),
10615            &true,
10616            "validated write operation error",
10617        )?;
10618        require_eq(
10619            &store.connection.query_row(
10620                "SELECT value FROM metadata WHERE key = ?1",
10621                [sentinel_key],
10622                |row| row.get::<_, String>(0),
10623            )?,
10624            &"committed".to_string(),
10625            "validated write rollback",
10626        )?;
10627
10628        with_validated_native_write_transaction(
10629            &store.connection,
10630            store.validated_project_root_identity.as_ref(),
10631            Some(expected_identity),
10632            |connection| set_metadata(connection, sentinel_key, "native-committed"),
10633        )?;
10634        require_eq(
10635            &store.connection.query_row(
10636                "SELECT value FROM metadata WHERE key = ?1",
10637                [sentinel_key],
10638                |row| row.get::<_, String>(0),
10639            )?,
10640            &"native-committed".to_string(),
10641            "native validated write positive control",
10642        )?;
10643
10644        let future_schema = schema::SCHEMA_VERSION + 1;
10645        let newer_owner = Connection::open(&db_path)?;
10646        newer_owner.execute(
10647            "UPDATE metadata SET value = ?2 WHERE key = ?1",
10648            params![schema::SCHEMA_VERSION_KEY, future_schema.to_string()],
10649        )?;
10650        let operation_invoked = Cell::new(false);
10651        let Err(binding_error) = with_validated_native_write_transaction(
10652            &store.connection,
10653            store.validated_project_root_identity.as_ref(),
10654            Some(expected_identity),
10655            |connection| {
10656                operation_invoked.set(true);
10657                set_metadata(connection, sentinel_key, "wrong-schema")
10658            },
10659        ) else {
10660            return Err(io::Error::other("newer schema accepted a validated write").into());
10661        };
10662        require_eq(
10663            &matches!(
10664                binding_error,
10665                DbError::SchemaVersion { found, expected }
10666                    if found == future_schema && expected == schema::SCHEMA_VERSION
10667            ),
10668            &true,
10669            "validated write schema recheck",
10670        )?;
10671        require_eq(
10672            &operation_invoked.get(),
10673            &false,
10674            "validated write closure invocation",
10675        )?;
10676        require_eq(
10677            &newer_owner.query_row(
10678                "SELECT value FROM metadata WHERE key = ?1",
10679                [sentinel_key],
10680                |row| row.get::<_, String>(0),
10681            )?,
10682            &"native-committed".to_string(),
10683            "newer-schema sentinel value",
10684        )?;
10685        Ok(())
10686    }
10687
10688    #[test]
10689    fn read_only_telemetry_revalidates_project_identity_before_write() -> Result<(), Box<dyn Error>>
10690    {
10691        let temp = tempfile::tempdir()?;
10692        let root = temp.path().join("repository");
10693        fs::create_dir_all(&root)?;
10694        let db_path = temp.path().join("projectatlas.db");
10695        drop(AtlasStore::open_for_project(&db_path, &root)?);
10696
10697        let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10698        reader.finish_index_read_snapshot()?;
10699        AtlasStore::transition_project_root(&db_path, &root, ProjectRootTransition::Detach)?;
10700
10701        let event = usage_from_estimates(
10702            "identity-race",
10703            "summary",
10704            Some("src/lib.rs".to_string()),
10705            None,
10706            100,
10707            20,
10708        );
10709        let Err(error) = reader.record_usage(&event) else {
10710            return Err(io::Error::other("telemetry wrote through replaced identity").into());
10711        };
10712        require_eq(
10713            &matches!(error, DbError::ProjectRootTransitionChanged { .. }),
10714            &true,
10715            "telemetry project identity recheck",
10716        )?;
10717        let current = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10718        require_eq(
10719            &current.token_overview(Some("identity-race"))?.calls,
10720            &0,
10721            "telemetry refused after identity replacement",
10722        )?;
10723        Ok(())
10724    }
10725
10726    #[test]
10727    fn read_only_recovery_requires_native_identity_before_telemetry_write()
10728    -> Result<(), Box<dyn Error>> {
10729        let temp = tempfile::tempdir()?;
10730        let root = temp.path().join("repository");
10731        fs::create_dir_all(&root)?;
10732        let database = temp.path().join("projectatlas.db");
10733        let event = usage_from_estimates(
10734            "rooted-telemetry",
10735            "summary",
10736            Some("src/lib.rs".to_string()),
10737            None,
10738            100,
10739            20,
10740        );
10741        let rooted_instance = UsageInstanceId::from_bytes([7; 16])?;
10742        let native_root_row;
10743        {
10744            let store = AtlasStore::open_for_project(&database, &root)?;
10745            native_root_row = store.connection.query_row(
10746                "SELECT codec_version, root
10747                   FROM project_root_identity
10748                  WHERE singleton = 1",
10749                [],
10750                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)),
10751            )?;
10752            store.record_usage(&event)?;
10753            store.record_usage_for_instance(
10754                rooted_instance,
10755                UsageInstanceOwner::LibraryHandle,
10756                &event,
10757                false,
10758            )?;
10759            store.seal_usage_instance(rooted_instance)?;
10760            require_eq(
10761                &store.token_overview(Some("rooted-telemetry"))?.calls,
10762                &2,
10763                "rooted telemetry positive write",
10764            )?;
10765            store
10766                .connection
10767                .execute("DELETE FROM project_root_identity", [])?;
10768            store
10769                .connection
10770                .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
10771        }
10772
10773        let file_snapshot = || -> Result<BTreeMap<std::ffi::OsString, Vec<u8>>, io::Error> {
10774            let mut files = BTreeMap::new();
10775            for entry in fs::read_dir(temp.path())? {
10776                let entry = entry?;
10777                if entry.file_type()?.is_file() {
10778                    files.insert(entry.file_name(), fs::read(entry.path())?);
10779                }
10780            }
10781            Ok(files)
10782        };
10783        let reader = AtlasStore::open_read_only(&database)?;
10784        require_eq(
10785            &reader.project_root_identity()?,
10786            &None,
10787            "rootless recovery has no native identity",
10788        )?;
10789        require_eq(
10790            &reader.validated_project_instance_id.is_some(),
10791            &true,
10792            "rootless recovery retains project identity",
10793        )?;
10794        require_eq(
10795            &reader.project_root()?,
10796            &Some(normalize_native_path_display(&root)),
10797            "rootless recovery retains legacy display root",
10798        )?;
10799        let overview_before = reader.token_overview(Some("rooted-telemetry"))?;
10800        let files_before = file_snapshot()?;
10801
10802        let missing_root_event = usage_from_estimates(
10803            "rootless-telemetry",
10804            "summary",
10805            Some("src/main.rs".to_string()),
10806            None,
10807            80,
10808            20,
10809        );
10810        let Err(error) = reader.record_usage(&missing_root_event) else {
10811            return Err(io::Error::other("rootless recovery recorded direct telemetry").into());
10812        };
10813        require_eq(
10814            &matches!(error, DbError::ProjectRootIdentityMissing),
10815            &true,
10816            "rootless direct telemetry refusal",
10817        )?;
10818
10819        let Err(error) = reader.record_usage_for_instance(
10820            UsageInstanceId::from_bytes([8; 16])?,
10821            UsageInstanceOwner::LibraryHandle,
10822            &missing_root_event,
10823            false,
10824        ) else {
10825            return Err(io::Error::other("rootless recovery recorded instance telemetry").into());
10826        };
10827        require_eq(
10828            &matches!(error, DbError::ProjectRootIdentityMissing),
10829            &true,
10830            "rootless instance telemetry refusal",
10831        )?;
10832
10833        let Err(error) = reader.record_usage_for_worktree_instance(
10834            UsageInstanceId::from_bytes([9; 16])?,
10835            UsageInstanceOwner::McpProcess,
10836            1,
10837            &missing_root_event,
10838            false,
10839        ) else {
10840            return Err(io::Error::other("rootless recovery recorded worktree telemetry").into());
10841        };
10842        require_eq(
10843            &matches!(error, DbError::ProjectRootIdentityMissing),
10844            &true,
10845            "rootless worktree telemetry refusal",
10846        )?;
10847
10848        let Err(error) = reader.seal_usage_instance(UsageInstanceId::from_bytes([10; 16])?) else {
10849            return Err(io::Error::other("rootless recovery sealed telemetry").into());
10850        };
10851        require_eq(
10852            &matches!(error, DbError::ProjectRootIdentityMissing),
10853            &true,
10854            "rootless seal telemetry refusal",
10855        )?;
10856
10857        require_eq(
10858            &reader.token_overview(Some("rooted-telemetry"))?,
10859            &overview_before,
10860            "rootless telemetry rows remain unchanged",
10861        )?;
10862        require_eq(
10863            &file_snapshot()?,
10864            &files_before,
10865            "rootless telemetry files remain unchanged",
10866        )?;
10867        drop(reader);
10868
10869        let repair = Connection::open(&database)?;
10870        schema::configure_writable(&repair)?;
10871        repair.execute(
10872            "INSERT INTO project_root_identity(singleton, codec_version, root)
10873             VALUES(1, ?1, ?2)",
10874            params![native_root_row.0, native_root_row.1],
10875        )?;
10876        drop(repair);
10877        let repaired = AtlasStore::open_for_project(&database, &root)?;
10878        repaired.record_usage(&usage_from_estimates(
10879            "repaired-telemetry",
10880            "summary",
10881            None,
10882            None,
10883            60,
10884            20,
10885        ))?;
10886        require_eq(
10887            &repaired.token_overview(Some("repaired-telemetry"))?.calls,
10888            &1,
10889            "repaired telemetry retry",
10890        )?;
10891        Ok(())
10892    }
10893
10894    #[test]
10895    fn telemetry_contention_uses_ancillary_busy_budget() -> Result<(), Box<dyn Error>> {
10896        let temp = tempfile::tempdir()?;
10897        let root = temp.path().join("repository");
10898        fs::create_dir_all(&root)?;
10899        let db_path = temp.path().join("projectatlas.db");
10900        drop(AtlasStore::open_for_project(&db_path, &root)?);
10901
10902        let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10903        let writer = Connection::open(&db_path)?;
10904        writer.execute_batch("BEGIN IMMEDIATE")?;
10905        let started = Instant::now();
10906        let result = reader.record_usage(&usage_from_estimates(
10907            "contended",
10908            "overview",
10909            None,
10910            None,
10911            100,
10912            20,
10913        ));
10914        let elapsed = started.elapsed();
10915        writer.execute_batch("ROLLBACK")?;
10916
10917        let Err(error) = result else {
10918            return Err(
10919                io::Error::other("contended telemetry write unexpectedly succeeded").into(),
10920            );
10921        };
10922        require_eq(
10923            &error.is_write_unavailable(),
10924            &true,
10925            "contended telemetry error kind",
10926        )?;
10927        require_eq(
10928            &(elapsed < Duration::from_millis(500)),
10929            &true,
10930            "ancillary telemetry busy latency",
10931        )?;
10932        require_eq(
10933            &reader.token_overview(Some("contended"))?.calls,
10934            &0,
10935            "contended telemetry rollback",
10936        )?;
10937        Ok(())
10938    }
10939
10940    #[cfg(unix)]
10941    #[test]
10942    fn read_only_telemetry_does_not_recreate_a_removed_database() -> Result<(), Box<dyn Error>> {
10943        let temp = tempfile::tempdir()?;
10944        let root = temp.path().join("repository");
10945        fs::create_dir_all(&root)?;
10946        let db_path = temp.path().join("projectatlas.db");
10947        drop(AtlasStore::open_for_project(&db_path, &root)?);
10948
10949        let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10950        fs::remove_file(&db_path)?;
10951        let event = usage_from_estimates(
10952            "removed-database",
10953            "summary",
10954            Some("src/lib.rs".to_string()),
10955            None,
10956            100,
10957            20,
10958        );
10959        if reader.record_usage(&event).is_ok() {
10960            return Err(io::Error::other("telemetry reopened a removed database").into());
10961        }
10962        require_eq(
10963            &db_path.exists(),
10964            &false,
10965            "telemetry must not recreate a removed database",
10966        )?;
10967        Ok(())
10968    }
10969
10970    #[test]
10971    fn read_only_store_opens_checkpointed_wal_without_existing_sidecars()
10972    -> Result<(), Box<dyn Error>> {
10973        let temp = tempfile::tempdir()?;
10974        let root = temp.path().join("repo with spaces");
10975        let atlas_dir = root.join(".projectatlas");
10976        fs::create_dir_all(&atlas_dir)?;
10977        let db_path = atlas_dir.join("projectatlas.db");
10978        {
10979            let mut store = AtlasStore::open(&db_path)?;
10980            store.set_project_root(&root)?;
10981            store
10982                .connection
10983                .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
10984        }
10985        for sidecar_path in [
10986            sqlite_sidecar_path(&db_path, "-wal"),
10987            sqlite_sidecar_path(&db_path, "-shm"),
10988        ] {
10989            match fs::remove_file(sidecar_path) {
10990                Ok(()) => {}
10991                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10992                Err(error) => return Err(error.into()),
10993            }
10994        }
10995
10996        let store = AtlasStore::open_read_only(&db_path)?;
10997        require_eq(
10998            &store.project_root()?,
10999            &Some(normalize_native_path_display(&root)),
11000            "read-only checkpointed project root",
11001        )?;
11002        store.finish_index_read_snapshot()?;
11003        Ok(())
11004    }
11005
11006    #[test]
11007    fn partial_scan_updates_and_absents_paths() -> Result<(), Box<dyn Error>> {
11008        let mut store = AtlasStore::in_memory()?;
11009        store.replace_scan(&[
11010            test_file_node("src/a.rs", "hash-a"),
11011            test_file_node("src/b.rs", "hash-b"),
11012        ])?;
11013        store.upsert_scan_nodes(&[test_file_node("src/a.rs", "hash-a2")])?;
11014        let updated = store
11015            .load_node_by_path("src/a.rs")?
11016            .ok_or_else(|| io::Error::other("updated node missing"))?;
11017        require_eq(
11018            &updated.node.content_hash,
11019            &Some("hash-a2".to_string()),
11020            "partial content hash",
11021        )?;
11022        require_eq(
11023            &store.load_node_by_path("src/b.rs")?.is_some(),
11024            &true,
11025            "unrelated node remains indexed",
11026        )?;
11027        store.mark_paths_absent(&["src/b.rs".to_string()])?;
11028        require_eq(
11029            &store.load_node_by_path("src/b.rs")?.is_none(),
11030            &true,
11031            "absent path is no longer returned",
11032        )?;
11033        Ok(())
11034    }
11035
11036    #[test]
11037    fn approved_purpose_survives_incremental_file_hash_changes() -> Result<(), Box<dyn Error>> {
11038        let mut store = AtlasStore::in_memory()?;
11039        store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
11040        store.set_purpose(
11041            "src/main.rs",
11042            "Application entry point",
11043            PurposeSource::Agent,
11044        )?;
11045        store.upsert_scan_nodes(&[test_file_node("src/main.rs", "hash-b")])?;
11046
11047        let node = store
11048            .load_node_by_path("src/main.rs")?
11049            .ok_or_else(|| io::Error::other("changed node missing"))?;
11050        require_eq(
11051            &node.purpose.status,
11052            &PurposeStatus::Approved,
11053            "changed approved file purpose status",
11054        )?;
11055        require_eq(
11056            &node.purpose.agent_reviewed(),
11057            &true,
11058            "changed approved file remains agent reviewed",
11059        )?;
11060        Ok(())
11061    }
11062
11063    #[test]
11064    fn ranked_nodes_are_loaded_bounded_from_sql() -> Result<(), Box<dyn Error>> {
11065        let mut store = AtlasStore::in_memory()?;
11066        let mut gradle_task_node = test_file_node("build.gradle.kts", "hash-gradle");
11067        gradle_task_node.extension = Some(".kts".to_string());
11068        gradle_task_node.language = Some("kotlin".to_string());
11069        store.replace_scan(&[
11070            test_folder_node("src/auth"),
11071            test_folder_node("src/ui"),
11072            test_file_node("src/auth/login.rs", "hash-login"),
11073            test_file_node("src/ui/button.rs", "hash-button"),
11074            gradle_task_node,
11075        ])?;
11076        store.set_purpose(
11077            "src/auth",
11078            "Authentication workflow folder",
11079            PurposeSource::Agent,
11080        )?;
11081        store.set_purpose("src/ui", "User interface folder", PurposeSource::Agent)?;
11082        store.set_node_summary("src/auth/login.rs", "rust source defining login flow")?;
11083
11084        let folders = store.load_ranked_nodes("authentication", NodeKind::Folder, None, 1, 0)?;
11085        require_eq(&folders.len(), &1, "bounded folder ranking")?;
11086        require_eq(
11087            &folders[0].node.path,
11088            &"src/auth".to_string(),
11089            "semantic folder ranking",
11090        )?;
11091
11092        let files = store.load_ranked_nodes("login", NodeKind::File, Some("src/auth"), 10, 0)?;
11093        require_eq(&files.len(), &1, "folder-constrained file ranking")?;
11094        require_eq(
11095            &files[0].node.path,
11096            &"src/auth/login.rs".to_string(),
11097            "ranked file path",
11098        )?;
11099        store.replace_symbol_graph(&SymbolGraph {
11100            path: "build.gradle.kts".to_string(),
11101            language: Some("kotlin".to_string()),
11102            parser: ParserKind::TreeSitter,
11103            symbols: vec![CodeSymbol {
11104                path: "build.gradle.kts".to_string(),
11105                language: Some("kotlin".to_string()),
11106                name: "bootRunE2E".to_string(),
11107                kind: SymbolKind::Function,
11108                signature: "tasks.register<BootRun>(\"bootRunE2E\")".to_string(),
11109                exported: false,
11110                documentation: None,
11111                line_start: 1,
11112                line_end: 1,
11113                source_selector: None,
11114                parent: None,
11115                parser: ParserKind::TreeSitter,
11116                detail: Some("gradle-kotlin-dsl-task".to_string()),
11117            }],
11118            relations: Vec::new(),
11119        })?;
11120        let gradle_files = store.load_ranked_nodes("bootRunE2E", NodeKind::File, None, 10, 0)?;
11121        require_eq(&gradle_files.len(), &1, "symbol-ranked file count")?;
11122        require_eq(
11123            &gradle_files[0].node.path,
11124            &"build.gradle.kts".to_string(),
11125            "symbol-ranked file path",
11126        )?;
11127        store.clear_symbol_graph_for_path("build.gradle.kts")?;
11128        let cleared_gradle_files =
11129            store.load_ranked_nodes("bootRunE2E", NodeKind::File, None, 10, 0)?;
11130        require_eq(
11131            &cleared_gradle_files.len(),
11132            &0,
11133            "cleared symbol-ranked file count",
11134        )?;
11135        Ok(())
11136    }
11137
11138    #[test]
11139    fn ranked_node_admission_preserves_dominant_tiers_before_candidate_cap()
11140    -> Result<(), Box<dyn Error>> {
11141        let mut store = AtlasStore::in_memory()?;
11142        let mut nodes = vec![
11143            test_file_node("needle", "hash-exact"),
11144            test_file_node("deep/needle", "hash-name"),
11145            test_file_node("reviewed.rs", "hash-reviewed"),
11146        ];
11147        nodes.extend(
11148            (0..130).map(|index| test_file_node(&format!("weak/needle-{index:03}.rs"), "hash")),
11149        );
11150        store.replace_scan(&nodes)?;
11151        store.set_purpose(
11152            "reviewed.rs",
11153            "Own needle responsibility",
11154            PurposeSource::Agent,
11155        )?;
11156        for index in 0..130 {
11157            let path = format!("weak/needle-{index:03}.rs");
11158            store.set_suggested_purpose(&path, "Generated needle suggestion")?;
11159            store.set_node_summary(&path, "Observed needle summary")?;
11160        }
11161
11162        let selected = store.load_ranked_nodes("needle", NodeKind::File, None, 100, 0)?;
11163        require_eq(&selected.len(), &100, "bounded adversarial candidate count")?;
11164        require_eq(
11165            &selected[..3]
11166                .iter()
11167                .map(|node| node.node.path.as_str())
11168                .collect::<Vec<_>>(),
11169            &vec!["needle", "deep/needle", "reviewed.rs"],
11170            "pre-cap exact path basename and reviewed-purpose admission",
11171        )?;
11172        Ok(())
11173    }
11174
11175    #[test]
11176    fn folder_like_filters_treat_wildcards_as_literal_path_text() -> Result<(), Box<dyn Error>> {
11177        let mut store = AtlasStore::in_memory()?;
11178        store.replace_scan(&[
11179            test_folder_node("src/a%b"),
11180            test_folder_node("src/axb"),
11181            test_folder_node("src/a_b"),
11182            test_folder_node("src/acb"),
11183            test_file_node("src/a%b/target.rs", "hash-percent-target"),
11184            test_file_node("src/axb/false.rs", "hash-percent-false"),
11185            test_file_node("src/a_b/target.rs", "hash-underscore-target"),
11186            test_file_node("src/acb/false.rs", "hash-underscore-false"),
11187        ])?;
11188        for path in [
11189            "src/a%b/target.rs",
11190            "src/axb/false.rs",
11191            "src/a_b/target.rs",
11192            "src/acb/false.rs",
11193        ] {
11194            store.set_node_summary(path, "needle indexed summary")?;
11195        }
11196
11197        let percent_files =
11198            store.load_ranked_nodes("needle", NodeKind::File, Some("src/a%b"), 10, 0)?;
11199        require_eq(&percent_files.len(), &1, "percent folder ranked count")?;
11200        require_eq(
11201            &percent_files[0].node.path,
11202            &"src/a%b/target.rs".to_string(),
11203            "percent folder ranked path",
11204        )?;
11205        require_eq(
11206            &store.source_file_byte_count(Some("src/a%b"))?,
11207            &12,
11208            "percent folder byte count",
11209        )?;
11210
11211        let mut visited = Vec::new();
11212        store.visit_file_token_estimates(Some("src/a_b"), |path, _size| {
11213            visited.push(path);
11214            Ok(true)
11215        })?;
11216        require_eq(
11217            &visited,
11218            &vec!["src/a_b/target.rs".to_string()],
11219            "underscore folder token paths",
11220        )?;
11221
11222        store.mark_paths_absent(&["src/a%b".to_string(), "src/a_b".to_string()])?;
11223        require_eq(
11224            &store.load_node_by_path("src/axb/false.rs")?.is_some(),
11225            &true,
11226            "percent-like sibling remains indexed",
11227        )?;
11228        require_eq(
11229            &store.load_node_by_path("src/acb/false.rs")?.is_some(),
11230            &true,
11231            "underscore-like sibling remains indexed",
11232        )?;
11233        require_eq(
11234            &store.load_node_by_path("src/a%b/target.rs")?.is_none(),
11235            &true,
11236            "percent folder target removed",
11237        )?;
11238        require_eq(
11239            &store.load_node_by_path("src/a_b/target.rs")?.is_none(),
11240            &true,
11241            "underscore folder target removed",
11242        )?;
11243        Ok(())
11244    }
11245
11246    #[test]
11247    fn sql_health_findings_match_resolution_ids() -> Result<(), Box<dyn Error>> {
11248        let mut store = AtlasStore::in_memory()?;
11249        store.replace_scan(&[
11250            test_file_node("src/a.rs", "hash-a"),
11251            test_file_node("src/b.rs", "hash-b"),
11252        ])?;
11253        store.set_purpose("src/a.rs", "Shared purpose", PurposeSource::Agent)?;
11254        store.set_purpose("src/b.rs", "Shared purpose", PurposeSource::Agent)?;
11255
11256        let findings = store.unresolved_health_findings(&[])?;
11257        let duplicate = findings
11258            .iter()
11259            .find(|finding| finding.category == "duplicate-purpose")
11260            .ok_or_else(|| io::Error::other("duplicate-purpose finding missing"))?;
11261        store.resolve_health_finding(&HealthResolution {
11262            finding_id: duplicate.id.clone(),
11263            category: duplicate.category.clone(),
11264            path: duplicate.path.clone(),
11265            related_path: duplicate.related_path.clone(),
11266            rationale: "Intentional mirror for test.".to_string(),
11267        })?;
11268        let remaining = store.unresolved_health_findings(&store.resolved_health_ids()?)?;
11269        require_eq(&remaining.is_empty(), &true, "resolved SQL health finding")?;
11270        Ok(())
11271    }
11272
11273    #[test]
11274    fn unresolved_health_findings_page_filters_and_bounds_rows() -> Result<(), Box<dyn Error>> {
11275        let mut store = AtlasStore::in_memory()?;
11276        store.replace_scan(&[
11277            test_folder_node("."),
11278            test_folder_node("src"),
11279            test_file_node("src/a.rs", "hash-a"),
11280            test_file_node("docs/a.rs", "hash-doc"),
11281        ])?;
11282        let query = HealthQuery {
11283            start_index: 1,
11284            limit: 1,
11285            category: Some("missing-purpose".to_string()),
11286            severity: Some(Severity::Warning),
11287            path_prefix: Some("src".to_string()),
11288            summary_only: false,
11289            scope: HealthScope::all(),
11290        };
11291
11292        let page = store.unresolved_health_findings_page(&[], &query)?;
11293        require_eq(&page.unfiltered_total, &4, "unfiltered health total")?;
11294        require_eq(&page.total, &2, "filtered health total")?;
11295        require_eq(&page.returned, &1, "returned health rows")?;
11296        require_eq(
11297            &page.findings[0].path,
11298            &"src/a.rs".to_string(),
11299            "paged path",
11300        )?;
11301
11302        let summary_page = store.unresolved_health_findings_page(
11303            &[],
11304            &HealthQuery {
11305                summary_only: true,
11306                ..query
11307            },
11308        )?;
11309        require_eq(&summary_page.total, &2, "summary-only total")?;
11310        require_eq(
11311            &summary_page.findings.is_empty(),
11312            &true,
11313            "summary-only rows",
11314        )?;
11315        Ok(())
11316    }
11317
11318    #[test]
11319    fn unresolved_health_findings_page_skips_resolved_lifecycle_rows_before_paging()
11320    -> Result<(), Box<dyn Error>> {
11321        let mut store = AtlasStore::in_memory()?;
11322        store.replace_scan(&[
11323            test_file_node("src/a.rs", "hash-a"),
11324            test_file_node("src/b.rs", "hash-b"),
11325            test_file_node("src/c.rs", "hash-c"),
11326            test_file_node("src/d.rs", "hash-d"),
11327        ])?;
11328        store.resolve_health_finding(&HealthResolution {
11329            finding_id: finding_id("missing-purpose", "src/b.rs", None),
11330            category: "missing-purpose".to_string(),
11331            path: "src/b.rs".to_string(),
11332            related_path: None,
11333            rationale: "Resolved for pagination regression.".to_string(),
11334        })?;
11335
11336        let page = store.unresolved_health_findings_page_current(&HealthQuery {
11337            start_index: 0,
11338            limit: 2,
11339            category: Some("missing-purpose".to_string()),
11340            severity: Some(Severity::Warning),
11341            path_prefix: Some("src".to_string()),
11342            summary_only: false,
11343            scope: HealthScope::all(),
11344        })?;
11345
11346        require_eq(&page.total, &3, "filtered unresolved missing total")?;
11347        require_eq(&page.returned, &2, "returned unresolved missing rows")?;
11348        require_eq(
11349            &page
11350                .findings
11351                .iter()
11352                .map(|finding| finding.path.as_str())
11353                .collect::<Vec<_>>(),
11354            &vec!["src/a.rs", "src/c.rs"],
11355            "resolved row skipped before limit",
11356        )?;
11357        Ok(())
11358    }
11359
11360    #[test]
11361    fn stored_health_resolution_filter_stays_indexed_and_bind_bounded() -> Result<(), Box<dyn Error>>
11362    {
11363        const HISTORICAL_RESOLUTIONS: usize = 1_500;
11364
11365        let temp = tempfile::tempdir()?;
11366        let root = temp.path().join("repository");
11367        fs::create_dir_all(root.join("src"))?;
11368        let database = temp.path().join("projectatlas.db");
11369        let mut store = AtlasStore::open_for_project(&database, &root)?;
11370        store.replace_scan(&[test_file_node("src/current.rs", "hash-current")])?;
11371        {
11372            let transaction = store.connection.transaction()?;
11373            {
11374                let mut statement = transaction.prepare(
11375                    "
11376                    INSERT INTO health_resolutions(
11377                        finding_id, category, path, related_path, rationale, resolved_by
11378                    )
11379                    VALUES(?1, 'missing-purpose', ?2, NULL, 'historical', 'agent')
11380                    ",
11381                )?;
11382                for index in 0..HISTORICAL_RESOLUTIONS {
11383                    let path = format!("removed/{index}.rs");
11384                    statement.execute(params![finding_id("missing-purpose", &path, None), path])?;
11385                }
11386            }
11387            transaction.commit()?;
11388        }
11389
11390        drop(store);
11391        let store = AtlasStore::open_read_only_for_project(&database, &root)?;
11392        let page = store.unresolved_health_findings_page_current(&HealthQuery {
11393            start_index: 0,
11394            limit: 2,
11395            category: Some("missing-purpose".to_string()),
11396            severity: Some(Severity::Warning),
11397            path_prefix: Some("src".to_string()),
11398            summary_only: false,
11399            scope: HealthScope::all(),
11400        })?;
11401        require_eq(&page.total, &1, "current unresolved total")?;
11402        require_eq(
11403            &page.findings[0].path,
11404            &"src/current.rs".to_string(),
11405            "current unresolved path",
11406        )?;
11407
11408        let (where_clause, values) = purpose_status_where_clause(
11409            PURPOSE_HEALTH_SPECS[0],
11410            None,
11411            HealthResolutionFilter::Stored,
11412            HealthScope::all(),
11413        );
11414        require_eq(&values.len(), &1, "stored filter bind count")?;
11415        let plan_sql = format!(
11416            "EXPLAIN QUERY PLAN
11417             SELECT COUNT(*)
11418             FROM nodes n
11419             JOIN purposes p ON p.node_id = n.id
11420             WHERE {where_clause}"
11421        );
11422        let mut statement = store.connection.prepare(&plan_sql)?;
11423        let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(3))?;
11424        let mut plan = Vec::new();
11425        for row in rows {
11426            plan.push(row?);
11427        }
11428        if !plan.iter().any(|detail| {
11429            detail.contains("health_resolutions")
11430                && detail.contains("finding_id")
11431                && detail.contains("SEARCH")
11432        }) {
11433            return Err(io::Error::other(format!(
11434                "stored resolution anti-lookup did not use the finding-id index: {plan:?}"
11435            ))
11436            .into());
11437        }
11438        store.finish_index_read_snapshot()?;
11439        Ok(())
11440    }
11441
11442    #[test]
11443    fn unresolved_health_findings_page_streams_duplicate_and_temp_rows()
11444    -> Result<(), Box<dyn Error>> {
11445        let mut store = AtlasStore::in_memory()?;
11446        store.replace_scan(&[
11447            test_folder_node("."),
11448            test_folder_node("tmp"),
11449            test_folder_node("src"),
11450            test_folder_node("src/tmp"),
11451            test_file_node("src/a.rs", "hash-a"),
11452            test_file_node("src/b.rs", "hash-b"),
11453        ])?;
11454        store.set_purpose(".", "Repository root", PurposeSource::Agent)?;
11455        store.set_purpose("src", "Source folder", PurposeSource::Agent)?;
11456        store.set_purpose("tmp", "Temporary output", PurposeSource::Agent)?;
11457        store.set_purpose("src/tmp", "Source temporary output", PurposeSource::Agent)?;
11458        store.set_purpose("src/a.rs", "Shared implementation", PurposeSource::Agent)?;
11459        store.set_purpose("src/b.rs", "Shared implementation", PurposeSource::Agent)?;
11460
11461        let duplicate_page = store.unresolved_health_findings_page(
11462            &[],
11463            &HealthQuery {
11464                start_index: 0,
11465                limit: 1,
11466                category: Some("duplicate-purpose".to_string()),
11467                severity: Some(Severity::Warning),
11468                path_prefix: Some("src".to_string()),
11469                summary_only: false,
11470                scope: HealthScope::all(),
11471            },
11472        )?;
11473        require_eq(&duplicate_page.total, &1, "duplicate total")?;
11474        require_eq(&duplicate_page.returned, &1, "duplicate returned")?;
11475        require_eq(
11476            &duplicate_page.findings[0].category,
11477            &"duplicate-purpose".to_string(),
11478            "duplicate category",
11479        )?;
11480
11481        let temp_page = store.unresolved_health_findings_page(
11482            &[],
11483            &HealthQuery {
11484                start_index: 0,
11485                limit: 1,
11486                category: Some("repeated-temporary-folder".to_string()),
11487                severity: Some(Severity::Warning),
11488                path_prefix: Some(".".to_string()),
11489                summary_only: false,
11490                scope: HealthScope::all(),
11491            },
11492        )?;
11493        require_eq(&temp_page.total, &1, "temp total")?;
11494        require_eq(&temp_page.returned, &1, "temp returned")?;
11495        require_eq(
11496            &temp_page.findings[0].category,
11497            &"repeated-temporary-folder".to_string(),
11498            "temp category",
11499        )?;
11500        Ok(())
11501    }
11502
11503    #[test]
11504    fn unresolved_health_findings_page_source_only_filters_asset_noise()
11505    -> Result<(), Box<dyn Error>> {
11506        let mut store = AtlasStore::in_memory()?;
11507        let asset_file = Node {
11508            path: "assets/logo.png".to_string(),
11509            kind: NodeKind::File,
11510            parent_path: Some("assets".to_string()),
11511            extension: Some(".png".to_string()),
11512            language: None,
11513            size_bytes: Some(42),
11514            mtime_ns: Some(10),
11515            content_hash: Some("hash-logo".to_string()),
11516        };
11517        store.replace_scan(&[
11518            test_folder_node("."),
11519            test_folder_node("src"),
11520            test_file_node("src/main.rs", "hash-main"),
11521            test_folder_node("assets"),
11522            asset_file,
11523        ])?;
11524
11525        let page = store.unresolved_health_findings_page(
11526            &[],
11527            &HealthQuery {
11528                start_index: 0,
11529                limit: 10,
11530                category: Some("missing-purpose".to_string()),
11531                severity: Some(Severity::Warning),
11532                path_prefix: Some(".".to_string()),
11533                summary_only: false,
11534                scope: HealthScope::source_only(),
11535            },
11536        )?;
11537
11538        require_eq(&page.unfiltered_total, &5, "all unresolved rows")?;
11539        require_eq(&page.total, &3, "source-only missing total")?;
11540        require_eq(
11541            &page
11542                .findings
11543                .iter()
11544                .map(|finding| finding.path.as_str())
11545                .collect::<Vec<_>>(),
11546            &vec![".", "src", "src/main.rs"],
11547            "source-only paths",
11548        )?;
11549        Ok(())
11550    }
11551
11552    #[test]
11553    fn high_impact_purpose_queue_filters_low_priority_files() -> Result<(), Box<dyn Error>> {
11554        let mut store = AtlasStore::in_memory()?;
11555        store.replace_scan(&[
11556            test_folder_node("."),
11557            test_folder_node("src"),
11558            test_file_node("src/main.rs", "hash-main"),
11559            test_file_node("src/helper.rs", "hash-helper"),
11560            test_file_node("build.gradle.kts", "hash-gradle"),
11561        ])?;
11562
11563        let page = store.unresolved_health_findings_page(
11564            &[],
11565            &HealthQuery {
11566                start_index: 0,
11567                limit: 10,
11568                category: Some("missing-purpose".to_string()),
11569                severity: Some(Severity::Warning),
11570                path_prefix: Some(".".to_string()),
11571                summary_only: false,
11572                scope: HealthScope::purpose_default(),
11573            },
11574        )?;
11575
11576        require_eq(&page.unfiltered_total, &5, "all missing rows")?;
11577        require_eq(&page.total, &4, "default actionable rows")?;
11578        require_eq(
11579            &page
11580                .findings
11581                .iter()
11582                .map(|finding| finding.path.as_str())
11583                .collect::<Vec<_>>(),
11584            &vec![".", "src", "build.gradle.kts", "src/main.rs"],
11585            "folder-first high-impact queue paths",
11586        )?;
11587
11588        let broad_page = store.unresolved_health_findings_page(
11589            &[],
11590            &HealthQuery {
11591                start_index: 0,
11592                limit: 10,
11593                category: Some("missing-purpose".to_string()),
11594                severity: Some(Severity::Warning),
11595                path_prefix: Some(".".to_string()),
11596                summary_only: false,
11597                scope: HealthScope::all(),
11598            },
11599        )?;
11600        require_eq(&broad_page.total, &5, "explicit broad queue rows")?;
11601        Ok(())
11602    }
11603
11604    #[test]
11605    fn high_impact_purpose_queue_keeps_asset_only_folders_without_asset_files()
11606    -> Result<(), Box<dyn Error>> {
11607        let mut store = AtlasStore::in_memory()?;
11608        let asset_file = Node {
11609            path: "assets/logo.svg".to_string(),
11610            kind: NodeKind::File,
11611            parent_path: Some("assets".to_string()),
11612            extension: Some(".svg".to_string()),
11613            language: None,
11614            size_bytes: Some(42),
11615            mtime_ns: Some(10),
11616            content_hash: Some("hash-logo".to_string()),
11617        };
11618        store.replace_scan(&[
11619            test_folder_node("."),
11620            test_folder_node("assets"),
11621            test_folder_node("src"),
11622            asset_file,
11623            test_file_node("src/helper.rs", "hash-helper"),
11624        ])?;
11625
11626        let page = store.unresolved_health_findings_page(
11627            &[],
11628            &HealthQuery {
11629                start_index: 0,
11630                limit: 10,
11631                category: Some("missing-purpose".to_string()),
11632                severity: Some(Severity::Warning),
11633                path_prefix: Some(".".to_string()),
11634                summary_only: false,
11635                scope: HealthScope::purpose_default(),
11636            },
11637        )?;
11638
11639        require_eq(&page.unfiltered_total, &5, "all missing rows")?;
11640        require_eq(&page.total, &3, "all folders without asset files")?;
11641        require_eq(
11642            &page
11643                .findings
11644                .iter()
11645                .map(|finding| finding.path.as_str())
11646                .collect::<Vec<_>>(),
11647            &vec![".", "assets", "src"],
11648            "folder-first default queue keeps asset-only folders",
11649        )?;
11650        Ok(())
11651    }
11652
11653    #[test]
11654    fn high_impact_purpose_queue_pages_folders_before_files() -> Result<(), Box<dyn Error>> {
11655        let mut store = AtlasStore::in_memory()?;
11656        store.replace_scan(&[
11657            test_folder_node("."),
11658            test_folder_node("src"),
11659            test_file_node("Cargo.toml", "hash-cargo"),
11660            test_file_node("package.json", "hash-package"),
11661            test_file_node("pyproject.toml", "hash-python"),
11662        ])?;
11663
11664        let page = store.unresolved_health_findings_page(
11665            &[],
11666            &HealthQuery {
11667                start_index: 0,
11668                limit: 2,
11669                category: Some("missing-purpose".to_string()),
11670                severity: Some(Severity::Warning),
11671                path_prefix: Some(".".to_string()),
11672                summary_only: false,
11673                scope: HealthScope::purpose_default(),
11674            },
11675        )?;
11676
11677        require_eq(&page.total, &5, "default actionable total")?;
11678        require_eq(
11679            &page
11680                .findings
11681                .iter()
11682                .map(|finding| finding.path.as_str())
11683                .collect::<Vec<_>>(),
11684            &vec![".", "src"],
11685            "small page keeps folders first",
11686        )?;
11687        Ok(())
11688    }
11689
11690    #[test]
11691    fn high_impact_purpose_queue_omits_low_value_stale_files() -> Result<(), Box<dyn Error>> {
11692        let mut store = AtlasStore::in_memory()?;
11693        store.replace_scan(&[
11694            test_file_node("src/helper.rs", "hash-a"),
11695            test_file_node("Cargo.toml", "hash-cargo"),
11696            test_file_node("package.json", "hash-package"),
11697        ])?;
11698        store.set_purpose(
11699            "src/helper.rs",
11700            "Reviewed helper implementation.",
11701            PurposeSource::Agent,
11702        )?;
11703        store.set_purpose(
11704            "Cargo.toml",
11705            "Reviewed Cargo manifest.",
11706            PurposeSource::Agent,
11707        )?;
11708        store.connection.execute(
11709            "UPDATE purposes
11710                SET status = 'stale'
11711              WHERE node_id IN (
11712                  SELECT id FROM nodes WHERE path IN ('src/helper.rs', 'Cargo.toml')
11713              )",
11714            [],
11715        )?;
11716        store.replace_scan(&[
11717            test_file_node("src/helper.rs", "hash-b"),
11718            test_file_node("Cargo.toml", "hash-cargo-new"),
11719            test_file_node("package.json", "hash-package"),
11720        ])?;
11721
11722        let page = store.unresolved_health_findings_page(
11723            &[],
11724            &HealthQuery {
11725                start_index: 0,
11726                limit: 1,
11727                category: None,
11728                severity: Some(Severity::Warning),
11729                path_prefix: Some(".".to_string()),
11730                summary_only: false,
11731                scope: HealthScope::purpose_default(),
11732            },
11733        )?;
11734
11735        require_eq(&page.total, &2, "default actionable total")?;
11736        require_eq(&page.returned, &1, "small page returned")?;
11737        require_eq(
11738            &page.findings[0].category,
11739            &"stale-purpose".to_string(),
11740            "stale high-impact file is still prioritized",
11741        )?;
11742        require_eq(
11743            &page.findings[0].path,
11744            &"Cargo.toml".to_string(),
11745            "stale high-impact file path",
11746        )?;
11747
11748        let broad_page = store.unresolved_health_findings_page(
11749            &[],
11750            &HealthQuery {
11751                start_index: 0,
11752                limit: 10,
11753                category: Some(CATEGORY_STALE_PURPOSE.to_string()),
11754                severity: Some(Severity::Warning),
11755                path_prefix: Some(".".to_string()),
11756                summary_only: false,
11757                scope: HealthScope::purpose_with_source_files(),
11758            },
11759        )?;
11760        require_eq(&broad_page.total, &2, "broad source stale rows")?;
11761        require_eq(
11762            &health_paths(&broad_page),
11763            &vec!["Cargo.toml", "src/helper.rs"],
11764            "broad scope includes low-value stale source files",
11765        )?;
11766        Ok(())
11767    }
11768
11769    #[test]
11770    fn include_assets_queue_includes_asset_files_not_low_priority_source()
11771    -> Result<(), Box<dyn Error>> {
11772        let mut store = AtlasStore::in_memory()?;
11773        let asset_file = Node {
11774            path: "assets/logo.svg".to_string(),
11775            kind: NodeKind::File,
11776            parent_path: Some("assets".to_string()),
11777            extension: Some(".svg".to_string()),
11778            language: None,
11779            size_bytes: Some(42),
11780            mtime_ns: Some(10),
11781            content_hash: Some("hash-logo".to_string()),
11782        };
11783        store.replace_scan(&[
11784            test_folder_node("."),
11785            test_folder_node("assets"),
11786            test_folder_node("src"),
11787            asset_file,
11788            test_file_node("src/helper.rs", "hash-helper"),
11789        ])?;
11790
11791        let page = store.unresolved_health_findings_page(
11792            &[],
11793            &HealthQuery {
11794                start_index: 0,
11795                limit: 10,
11796                category: Some("missing-purpose".to_string()),
11797                severity: Some(Severity::Warning),
11798                path_prefix: Some(".".to_string()),
11799                summary_only: false,
11800                scope: HealthScope::purpose_with_assets(),
11801            },
11802        )?;
11803
11804        require_eq(&page.unfiltered_total, &5, "all missing rows")?;
11805        require_eq(
11806            &page.total,
11807            &4,
11808            "assets included without broad source cleanup",
11809        )?;
11810        require_eq(
11811            &page
11812                .findings
11813                .iter()
11814                .map(|finding| finding.path.as_str())
11815                .collect::<Vec<_>>(),
11816            &vec![".", "assets", "src", "assets/logo.svg"],
11817            "asset files included and low-priority source omitted",
11818        )?;
11819        Ok(())
11820    }
11821
11822    #[test]
11823    fn legacy_human_stale_files_remain_in_default_queue() -> Result<(), Box<dyn Error>> {
11824        let mut store = AtlasStore::in_memory()?;
11825        store.replace_scan(&[test_file_node("src/helper.rs", "hash-a")])?;
11826        store.set_purpose(
11827            "src/helper.rs",
11828            "Legacy reviewed helper implementation.",
11829            PurposeSource::Agent,
11830        )?;
11831        store.connection.execute(
11832            "
11833            UPDATE purposes
11834            SET source = 'human', status = 'stale'
11835            WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/helper.rs')
11836            ",
11837            [],
11838        )?;
11839        store.replace_scan(&[test_file_node("src/helper.rs", "hash-b")])?;
11840
11841        let page = store.unresolved_health_findings_page(
11842            &[],
11843            &HealthQuery {
11844                start_index: 0,
11845                limit: 10,
11846                category: Some("stale-purpose".to_string()),
11847                severity: Some(Severity::Warning),
11848                path_prefix: Some(".".to_string()),
11849                summary_only: false,
11850                scope: HealthScope::purpose_default(),
11851            },
11852        )?;
11853
11854        require_eq(&page.total, &1, "legacy reviewed stale row total")?;
11855        require_eq(
11856            &page.findings[0].path,
11857            &"src/helper.rs".to_string(),
11858            "legacy reviewed stale file",
11859        )?;
11860        Ok(())
11861    }
11862
11863    #[test]
11864    fn stale_imported_files_remain_in_default_queue() -> Result<(), Box<dyn Error>> {
11865        let mut store = AtlasStore::in_memory()?;
11866        store.replace_scan(&[
11867            test_file_node("src/imported.rs", "hash-a"),
11868            test_file_node("Cargo.toml", "hash-cargo"),
11869        ])?;
11870        store.set_purpose(
11871            "src/imported.rs",
11872            "Imported helper implementation.",
11873            PurposeSource::Imported,
11874        )?;
11875        store.connection.execute(
11876            "UPDATE purposes
11877                SET status = 'stale'
11878              WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/imported.rs')",
11879            [],
11880        )?;
11881        store.replace_scan(&[
11882            test_file_node("src/imported.rs", "hash-b"),
11883            test_file_node("Cargo.toml", "hash-cargo"),
11884        ])?;
11885
11886        let page = store.unresolved_health_findings_page(
11887            &[],
11888            &HealthQuery {
11889                start_index: 0,
11890                limit: 10,
11891                category: None,
11892                severity: Some(Severity::Warning),
11893                path_prefix: Some(".".to_string()),
11894                summary_only: false,
11895                scope: HealthScope::purpose_default(),
11896            },
11897        )?;
11898
11899        require_eq(
11900            &page.total,
11901            &2,
11902            "default queue includes stale imported file",
11903        )?;
11904        require_eq(
11905            &health_paths(&page),
11906            &vec!["src/imported.rs", "Cargo.toml"],
11907            "stale imported file is queued before high-impact files",
11908        )?;
11909        require_eq(
11910            &page.findings[0].category,
11911            &"stale-purpose".to_string(),
11912            "stale imported finding category",
11913        )?;
11914        Ok(())
11915    }
11916
11917    #[test]
11918    fn duplicate_purpose_health_is_contextual_for_folders() -> Result<(), Box<dyn Error>> {
11919        let mut store = AtlasStore::in_memory()?;
11920        store.replace_scan(&[
11921            test_folder_node("."),
11922            test_folder_node("customers"),
11923            test_folder_node("customers/service"),
11924            test_folder_node("settings"),
11925            test_folder_node("settings/service"),
11926        ])?;
11927        store.set_purpose("customers/service", "Service layer", PurposeSource::Agent)?;
11928        store.set_purpose("settings/service", "Service layer", PurposeSource::Agent)?;
11929
11930        let page = store.unresolved_health_findings_page(
11931            &[],
11932            &HealthQuery {
11933                start_index: 0,
11934                limit: 10,
11935                category: Some("duplicate-purpose".to_string()),
11936                severity: Some(Severity::Warning),
11937                path_prefix: Some(".".to_string()),
11938                summary_only: false,
11939                scope: HealthScope::all(),
11940            },
11941        )?;
11942
11943        require_eq(&page.total, &0, "folder duplicates scoped by parent")?;
11944        Ok(())
11945    }
11946
11947    #[test]
11948    fn contextual_folder_duplicate_identity_matches_unpaged_health_and_resolution()
11949    -> Result<(), Box<dyn Error>> {
11950        let mut store = AtlasStore::in_memory()?;
11951        store.replace_scan(&[
11952            test_folder_node("."),
11953            test_folder_node("customers"),
11954            test_folder_node("customers/service"),
11955            test_folder_node("settings"),
11956            test_folder_node("settings/service"),
11957            test_folder_node("settings/worker"),
11958        ])?;
11959        store.set_purpose("customers/service", "Service layer", PurposeSource::Agent)?;
11960        store.set_purpose("settings/service", "Service layer", PurposeSource::Agent)?;
11961        store.set_purpose("settings/worker", "Service layer", PurposeSource::Agent)?;
11962
11963        let page = store.unresolved_health_findings_page(
11964            &[],
11965            &HealthQuery {
11966                start_index: 0,
11967                limit: 10,
11968                category: Some("duplicate-purpose".to_string()),
11969                severity: Some(Severity::Warning),
11970                path_prefix: Some(".".to_string()),
11971                summary_only: false,
11972                scope: HealthScope::all(),
11973            },
11974        )?;
11975        require_eq(&page.total, &1, "contextual duplicate total")?;
11976        let paged_finding = page
11977            .findings
11978            .first()
11979            .ok_or_else(|| io::Error::other("paged duplicate missing"))?;
11980        require_eq(
11981            &paged_finding.path,
11982            &"settings/worker".to_string(),
11983            "paged duplicate path",
11984        )?;
11985        require_eq(
11986            &paged_finding.related_path,
11987            &Some("settings/service".to_string()),
11988            "paged related path",
11989        )?;
11990
11991        let unpaged_duplicates = store
11992            .unresolved_health_findings(&[])?
11993            .into_iter()
11994            .filter(|finding| finding.category == "duplicate-purpose")
11995            .collect::<Vec<_>>();
11996        require_eq(
11997            &unpaged_duplicates,
11998            &page.findings,
11999            "unpaged duplicate identity",
12000        )?;
12001
12002        store.resolve_health_finding(&HealthResolution {
12003            finding_id: paged_finding.id.clone(),
12004            category: paged_finding.category.clone(),
12005            path: paged_finding.path.clone(),
12006            related_path: paged_finding.related_path.clone(),
12007            rationale: "Settings service and worker intentionally share a layer purpose."
12008                .to_string(),
12009        })?;
12010        let has_remaining_duplicate = store
12011            .unresolved_health_findings(&store.resolved_health_ids()?)?
12012            .into_iter()
12013            .any(|finding| finding.category == "duplicate-purpose");
12014        require_eq(
12015            &has_remaining_duplicate,
12016            &false,
12017            "resolved contextual duplicate",
12018        )?;
12019        Ok(())
12020    }
12021
12022    #[test]
12023    fn agent_review_required_scope_expands_from_low_to_strict() -> Result<(), Box<dyn Error>> {
12024        let mut store = AtlasStore::in_memory()?;
12025        let asset_file = Node {
12026            path: "assets/logo.svg".to_string(),
12027            kind: NodeKind::File,
12028            parent_path: Some("assets".to_string()),
12029            extension: Some(".svg".to_string()),
12030            language: None,
12031            size_bytes: Some(42),
12032            mtime_ns: Some(10),
12033            content_hash: Some("hash-logo".to_string()),
12034        };
12035        store.replace_scan(&[
12036            test_folder_node("."),
12037            test_folder_node("assets"),
12038            test_folder_node("src"),
12039            test_file_node("Cargo.toml", "hash-cargo"),
12040            test_file_node("src/detail.rs", "hash-detail"),
12041            asset_file,
12042        ])?;
12043        for (path, purpose) in [
12044            (".", "Imported repository root"),
12045            ("assets", "Imported asset folder"),
12046            ("src", "Imported Rust source folder"),
12047            ("Cargo.toml", "Imported Rust manifest"),
12048            ("src/detail.rs", "Imported implementation detail"),
12049            ("assets/logo.svg", "Imported SVG brand asset"),
12050        ] {
12051            store.set_purpose(path, purpose, PurposeSource::Imported)?;
12052        }
12053
12054        let low = store.unresolved_health_findings_page(
12055            &[],
12056            &HealthQuery {
12057                start_index: 0,
12058                limit: 20,
12059                category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
12060                severity: Some(Severity::Warning),
12061                path_prefix: Some(".".to_string()),
12062                summary_only: false,
12063                scope: HealthScope::purpose_default(),
12064            },
12065        )?;
12066        require_eq(
12067            &health_paths(&low),
12068            &vec![".", "assets", "src", "Cargo.toml"],
12069            "low purpose review scope",
12070        )?;
12071        require_eq(
12072            &low.unfiltered_total,
12073            &6,
12074            "agent-review findings are counted once in unfiltered total",
12075        )?;
12076
12077        let asset_scope = store.unresolved_health_findings_page(
12078            &[],
12079            &HealthQuery {
12080                scope: HealthScope::purpose_with_assets(),
12081                ..low_query()
12082            },
12083        )?;
12084        require_eq(
12085            &health_paths(&asset_scope),
12086            &vec![".", "assets", "src", "Cargo.toml", "assets/logo.svg"],
12087            "asset purpose review scope",
12088        )?;
12089
12090        let medium = store.unresolved_health_findings_page(
12091            &[],
12092            &HealthQuery {
12093                scope: HealthScope::purpose_with_source_files(),
12094                ..low_query()
12095            },
12096        )?;
12097        require_eq(
12098            &health_paths(&medium),
12099            &vec![".", "assets", "src", "Cargo.toml", "src/detail.rs"],
12100            "medium purpose review scope",
12101        )?;
12102
12103        let strict = store.unresolved_health_findings_page(
12104            &[],
12105            &HealthQuery {
12106                scope: HealthScope::purpose_strict(),
12107                ..low_query()
12108            },
12109        )?;
12110        require_eq(
12111            &health_paths(&strict),
12112            &vec![
12113                ".",
12114                "assets",
12115                "src",
12116                "Cargo.toml",
12117                "assets/logo.svg",
12118                "src/detail.rs",
12119            ],
12120            "strict purpose review scope",
12121        )?;
12122
12123        let all = store.unresolved_health_findings_page(
12124            &[],
12125            &HealthQuery {
12126                scope: HealthScope::all(),
12127                ..low_query()
12128            },
12129        )?;
12130        require_eq(
12131            &health_paths(&all),
12132            &health_paths(&strict),
12133            "all health scope should include every purpose review candidate",
12134        )?;
12135        Ok(())
12136    }
12137
12138    #[test]
12139    fn replace_scan_preserves_curated_purposes_and_reconciles_changed_paths()
12140    -> Result<(), Box<dyn Error>> {
12141        let mut store = AtlasStore::in_memory()?;
12142        store.replace_scan(&[
12143            test_folder_node("."),
12144            test_folder_node("src"),
12145            test_file_node("src/main.rs", "hash-a"),
12146        ])?;
12147        store.set_purpose(".", "Agent-reviewed repository root", PurposeSource::Agent)?;
12148        store.set_purpose(
12149            "src",
12150            "Agent-reviewed Rust source folder",
12151            PurposeSource::Agent,
12152        )?;
12153        store.set_purpose(
12154            "src/main.rs",
12155            "Agent-reviewed Rust entry point",
12156            PurposeSource::Agent,
12157        )?;
12158
12159        store.replace_scan(&[
12160            test_folder_node("."),
12161            test_folder_node("src"),
12162            test_file_node("src/main.rs", "hash-a"),
12163            test_file_node("src/new.rs", "hash-new"),
12164        ])?;
12165        let nodes = store.load_nodes_by_paths(&[
12166            ".".to_string(),
12167            "src".to_string(),
12168            "src/main.rs".to_string(),
12169            "src/new.rs".to_string(),
12170        ])?;
12171        let by_path = nodes
12172            .iter()
12173            .map(|node| (node.node.path.as_str(), node))
12174            .collect::<HashMap<_, _>>();
12175        require_eq(
12176            &by_path["src/main.rs"].purpose.purpose,
12177            &Some("Agent-reviewed Rust entry point".to_string()),
12178            "unchanged file purpose preserved",
12179        )?;
12180        require_eq(
12181            &by_path["src/main.rs"].purpose.status,
12182            &PurposeStatus::Approved,
12183            "unchanged file purpose stays approved",
12184        )?;
12185        require_eq(
12186            &by_path["src/new.rs"].purpose.status,
12187            &PurposeStatus::Missing,
12188            "new file starts missing",
12189        )?;
12190
12191        store.replace_scan(&[
12192            test_folder_node("."),
12193            test_folder_node("src"),
12194            test_file_node("src/main.rs", "hash-b"),
12195            test_file_node("src/new.rs", "hash-new"),
12196        ])?;
12197        let changed = store
12198            .load_nodes_by_paths(&["src/main.rs".to_string()])?
12199            .pop()
12200            .ok_or_else(|| io::Error::other("changed node missing"))?;
12201        require_eq(
12202            &changed.purpose.purpose,
12203            &Some("Agent-reviewed Rust entry point".to_string()),
12204            "changed file purpose text preserved",
12205        )?;
12206        require_eq(
12207            &changed.purpose.status,
12208            &PurposeStatus::Approved,
12209            "changed file purpose stays approved",
12210        )?;
12211
12212        store.replace_scan(&[test_folder_node("."), test_folder_node("src")])?;
12213        let removed = store.load_nodes_by_paths(&["src/main.rs".to_string()])?;
12214        require_eq(&removed.is_empty(), &true, "removed file is inactive")?;
12215
12216        let dormant = store.connection.query_row(
12217            "SELECT n.exists_now, p.purpose, p.status
12218               FROM nodes AS n
12219               JOIN purposes AS p ON p.node_id = n.id
12220              WHERE n.path = 'src/main.rs'",
12221            [],
12222            |row| {
12223                Ok((
12224                    row.get::<_, i64>(0)?,
12225                    row.get::<_, Option<String>>(1)?,
12226                    row.get::<_, String>(2)?,
12227                ))
12228            },
12229        )?;
12230        require_eq(
12231            &dormant,
12232            &(
12233                0,
12234                Some("Agent-reviewed Rust entry point".to_string()),
12235                PurposeStatus::Approved.as_str().to_string(),
12236            ),
12237            "removed file keeps a dormant approved purpose",
12238        )?;
12239
12240        store.replace_scan(&[
12241            test_folder_node("."),
12242            test_folder_node("src"),
12243            test_file_node("src/renamed.rs", "hash-b"),
12244        ])?;
12245        let renamed = store
12246            .load_node_by_path("src/renamed.rs")?
12247            .ok_or_else(|| io::Error::other("renamed path missing"))?;
12248        require_eq(
12249            &renamed.purpose.status,
12250            &PurposeStatus::Missing,
12251            "rename does not transfer approval",
12252        )?;
12253
12254        store.replace_scan(&[
12255            test_folder_node("."),
12256            test_folder_node("src"),
12257            test_file_node("src/main.rs", "hash-c"),
12258        ])?;
12259        let reactivated = store
12260            .load_node_by_path("src/main.rs")?
12261            .ok_or_else(|| io::Error::other("reactivated path missing"))?;
12262        require_eq(
12263            &reactivated.purpose.purpose,
12264            &Some("Agent-reviewed Rust entry point".to_string()),
12265            "exact-path reactivation restores the dormant purpose",
12266        )?;
12267        require_eq(
12268            &reactivated.purpose.status,
12269            &PurposeStatus::Approved,
12270            "exact-path reactivation restores approval",
12271        )?;
12272        Ok(())
12273    }
12274
12275    #[test]
12276    fn file_token_estimates_are_visited_without_loading_nodes() -> Result<(), Box<dyn Error>> {
12277        let mut store = AtlasStore::in_memory()?;
12278        store.replace_scan(&[
12279            test_file_node("src/a.rs", "hash-a"),
12280            test_file_node("tests/b.rs", "hash-b"),
12281        ])?;
12282
12283        let mut visited = Vec::new();
12284        store.visit_file_token_estimates(Some("src"), |path, size_bytes| {
12285            visited.push((path, size_bytes));
12286            Ok(true)
12287        })?;
12288        require_eq(
12289            &visited,
12290            &vec![("src/a.rs".to_string(), Some(12))],
12291            "folder-scoped token estimate rows",
12292        )?;
12293        Ok(())
12294    }
12295
12296    #[test]
12297    fn indexed_file_text_replaces_and_clears_stale_rows() -> Result<(), Box<dyn Error>> {
12298        let mut store = AtlasStore::in_memory()?;
12299        store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
12300        store.replace_file_texts_for_paths(
12301            &["src/main.rs".to_string()],
12302            &[IndexedFileText {
12303                path: "src/main.rs".to_string(),
12304                content_hash: Some("hash-a".to_string()),
12305                byte_count: "needle old\n".len(),
12306                line_count: 1,
12307                content: "needle old\n".to_string(),
12308            }],
12309        )?;
12310        let texts = store.load_file_texts_for_search(Some("needle"), true)?;
12311        require_eq(&texts.len(), &1, "indexed text row count")?;
12312        require_eq(
12313            &store.load_file_text("src/main.rs")?,
12314            &texts.first().cloned(),
12315            "initial persisted-text read",
12316        )?;
12317
12318        store.replace_file_texts_for_paths(&["src/main.rs".to_string()], &[])?;
12319        let missing = store.load_file_text("src/main.rs")?;
12320        require_eq(&missing.is_none(), &true, "cleared stale indexed text")?;
12321        Ok(())
12322    }
12323
12324    #[test]
12325    fn indexed_text_candidates_preserve_unicode_folding_and_early_stop()
12326    -> Result<(), Box<dyn Error>> {
12327        let mut store = AtlasStore::in_memory()?;
12328        let paths = ["src/a.rs".to_string(), "src/b.rs".to_string()];
12329        store.replace_scan(&[
12330            test_file_node(&paths[0], "hash-a"),
12331            test_file_node(&paths[1], "hash-b"),
12332        ])?;
12333        let content = "für \u{212a}elvin ſource\n";
12334        let texts = paths
12335            .iter()
12336            .map(|path| IndexedFileText {
12337                path: path.clone(),
12338                content_hash: None,
12339                byte_count: content.len(),
12340                line_count: 1,
12341                content: content.to_string(),
12342            })
12343            .collect::<Vec<_>>();
12344        store.replace_file_texts_for_paths(&paths, &texts)?;
12345        for pattern in ["FÜR", "kelvin", "source"] {
12346            let mut visited = Vec::new();
12347            store.visit_file_texts_for_search(Some(pattern), false, |text| {
12348                visited.push(text.path);
12349                Ok(false)
12350            })?;
12351            require_eq(
12352                &visited,
12353                &vec![paths[0].clone()],
12354                "Unicode candidate early stop",
12355            )?;
12356            require_eq(
12357                &store.load_file_texts_for_search(Some(pattern), true)?.len(),
12358                &0,
12359                "case-sensitive candidate filter",
12360            )?;
12361        }
12362        require_eq(
12363            &store.load_file_texts_for_search(Some("für"), true)?.len(),
12364            &2,
12365            "exact substring candidates",
12366        )?;
12367        Ok(())
12368    }
12369
12370    #[test]
12371    fn indexed_file_text_search_can_stop_without_collecting_all_rows() -> Result<(), Box<dyn Error>>
12372    {
12373        let mut store = AtlasStore::in_memory()?;
12374        store.replace_scan(&[
12375            test_file_node("src/a.rs", "hash-a"),
12376            test_file_node("src/b.rs", "hash-b"),
12377        ])?;
12378        store.replace_file_texts_for_paths(
12379            &["src/a.rs".to_string(), "src/b.rs".to_string()],
12380            &[
12381                IndexedFileText {
12382                    path: "src/a.rs".to_string(),
12383                    content_hash: Some("hash-a".to_string()),
12384                    byte_count: "needle first\n".len(),
12385                    line_count: 1,
12386                    content: "needle first\n".to_string(),
12387                },
12388                IndexedFileText {
12389                    path: "src/b.rs".to_string(),
12390                    content_hash: Some("hash-b".to_string()),
12391                    byte_count: "needle second\n".len(),
12392                    line_count: 1,
12393                    content: "needle second\n".to_string(),
12394                },
12395            ],
12396        )?;
12397
12398        let mut visited = Vec::new();
12399        store.visit_file_texts_for_search(Some("needle"), true, |text| {
12400            visited.push(text.path);
12401            Ok(false)
12402        })?;
12403        require_eq(&visited, &vec!["src/a.rs".to_string()], "early stop rows")?;
12404        Ok(())
12405    }
12406
12407    #[test]
12408    fn file_text_fts_candidates_are_bounded_scoped_safe_and_metadata_only()
12409    -> Result<(), Box<dyn Error>> {
12410        let mut store = AtlasStore::in_memory()?;
12411        store.replace_scan(&[
12412            test_file_node("src/a.rs", "hash-a"),
12413            test_file_node("src/b.rs", "hash-b"),
12414            test_file_node("tests/c.rs", "hash-c"),
12415        ])?;
12416        store.replace_file_texts_for_paths(
12417            &[
12418                "src/a.rs".to_string(),
12419                "src/b.rs".to_string(),
12420                "tests/c.rs".to_string(),
12421            ],
12422            &[
12423                IndexedFileText {
12424                    path: "src/a.rs".to_string(),
12425                    content_hash: Some("hash-a".to_string()),
12426                    byte_count: 13,
12427                    line_count: 1,
12428                    content: "needle alpha\n".to_string(),
12429                },
12430                IndexedFileText {
12431                    path: "src/b.rs".to_string(),
12432                    content_hash: Some("hash-b".to_string()),
12433                    byte_count: 19,
12434                    line_count: 1,
12435                    content: "prefixneedlesuffix\n".to_string(),
12436                },
12437                IndexedFileText {
12438                    path: "tests/c.rs".to_string(),
12439                    content_hash: Some("hash-c".to_string()),
12440                    byte_count: 32,
12441                    line_count: 1,
12442                    content: "needle gamma AND NOT NEAR terms\n".to_string(),
12443                },
12444            ],
12445        )?;
12446
12447        let bounded = store.query_file_text_fts_candidates(
12448            &FileTextFtsQuery {
12449                literal_token: "needle",
12450                path_prefix: None,
12451                limit: 1,
12452            },
12453            None,
12454        )?;
12455        require_eq(&bounded.candidates.len(), &1, "bounded FTS row count")?;
12456        require_eq(&bounded.overflow, &true, "bounded FTS overflow")?;
12457        require(
12458            bounded.candidates[0].bm25.is_finite(),
12459            "FTS rank was non-finite",
12460        )?;
12461        let zero_limit = store.query_file_text_fts_candidates(
12462            &FileTextFtsQuery {
12463                literal_token: "needle",
12464                path_prefix: None,
12465                limit: 0,
12466            },
12467            None,
12468        )?;
12469        require_eq(
12470            &zero_limit,
12471            &FileTextFtsPage {
12472                candidates: Vec::new(),
12473                overflow: true,
12474            },
12475            "zero-limit FTS overflow",
12476        )?;
12477
12478        let scoped = store.query_file_text_fts_candidates(
12479            &FileTextFtsQuery {
12480                literal_token: "needle",
12481                path_prefix: Some("src/"),
12482                limit: 10,
12483            },
12484            None,
12485        )?;
12486        require_eq(
12487            &scoped
12488                .candidates
12489                .iter()
12490                .map(|candidate| candidate.path.as_str())
12491                .collect::<Vec<_>>(),
12492            &vec!["src/a.rs", "src/b.rs"],
12493            "path-scoped FTS rank order",
12494        )?;
12495        let exact_path = store.query_file_text_fts_candidates(
12496            &FileTextFtsQuery {
12497                literal_token: "needle",
12498                path_prefix: Some("src/a.rs"),
12499                limit: 10,
12500            },
12501            None,
12502        )?;
12503        require_eq(
12504            &exact_path.candidates[0].path,
12505            &"src/a.rs".to_string(),
12506            "exact-path FTS scope",
12507        )?;
12508        for reserved_token in ["AND", "NOT", "NEAR"] {
12509            let reserved = store.query_file_text_fts_candidates(
12510                &FileTextFtsQuery {
12511                    literal_token: reserved_token,
12512                    path_prefix: Some("tests/c.rs"),
12513                    limit: 10,
12514                },
12515                None,
12516            )?;
12517            require_eq(
12518                &reserved.candidates.len(),
12519                &1,
12520                "quoted FTS reserved-token candidate",
12521            )?;
12522        }
12523        let mut plan_statement = store.connection.prepare(
12524            "EXPLAIN QUERY PLAN
12525             SELECT f.path, f.content_hash, f.byte_count, f.line_count, bm25(file_text_fts)
12526             FROM file_text_fts
12527             JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
12528             WHERE file_text_fts MATCH ?1
12529             ORDER BY bm25(file_text_fts), f.path
12530             LIMIT ?2",
12531        )?;
12532        let plan_details = plan_statement
12533            .query_map(params!["needle", 11], |row| row.get::<_, String>(3))?
12534            .collect::<Result<Vec<_>, _>>()?;
12535        require(
12536            plan_details
12537                .iter()
12538                .any(|detail| detail.contains("VIRTUAL TABLE INDEX")),
12539            "FTS candidate query did not use the virtual-table index",
12540        )?;
12541        require(
12542            plan_details
12543                .iter()
12544                .any(|detail| detail.contains("INTEGER PRIMARY KEY")),
12545            "FTS candidate query did not hydrate metadata by file_texts rowid",
12546        )?;
12547
12548        for unsafe_token in ["ab", "foo_bar", "needle\" OR content:*", "néedle"] {
12549            let result = store.query_file_text_fts_candidates(
12550                &FileTextFtsQuery {
12551                    literal_token: unsafe_token,
12552                    path_prefix: None,
12553                    limit: 10,
12554                },
12555                None,
12556            );
12557            require(
12558                matches!(result, Err(DbError::FileTextFtsTokenUnsafe { .. })),
12559                "unsafe FTS token was accepted",
12560            )?;
12561        }
12562        let over_cap = store.query_file_text_fts_candidates(
12563            &FileTextFtsQuery {
12564                literal_token: "needle",
12565                path_prefix: None,
12566                limit: MAX_FILE_TEXT_FTS_CANDIDATES + 1,
12567            },
12568            None,
12569        );
12570        require(
12571            matches!(over_cap, Err(DbError::FileTextFtsCandidateLimit { .. })),
12572            "over-cap FTS request was accepted",
12573        )?;
12574
12575        store.connection.execute(
12576            "UPDATE file_texts SET content = x'80' WHERE path = 'src/a.rs'",
12577            [],
12578        )?;
12579        let metadata_only = store.query_file_text_fts_candidates(
12580            &FileTextFtsQuery {
12581                literal_token: "needle",
12582                path_prefix: Some("src/a.rs"),
12583                limit: 1,
12584            },
12585            None,
12586        )?;
12587        require_eq(
12588            &metadata_only.candidates[0].byte_count,
12589            &13,
12590            "metadata-only candidate byte count",
12591        )?;
12592        require(
12593            store.load_file_text("src/a.rs").is_err(),
12594            "content corruption fixture did not prove candidate pre-hydration",
12595        )?;
12596        Ok(())
12597    }
12598
12599    #[test]
12600    fn file_text_fts_mutations_are_atomic_and_state_reports_identity_drift()
12601    -> Result<(), Box<dyn Error>> {
12602        let mut store = AtlasStore::in_memory()?;
12603        store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
12604        let invalid_metadata = IndexedFileText {
12605            path: "src/main.rs".to_string(),
12606            content_hash: Some("hash-invalid".to_string()),
12607            byte_count: 0,
12608            line_count: 1,
12609            content: "not empty\n".to_string(),
12610        };
12611        require(
12612            matches!(
12613                store.replace_file_texts_for_paths(
12614                    std::slice::from_ref(&invalid_metadata.path),
12615                    std::slice::from_ref(&invalid_metadata),
12616                ),
12617                Err(DbError::FileTextMetadataMismatch {
12618                    field: "byte_count",
12619                    ..
12620                })
12621            ),
12622            "invalid file-text byte metadata was accepted",
12623        )?;
12624        require_eq(
12625            &store.load_file_text(&invalid_metadata.path)?,
12626            &None,
12627            "invalid metadata write rollback",
12628        )?;
12629        let invalid_line_count = IndexedFileText {
12630            byte_count: invalid_metadata.content.len(),
12631            line_count: 0,
12632            ..invalid_metadata.clone()
12633        };
12634        require(
12635            matches!(
12636                store.replace_file_texts_for_paths(
12637                    std::slice::from_ref(&invalid_line_count.path),
12638                    std::slice::from_ref(&invalid_line_count),
12639                ),
12640                Err(DbError::FileTextMetadataMismatch {
12641                    field: "line_count",
12642                    ..
12643                })
12644            ),
12645            "invalid file-text line metadata was accepted",
12646        )?;
12647        let original = IndexedFileText {
12648            path: "src/main.rs".to_string(),
12649            content_hash: Some("hash-a".to_string()),
12650            byte_count: "needle old\n".len(),
12651            line_count: 1,
12652            content: "needle old\n".to_string(),
12653        };
12654        store.replace_file_texts_for_paths(
12655            std::slice::from_ref(&original.path),
12656            std::slice::from_ref(&original),
12657        )?;
12658        require_eq(
12659            &store.file_text_fts_state()?,
12660            &FileTextFtsState {
12661                source_rows: 1,
12662                indexed_rows: 1,
12663                synchronized: true,
12664            },
12665            "initial FTS synchronization state",
12666        )?;
12667
12668        store.connection.execute_batch(
12669            "CREATE TRIGGER fail_file_text_insert
12670             BEFORE INSERT ON file_texts
12671             BEGIN
12672                 SELECT RAISE(ABORT, 'injected file-text failure');
12673             END;",
12674        )?;
12675        let replacement = IndexedFileText {
12676            path: original.path.clone(),
12677            content_hash: Some("hash-b".to_string()),
12678            byte_count: 11,
12679            line_count: 1,
12680            content: "beacon new\n".to_string(),
12681        };
12682        require(
12683            store
12684                .replace_file_texts_for_paths(
12685                    std::slice::from_ref(&replacement.path),
12686                    std::slice::from_ref(&replacement),
12687                )
12688                .is_err(),
12689            "fault-injected replacement unexpectedly committed",
12690        )?;
12691        store
12692            .connection
12693            .execute_batch("DROP TRIGGER fail_file_text_insert")?;
12694        require_eq(
12695            &store.load_file_text(&original.path)?,
12696            &Some(original.clone()),
12697            "rolled-back authoritative text",
12698        )?;
12699        require_eq(
12700            &store
12701                .query_file_text_fts_candidates(
12702                    &FileTextFtsQuery {
12703                        literal_token: "needle",
12704                        path_prefix: None,
12705                        limit: 10,
12706                    },
12707                    None,
12708                )?
12709                .candidates
12710                .len(),
12711            &1,
12712            "rolled-back FTS document",
12713        )?;
12714        require_eq(
12715            &store.file_text_fts_state()?.synchronized,
12716            &true,
12717            "rollback FTS synchronization",
12718        )?;
12719
12720        store.replace_file_texts_for_paths(
12721            std::slice::from_ref(&replacement.path),
12722            std::slice::from_ref(&replacement),
12723        )?;
12724        require_eq(
12725            &store.load_file_text(&replacement.path)?,
12726            &Some(replacement.clone()),
12727            "replacement read after repeated hydration",
12728        )?;
12729        require_eq(
12730            &store
12731                .query_file_text_fts_candidates(
12732                    &FileTextFtsQuery {
12733                        literal_token: "needle",
12734                        path_prefix: None,
12735                        limit: 10,
12736                    },
12737                    None,
12738                )?
12739                .candidates
12740                .len(),
12741            &0,
12742            "replaced token removed from FTS",
12743        )?;
12744        require_eq(
12745            &store
12746                .query_file_text_fts_candidates(
12747                    &FileTextFtsQuery {
12748                        literal_token: "beacon",
12749                        path_prefix: None,
12750                        limit: 10,
12751                    },
12752                    None,
12753                )?
12754                .candidates
12755                .len(),
12756            &1,
12757            "replacement token added to FTS",
12758        )?;
12759
12760        store.connection.execute(
12761            "INSERT INTO file_text_fts(file_text_fts, rowid, content)
12762             SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
12763            [&replacement.path],
12764        )?;
12765        require_eq(
12766            &store.file_text_fts_state()?,
12767            &FileTextFtsState {
12768                source_rows: 1,
12769                indexed_rows: 0,
12770                synchronized: false,
12771            },
12772            "desynchronized FTS identity state",
12773        )?;
12774        store.connection.execute(
12775            "INSERT INTO file_text_fts(file_text_fts) VALUES('rebuild')",
12776            [],
12777        )?;
12778        let (source_revision, projection_revision) =
12779            load_file_text_fts_revisions(&store.connection)?.ok_or_else(|| {
12780                io::Error::other("FTS revisions disappeared after synchronized writes")
12781            })?;
12782        require_eq(
12783            &source_revision,
12784            &projection_revision,
12785            "synchronized FTS revisions",
12786        )?;
12787        set_metadata(
12788            &store.connection,
12789            FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
12790            &projection_revision.saturating_sub(1).to_string(),
12791        )?;
12792        require_eq(
12793            &store.file_text_fts_ready()?,
12794            &false,
12795            "incomplete FTS revision readiness",
12796        )?;
12797        set_metadata(
12798            &store.connection,
12799            FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
12800            &source_revision.to_string(),
12801        )?;
12802        require_eq(
12803            &store.file_text_fts_ready()?,
12804            &true,
12805            "restored FTS revision readiness",
12806        )?;
12807        store.mark_paths_absent(&["src".to_string()])?;
12808        require_eq(
12809            &store.file_text_fts_state()?,
12810            &FileTextFtsState {
12811                source_rows: 0,
12812                indexed_rows: 0,
12813                synchronized: true,
12814            },
12815            "absent-path FTS synchronization",
12816        )?;
12817
12818        store.replace_scan(&[test_file_node("src/main.rs", "hash-c")])?;
12819        let rescanned = IndexedFileText {
12820            path: "src/main.rs".to_string(),
12821            content_hash: Some("hash-c".to_string()),
12822            byte_count: 13,
12823            line_count: 1,
12824            content: "rescan token\n".to_string(),
12825        };
12826        store.replace_file_texts_for_paths(
12827            std::slice::from_ref(&rescanned.path),
12828            std::slice::from_ref(&rescanned),
12829        )?;
12830        store.replace_scan(&[])?;
12831        require_eq(
12832            &store.file_text_fts_state()?,
12833            &FileTextFtsState {
12834                source_rows: 0,
12835                indexed_rows: 0,
12836                synchronized: true,
12837            },
12838            "full-scan deletion FTS synchronization",
12839        )?;
12840        Ok(())
12841    }
12842
12843    #[cfg(windows)]
12844    #[test]
12845    fn file_text_fts_migration_backfills_existing_text_and_survives_reopen()
12846    -> Result<(), Box<dyn Error>> {
12847        let temp = tempfile::tempdir()?;
12848        let root = temp.path().join("project");
12849        fs::create_dir(&root)?;
12850        let database = temp.path().join("atlas.db");
12851        let mut store = AtlasStore::open_for_project(&database, &root)?;
12852        store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
12853        store.replace_file_texts_for_paths(
12854            &["src/main.rs".to_string()],
12855            &[IndexedFileText {
12856                path: "src/main.rs".to_string(),
12857                content_hash: Some("hash-a".to_string()),
12858                byte_count: 17,
12859                line_count: 1,
12860                content: "migration needle\n".to_string(),
12861            }],
12862        )?;
12863        store.connection.execute_batch(
12864            "DROP TABLE file_content_classifications;
12865             DROP INDEX idx_nodes_path_kind;
12866             DROP TABLE usage_instance_worktree_origins;
12867             DROP TABLE worktree_usage_aggregates;
12868             DROP TABLE worktree_registrations;
12869             DROP TABLE usage_aggregate_revisions;
12870             DROP TABLE project_root_identity;
12871             DROP TABLE IF EXISTS graph_identity_rejections;",
12872        )?;
12873        crate::schema::recreate_disposable_graph_projection(&store.connection, false)?;
12874        crate::schema::recreate_pre_selector_symbol_storage_for_test(&store.connection)?;
12875        store.connection.execute_batch(
12876            "DROP INDEX idx_symbol_import_alias_lookup;
12877             DROP TABLE file_text_fts;",
12878        )?;
12879        store.connection.execute(
12880            "DELETE FROM metadata WHERE key IN (?1, ?2)",
12881            params![
12882                FILE_TEXT_FTS_SOURCE_REVISION_KEY,
12883                FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
12884            ],
12885        )?;
12886        set_metadata(
12887            &store.connection,
12888            SCHEMA_VERSION_KEY,
12889            &crate::schema::COVERAGE_DISCOVERY_SCHEMA_VERSION.to_string(),
12890        )?;
12891        drop(store);
12892
12893        let migrated = AtlasStore::open_for_project(&database, &root)?;
12894        require_eq(
12895            &migrated.file_text_fts_state()?,
12896            &FileTextFtsState {
12897                source_rows: 1,
12898                indexed_rows: 1,
12899                synchronized: true,
12900            },
12901            "migrated FTS synchronization state",
12902        )?;
12903        require_eq(
12904            &migrated
12905                .query_file_text_fts_candidates(
12906                    &FileTextFtsQuery {
12907                        literal_token: "needle",
12908                        path_prefix: None,
12909                        limit: 10,
12910                    },
12911                    None,
12912                )?
12913                .candidates[0]
12914                .path,
12915            &"src/main.rs".to_string(),
12916            "migrated FTS backfill candidate",
12917        )?;
12918        drop(migrated);
12919
12920        let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
12921        require_eq(
12922            &reopened.file_text_fts_state()?.synchronized,
12923            &true,
12924            "reopened FTS synchronization",
12925        )?;
12926        require_eq(
12927            &reopened
12928                .query_file_text_fts_candidates(
12929                    &FileTextFtsQuery {
12930                        literal_token: "migration",
12931                        path_prefix: Some("src"),
12932                        limit: 1,
12933                    },
12934                    None,
12935                )?
12936                .candidates
12937                .len(),
12938            &1,
12939            "reopened FTS candidate count",
12940        )?;
12941        Ok(())
12942    }
12943
12944    #[test]
12945    fn sqlite_read_progress_interrupts_active_repository_traversal_and_clears_handler()
12946    -> Result<(), Box<dyn Error>> {
12947        let store = AtlasStore::in_memory()?;
12948        let control = IndexWorkControl::with_deadline(
12949            projectatlas_core::IndexCancellation::new(),
12950            Instant::now() + std::time::Duration::from_millis(50),
12951        );
12952        let interrupted = with_sqlite_read_progress(
12953            &store.connection,
12954            Some(&control),
12955            IndexWorkStage::RepositoryTraversal,
12956            || {
12957                store
12958                    .connection
12959                    .query_row(
12960                        "WITH RECURSIVE numbers(value) AS (
12961                             VALUES(1)
12962                             UNION ALL
12963                             SELECT value + 1 FROM numbers WHERE value < 100000000
12964                         )
12965                         SELECT SUM(value) FROM numbers",
12966                        [],
12967                        |row| row.get::<_, i64>(0),
12968                    )
12969                    .map_err(DbError::from)
12970            },
12971        );
12972        require(
12973            matches!(
12974                interrupted,
12975                Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
12976                    stage: IndexWorkStage::RepositoryTraversal
12977                }))
12978            ),
12979            "active SQLite traversal was not interrupted with its typed deadline",
12980        )?;
12981        require_eq(
12982            &store
12983                .connection
12984                .query_row("SELECT 1", [], |row| row.get::<_, i64>(0))?,
12985            &1,
12986            "cleared repository traversal progress handler",
12987        )?;
12988        store
12989            .connection
12990            .execute("CREATE TEMP TABLE follow_up(value INTEGER NOT NULL)", [])?;
12991        store
12992            .connection
12993            .execute("INSERT INTO follow_up(value) VALUES(1)", [])?;
12994        Ok(())
12995    }
12996
12997    #[cfg(feature = "sqlite-progress-test-observer")]
12998    #[test]
12999    fn sqlite_read_progress_propagates_cancellation_during_an_active_batch()
13000    -> Result<(), Box<dyn Error>> {
13001        use crate::sqlite_progress_test_observer::{
13002            SqliteReadProgressEvent, observe_sqlite_read_progress,
13003        };
13004        use std::cell::Cell;
13005        use std::rc::Rc;
13006
13007        let store = AtlasStore::in_memory()?;
13008        let cancellation = projectatlas_core::IndexCancellation::new();
13009        let control = IndexWorkControl::new(cancellation.clone(), None);
13010        let callback_seen = Rc::new(Cell::new(false));
13011        let cancelled = observe_sqlite_read_progress(
13012            {
13013                let callback_seen = Rc::clone(&callback_seen);
13014                move |event| {
13015                    if matches!(
13016                        event,
13017                        SqliteReadProgressEvent::CallbackEntered {
13018                            stage: IndexWorkStage::RepositoryTraversal
13019                        }
13020                    ) {
13021                        callback_seen.set(true);
13022                        cancellation.cancel();
13023                    }
13024                }
13025            },
13026            || {
13027                with_sqlite_read_progress(
13028                    &store.connection,
13029                    Some(&control),
13030                    IndexWorkStage::RepositoryTraversal,
13031                    || {
13032                        store
13033                            .connection
13034                            .query_row(
13035                                "WITH RECURSIVE numbers(value) AS (
13036                                     VALUES(1)
13037                                     UNION ALL
13038                                     SELECT value + 1 FROM numbers WHERE value < 100000000
13039                                 )
13040                                 SELECT SUM(value) FROM numbers",
13041                                [],
13042                                |row| row.get::<_, i64>(0),
13043                            )
13044                            .map_err(DbError::from)
13045                    },
13046                )
13047            },
13048        );
13049        require(
13050            callback_seen.get()
13051                && matches!(
13052                    cancelled,
13053                    Err(DbError::IndexWork(IndexWorkFailure::Cancelled {
13054                        stage: IndexWorkStage::RepositoryTraversal
13055                    }))
13056                ),
13057            "active SQLite batch did not propagate typed cancellation",
13058        )?;
13059        store.connection.execute(
13060            "CREATE TEMP TABLE cancellation_follow_up(value INTEGER)",
13061            [],
13062        )?;
13063        Ok(())
13064    }
13065
13066    #[test]
13067    fn fallback_admission_precedes_content_decode_and_clears_progress_handlers()
13068    -> Result<(), Box<dyn Error>> {
13069        let mut store = AtlasStore::in_memory()?;
13070        store.replace_scan(&[test_file_node("src/good.rs", "hash-good")])?;
13071        store.replace_file_texts_for_paths(
13072            &["src/good.rs".to_string()],
13073            &[IndexedFileText {
13074                path: "src/good.rs".to_string(),
13075                content_hash: Some("hash-good".to_string()),
13076                byte_count: 12,
13077                line_count: 1,
13078                content: "needle good\n".to_string(),
13079            }],
13080        )?;
13081        store.connection.execute_batch(
13082            "INSERT INTO nodes(path, kind, exists_now) VALUES('src/bad.rs', 'file', 1);
13083             INSERT INTO file_content_classifications(path, classification)
13084                VALUES('src/bad.rs', 'source');
13085             INSERT INTO file_texts(path, content_hash, byte_count, line_count, content)
13086                VALUES('src/bad.rs', 'hash-bad', 1, 1, x'80');",
13087        )?;
13088
13089        let mut admitted = Vec::new();
13090        let mut visited = Vec::new();
13091        store.visit_file_texts_for_fallback(
13092            Some("src"),
13093            None,
13094            |metadata| {
13095                admitted.push((metadata.path.clone(), metadata.classification));
13096                Ok(if metadata.path == "src/bad.rs" {
13097                    FileTextAdmission::Skip
13098                } else {
13099                    FileTextAdmission::Read
13100                })
13101            },
13102            |text| {
13103                visited.push(text.path);
13104                Ok(true)
13105            },
13106        )?;
13107        require_eq(
13108            &admitted,
13109            &vec![
13110                ("src/bad.rs".to_string(), ContentClassification::Source),
13111                ("src/good.rs".to_string(), ContentClassification::Opaque),
13112            ],
13113            "fallback metadata admission rows",
13114        )?;
13115        require_eq(
13116            &visited,
13117            &vec!["src/good.rs".to_string()],
13118            "fallback decoded rows",
13119        )?;
13120        let mut scoped_plan = store.connection.prepare(&format!(
13121            "EXPLAIN QUERY PLAN {FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL}"
13122        ))?;
13123        let scoped_plan_details = scoped_plan
13124            .query_map(params!["src", "src/", "src0"], |row| {
13125                row.get::<_, String>(3)
13126            })?
13127            .collect::<Result<Vec<_>, _>>()?;
13128        require(
13129            scoped_plan_details
13130                .iter()
13131                .any(|detail| detail.contains("sqlite_autoindex_file_texts_1"))
13132                && scoped_plan_details.iter().any(|detail| {
13133                    detail.contains("sqlite_autoindex_file_content_classifications_1")
13134                })
13135                && scoped_plan_details.iter().all(|detail| {
13136                    !detail.contains("SCAN text") && !detail.contains("SCAN classification")
13137                }),
13138            "path-scoped fallback did not use bounded binary index ranges",
13139        )?;
13140        let mut scoped_opcodes = store
13141            .connection
13142            .prepare(&format!("EXPLAIN {FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL}"))?;
13143        let scoped_opcode_rows = scoped_opcodes
13144            .query_map(params!["src", "src/", "src0"], |row| {
13145                Ok((
13146                    row.get::<_, String>(1)?,
13147                    row.get::<_, i64>(2)?,
13148                    row.get::<_, i64>(3)?,
13149                    row.get::<_, Option<String>>(5)?,
13150                ))
13151            })?
13152            .collect::<Result<Vec<_>, _>>()?;
13153        let file_text_table_cursors = scoped_opcode_rows
13154            .iter()
13155            .filter(|(opcode, _, _, shape)| opcode == "DeferredSeek" && shape.is_some())
13156            .map(|(_, _, table_cursor, _)| *table_cursor)
13157            .collect::<Vec<_>>();
13158        require(
13159            !file_text_table_cursors.is_empty()
13160                && scoped_opcode_rows
13161                    .iter()
13162                    .all(|(opcode, cursor, column, _)| {
13163                        opcode != "Column"
13164                            || !file_text_table_cursors.contains(cursor)
13165                            || *column != 4
13166                    }),
13167            &format!(
13168                "fallback metadata cursor read source content before admission: {scoped_opcode_rows:?}"
13169            ),
13170        )?;
13171        require(
13172            store
13173                .visit_file_texts_for_fallback(
13174                    Some("src/bad.rs"),
13175                    None,
13176                    |_| Ok(FileTextAdmission::Read),
13177                    |_| Ok(true),
13178                )
13179                .is_err(),
13180            "invalid source content decoded without error",
13181        )?;
13182
13183        let success_cancellation = projectatlas_core::IndexCancellation::new();
13184        let success_control = IndexWorkControl::new(success_cancellation.clone(), None);
13185        store.query_file_text_fts_candidates(
13186            &FileTextFtsQuery {
13187                literal_token: "needle",
13188                path_prefix: None,
13189                limit: 10,
13190            },
13191            Some(&success_control),
13192        )?;
13193        success_cancellation.cancel();
13194        let recursive_sum = store.connection.query_row(
13195            "WITH RECURSIVE numbers(value) AS (
13196                 VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 5000
13197             ) SELECT SUM(value) FROM numbers",
13198            [],
13199            |row| row.get::<_, i64>(0),
13200        )?;
13201        require_eq(
13202            &recursive_sum,
13203            &12_502_500,
13204            "cleared success progress handler",
13205        )?;
13206
13207        let cancellation = projectatlas_core::IndexCancellation::new();
13208        let control = IndexWorkControl::new(cancellation.clone(), None);
13209        let mut rows_seen = 0;
13210        let cancelled = store.visit_file_texts_for_fallback(
13211            Some("src"),
13212            Some(&control),
13213            |_| {
13214                rows_seen += 1;
13215                cancellation.cancel();
13216                Ok(FileTextAdmission::Skip)
13217            },
13218            |_| Ok(true),
13219        );
13220        require_eq(&rows_seen, &1, "rows before fallback cancellation")?;
13221        require(
13222            matches!(
13223                cancelled,
13224                Err(DbError::IndexWork(IndexWorkFailure::Cancelled {
13225                    stage: IndexWorkStage::TextIndex
13226                }))
13227            ),
13228            "fallback cancellation was not typed",
13229        )?;
13230        let post_error_sum = store.connection.query_row(
13231            "WITH RECURSIVE numbers(value) AS (
13232                 VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 5000
13233             ) SELECT SUM(value) FROM numbers",
13234            [],
13235            |row| row.get::<_, i64>(0),
13236        )?;
13237        require_eq(
13238            &post_error_sum,
13239            &12_502_500,
13240            "cleared error progress handler",
13241        )?;
13242
13243        let expired = IndexWorkControl::with_deadline(
13244            projectatlas_core::IndexCancellation::new(),
13245            Instant::now(),
13246        );
13247        let deadline = store.query_file_text_fts_candidates(
13248            &FileTextFtsQuery {
13249                literal_token: "needle",
13250                path_prefix: None,
13251                limit: 10,
13252            },
13253            Some(&expired),
13254        );
13255        require(
13256            matches!(
13257                deadline,
13258                Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
13259                    stage: IndexWorkStage::TextIndex
13260                }))
13261            ),
13262            "FTS deadline was not typed",
13263        )?;
13264        store.connection.execute(
13265            "DELETE FROM file_content_classifications WHERE path = 'src/good.rs'",
13266            [],
13267        )?;
13268        let missing_classification = store.visit_file_texts_for_fallback(
13269            Some("src/good.rs"),
13270            None,
13271            |_| Ok(FileTextAdmission::Read),
13272            |_| Ok(true),
13273        );
13274        require(
13275            matches!(
13276                missing_classification,
13277                Err(DbError::FileContentClassificationMissing { .. })
13278            ),
13279            "fallback omitted a text row whose predecode classification was missing",
13280        )?;
13281        Ok(())
13282    }
13283
13284    #[test]
13285    fn suggested_purpose_is_not_approved() -> Result<(), Box<dyn Error>> {
13286        let mut store = AtlasStore::in_memory()?;
13287        let node = Node {
13288            path: "src/main.rs".to_string(),
13289            kind: NodeKind::File,
13290            parent_path: normalized_parent("src/main.rs"),
13291            extension: Some(".rs".to_string()),
13292            language: Some("rust".to_string()),
13293            size_bytes: Some(12),
13294            mtime_ns: Some(10),
13295            content_hash: Some("abc".to_string()),
13296        };
13297        store.replace_scan(&[node])?;
13298        store.set_suggested_purpose("src/main.rs", "Maybe application entry point")?;
13299        let nodes = store.load_nodes()?;
13300        require_eq(
13301            &nodes[0].purpose.source,
13302            &PurposeSource::Generated,
13303            "suggested source",
13304        )?;
13305        require_eq(
13306            &nodes[0].purpose.status,
13307            &PurposeStatus::Suggested,
13308            "suggested status",
13309        )?;
13310        store.set_purpose(
13311            "src/main.rs",
13312            "Application entry point",
13313            PurposeSource::Agent,
13314        )?;
13315        let nodes = store.load_nodes()?;
13316        require_eq(
13317            &nodes[0].purpose.status,
13318            &PurposeStatus::Approved,
13319            "agent-approved status",
13320        )?;
13321        store.set_suggested_purpose("src/main.rs", "Late generated suggestion")?;
13322        let nodes = store.load_nodes()?;
13323        require_eq(
13324            &nodes[0].purpose.purpose,
13325            &Some("Application entry point".to_string()),
13326            "approved purpose survives a late suggestion",
13327        )?;
13328        require_eq(
13329            &nodes[0].purpose.source,
13330            &PurposeSource::Agent,
13331            "approved purpose source survives a late suggestion",
13332        )?;
13333        require_eq(
13334            &nodes[0].purpose.status,
13335            &PurposeStatus::Approved,
13336            "approved purpose status survives a late suggestion",
13337        )?;
13338
13339        store.connection.execute(
13340            "
13341            UPDATE purposes
13342            SET status = ?1
13343            WHERE node_id = (SELECT id FROM nodes WHERE path = ?2)
13344            ",
13345            params![PurposeStatus::Stale.as_str(), "src/main.rs"],
13346        )?;
13347        store.set_suggested_purpose("src/main.rs", "Later generated suggestion")?;
13348        let nodes = store.load_nodes()?;
13349        require_eq(
13350            &nodes[0].purpose.purpose,
13351            &Some("Application entry point".to_string()),
13352            "stale reviewed purpose survives a late suggestion",
13353        )?;
13354        require_eq(
13355            &nodes[0].purpose.source,
13356            &PurposeSource::Agent,
13357            "stale reviewed source survives a late suggestion",
13358        )?;
13359        require_eq(
13360            &nodes[0].purpose.status,
13361            &PurposeStatus::Stale,
13362            "stale reviewed status survives a late suggestion",
13363        )?;
13364        Ok(())
13365    }
13366
13367    #[test]
13368    fn agent_reviewed_marker_depends_on_agent_approved_source() -> Result<(), Box<dyn Error>> {
13369        let mut store = AtlasStore::in_memory()?;
13370        store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
13371
13372        store.set_suggested_purpose("src/main.rs", "Maybe application entry point")?;
13373        let nodes = store.load_nodes()?;
13374        require_eq(
13375            &nodes[0].purpose.agent_reviewed(),
13376            &false,
13377            "generated suggestion is not agent reviewed",
13378        )?;
13379
13380        store.set_purpose(
13381            "src/main.rs",
13382            "Imported application entry point",
13383            PurposeSource::Imported,
13384        )?;
13385        let nodes = store.load_nodes()?;
13386        require_eq(
13387            &nodes[0].purpose.agent_reviewed(),
13388            &false,
13389            "imported purpose is not agent reviewed",
13390        )?;
13391
13392        store.set_purpose(
13393            "src/main.rs",
13394            "Agent-reviewed application entry point",
13395            PurposeSource::Agent,
13396        )?;
13397        let nodes = store.load_nodes()?;
13398        require_eq(
13399            &nodes[0].purpose.agent_reviewed(),
13400            &true,
13401            "agent-approved purpose is agent reviewed",
13402        )?;
13403
13404        store.connection.execute(
13405            "
13406            UPDATE purposes
13407            SET source = 'human'
13408            WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/main.rs')
13409            ",
13410            [],
13411        )?;
13412        let nodes = store.load_nodes()?;
13413        require_eq(
13414            &nodes[0].purpose.source,
13415            &PurposeSource::Agent,
13416            "legacy human source normalizes to agent",
13417        )?;
13418        require_eq(
13419            &nodes[0].purpose.agent_reviewed(),
13420            &true,
13421            "legacy approved human row remains reviewed",
13422        )?;
13423
13424        store.replace_scan(&[test_file_node("src/main.rs", "hash-b")])?;
13425        let nodes = store.load_nodes()?;
13426        require_eq(
13427            &nodes[0].purpose.status,
13428            &PurposeStatus::Approved,
13429            "changed reviewed purpose stays approved",
13430        )?;
13431        require_eq(
13432            &nodes[0].purpose.agent_reviewed(),
13433            &true,
13434            "changed approved purpose remains agent reviewed",
13435        )?;
13436        Ok(())
13437    }
13438
13439    #[test]
13440    fn purpose_curation_batch_coalesces_current_unapproved_rows() -> Result<(), Box<dyn Error>> {
13441        let temp = tempfile::tempdir()?;
13442        let root = temp.path().join("project");
13443        fs::create_dir(&root)?;
13444        let database = temp.path().join("projectatlas.db");
13445        let mut store = AtlasStore::open_for_project(&database, &root)?;
13446        store.replace_scan(&[
13447            test_file_node("src/a.rs", "hash-a"),
13448            test_file_node("src/b.rs", "hash-b"),
13449            test_file_node("src/accepted.rs", "hash-accepted"),
13450        ])?;
13451        store.set_suggested_purpose("src/b.rs", "Generated B suggestion")?;
13452        store.set_purpose("src/accepted.rs", "Accepted purpose", PurposeSource::Agent)?;
13453
13454        let selected = vec![
13455            "src/b.rs".to_string(),
13456            "src/a.rs".to_string(),
13457            "src/a.rs".to_string(),
13458            "src/accepted.rs".to_string(),
13459        ];
13460        let first = store.load_purpose_curation_batch(" issue   308 ", &selected)?;
13461        let second = store.load_purpose_curation_batch(
13462            "issue 308",
13463            &selected.iter().rev().cloned().collect::<Vec<_>>(),
13464        )?;
13465        require_eq(&first.task, &"issue 308".to_string(), "normalized task")?;
13466        require_eq(&first.work_key, &second.work_key, "deterministic batch key")?;
13467        require_eq(&first.items, &second.items, "deterministic candidate rows")?;
13468        require_eq(&first.items.len(), &2, "coalesced unapproved row count")?;
13469        require_eq(
13470            &first
13471                .items
13472                .iter()
13473                .map(|item| item.node.node.path.as_str())
13474                .collect::<Vec<_>>(),
13475            &vec!["src/a.rs", "src/b.rs"],
13476            "sorted actionable paths",
13477        )?;
13478        require_eq(
13479            &first
13480                .items
13481                .iter()
13482                .all(|item| item.work_key.len() == 64 && item.state_token.len() == 64),
13483            &true,
13484            "bounded opaque item identities",
13485        )?;
13486
13487        let page = store.purpose_curation_findings_page_current(&HealthQuery {
13488            start_index: 0,
13489            limit: 20,
13490            category: None,
13491            severity: Some(Severity::Warning),
13492            path_prefix: None,
13493            summary_only: false,
13494            scope: HealthScope::all(),
13495        })?;
13496        require_eq(&page.total, &2, "actionable queue count")?;
13497        require_eq(
13498            &page
13499                .findings
13500                .iter()
13501                .any(|finding| finding.path == "src/accepted.rs"),
13502            &false,
13503            "accepted purpose omitted from automatic curation",
13504        )?;
13505
13506        store.set_purpose(
13507            "src/accepted.rs",
13508            "Deliberately corrected purpose",
13509            PurposeSource::Agent,
13510        )?;
13511        let corrected = store
13512            .load_node_by_path("src/accepted.rs")?
13513            .ok_or_else(|| io::Error::other("corrected purpose path disappeared"))?;
13514        require_eq(
13515            &corrected.purpose.purpose,
13516            &Some("Deliberately corrected purpose".to_string()),
13517            "explicit accepted-purpose correction",
13518        )?;
13519        Ok(())
13520    }
13521
13522    #[test]
13523    fn explicit_purpose_rollback_reports_storage_failure() -> Result<(), Box<dyn Error>> {
13524        let temp = tempfile::tempdir()?;
13525        let root = temp.path().join("project");
13526        fs::create_dir(&root)?;
13527        let database = temp.path().join("projectatlas.db");
13528        let mut store = AtlasStore::open_for_project(&database, &root)?;
13529        store.replace_scan(&[test_file_node("source.rs", "hash")])?;
13530        let before_revision = store.authored_purpose_revision()?;
13531        let transaction = store.begin_purpose_mutation()?;
13532        store.set_purpose("source.rs", "Rejected purpose", PurposeSource::Agent)?;
13533        store.connection.execute_batch("ROLLBACK")?;
13534
13535        require(
13536            matches!(transaction.rollback(), Err(DbError::Sqlite(_))),
13537            "explicit purpose rollback suppressed its SQLite failure",
13538        )?;
13539        require_eq(
13540            &store.authored_purpose_revision()?,
13541            &before_revision,
13542            "manually rolled-back purpose revision",
13543        )?;
13544        require(
13545            store
13546                .load_node_by_path("source.rs")?
13547                .is_none_or(|node| node.purpose.purpose.as_deref() != Some("Rejected purpose")),
13548            "manual rollback retained the rejected purpose",
13549        )
13550    }
13551
13552    #[test]
13553    fn conditional_purpose_batch_is_atomic_and_rejects_changed_work() -> Result<(), Box<dyn Error>>
13554    {
13555        let temp = tempfile::tempdir()?;
13556        let root = temp.path().join("project");
13557        fs::create_dir(&root)?;
13558        let database = temp.path().join("projectatlas.db");
13559        let mut store = AtlasStore::open_for_project(&database, &root)?;
13560        let nodes = vec![
13561            test_file_node("src/a.rs", "hash-a"),
13562            test_file_node("src/b.rs", "hash-b"),
13563            test_file_node("src/changed.rs", "hash-changed"),
13564            test_file_node("src/accepted.rs", "hash-accepted"),
13565            test_file_node("src/generation.rs", "hash-generation"),
13566            test_file_node("src/rollback-a.rs", "hash-rollback-a"),
13567            test_file_node("src/rollback-b.rs", "hash-rollback-b"),
13568        ];
13569        store.replace_scan(&nodes)?;
13570        store.set_suggested_purpose("src/b.rs", "Generated B suggestion")?;
13571        store.set_suggested_purpose("src/changed.rs", "Original suggestion")?;
13572        require_eq(
13573            &store.authored_purpose_revision()?,
13574            &0,
13575            "suggestions do not advance authored-purpose revision",
13576        )?;
13577        let task = "issue-308-purpose-curation";
13578
13579        let apply_batch = store
13580            .load_purpose_curation_batch(task, &["src/a.rs".to_string(), "src/b.rs".to_string()])?;
13581        let requests = apply_batch
13582            .items
13583            .iter()
13584            .map(|candidate| {
13585                conditional_purpose_request(
13586                    task,
13587                    candidate,
13588                    &format!("Reviewed {}", candidate.node.node.path),
13589                )
13590            })
13591            .collect::<Vec<_>>();
13592        let applied = store.conditionally_set_purposes(&requests)?;
13593        require_eq(
13594            &applied
13595                .iter()
13596                .map(|result| result.state)
13597                .collect::<Vec<_>>(),
13598            &vec![
13599                PurposeConditionalApplyState::Applied,
13600                PurposeConditionalApplyState::Applied,
13601            ],
13602            "one-transaction batch outcomes",
13603        )?;
13604        require_eq(
13605            &applied.iter().all(|result| {
13606                result.current_purpose.as_ref().is_some_and(|purpose| {
13607                    purpose.status == PurposeStatus::Approved
13608                        && purpose.source == PurposeSource::Agent
13609                })
13610            }),
13611            &true,
13612            "applied transaction-owned purpose metadata",
13613        )?;
13614        require_eq(
13615            &store.authored_purpose_revision()?,
13616            &1,
13617            "one accepted batch advances one authored-purpose revision",
13618        )?;
13619
13620        let changed = store
13621            .load_purpose_curation_batch(task, &["src/changed.rs".to_string()])?
13622            .items
13623            .into_iter()
13624            .next()
13625            .ok_or_else(|| io::Error::other("changed candidate missing"))?;
13626        store.set_suggested_purpose("src/changed.rs", "Concurrent suggestion")?;
13627        let changed_result = store
13628            .conditionally_set_purposes(&[conditional_purpose_request(
13629                task,
13630                &changed,
13631                "Stale curator answer",
13632            )])?
13633            .pop()
13634            .ok_or_else(|| io::Error::other("changed conditional result missing"))?;
13635        require_eq(
13636            &changed_result.state,
13637            &PurposeConditionalApplyState::Stale,
13638            "changed suggestion state",
13639        )?;
13640        require_eq(
13641            &changed_result
13642                .current_purpose
13643                .as_ref()
13644                .map(|purpose| (purpose.status, purpose.source, purpose.purpose.as_deref())),
13645            &Some((
13646                PurposeStatus::Suggested,
13647                PurposeSource::Generated,
13648                Some("Concurrent suggestion"),
13649            )),
13650            "stale transaction-owned purpose metadata",
13651        )?;
13652        require_eq(
13653            &store.authored_purpose_revision()?,
13654            &1,
13655            "stale conditional purpose leaves revision unchanged",
13656        )?;
13657
13658        let accepted = store
13659            .load_purpose_curation_batch(task, &["src/accepted.rs".to_string()])?
13660            .items
13661            .into_iter()
13662            .next()
13663            .ok_or_else(|| io::Error::other("accepted candidate missing"))?;
13664        store.set_purpose(
13665            "src/accepted.rs",
13666            "Concurrent accepted purpose",
13667            PurposeSource::Agent,
13668        )?;
13669        require_eq(
13670            &store.authored_purpose_revision()?,
13671            &2,
13672            "explicit accepted correction advances the authored-purpose revision",
13673        )?;
13674        store.set_purpose(
13675            "src/accepted.rs",
13676            "Concurrent accepted purpose",
13677            PurposeSource::Agent,
13678        )?;
13679        require_eq(
13680            &store.authored_purpose_revision()?,
13681            &2,
13682            "identical accepted purpose leaves the authored-purpose revision unchanged",
13683        )?;
13684        let accepted_result = store
13685            .conditionally_set_purposes(&[conditional_purpose_request(
13686                task,
13687                &accepted,
13688                "Stale curator overwrite",
13689            )])?
13690            .pop()
13691            .ok_or_else(|| io::Error::other("accepted conditional result missing"))?;
13692        require_eq(
13693            &accepted_result.state,
13694            &PurposeConditionalApplyState::Accepted,
13695            "accepted concurrent state",
13696        )?;
13697        require_eq(
13698            &accepted_result
13699                .current_purpose
13700                .as_ref()
13701                .map(|purpose| (purpose.status, purpose.source, purpose.purpose.as_deref())),
13702            &Some((
13703                PurposeStatus::Approved,
13704                PurposeSource::Agent,
13705                Some("Concurrent accepted purpose"),
13706            )),
13707            "accepted transaction-owned purpose metadata",
13708        )?;
13709        require_eq(
13710            &store.authored_purpose_revision()?,
13711            &2,
13712            "accepted conditional no-op leaves revision unchanged",
13713        )?;
13714
13715        let unavailable_result = store
13716            .conditionally_set_purposes(&[PurposeConditionalApplyRequest {
13717                task: task.to_string(),
13718                path: "src/unavailable.rs".to_string(),
13719                work_key: "unavailable-work".to_string(),
13720                state_token: "unavailable-state".to_string(),
13721                purpose: "Unavailable purpose".to_string(),
13722            }])?
13723            .pop()
13724            .ok_or_else(|| io::Error::other("unavailable conditional result missing"))?;
13725        require_eq(
13726            &unavailable_result.state,
13727            &PurposeConditionalApplyState::PathUnavailable,
13728            "unavailable path state",
13729        )?;
13730        require_eq(
13731            &unavailable_result.current_purpose,
13732            &None,
13733            "unavailable transaction-owned purpose metadata",
13734        )?;
13735
13736        let generation = store
13737            .load_purpose_curation_batch(task, &["src/generation.rs".to_string()])?
13738            .items
13739            .into_iter()
13740            .next()
13741            .ok_or_else(|| io::Error::other("generation candidate missing"))?;
13742        let project = store
13743            .project_instance_id()?
13744            .ok_or_else(|| io::Error::other("project identity missing"))?;
13745        {
13746            let mut publication = store.begin_index_publication("purpose-curation-test")?;
13747            publication.begin_scan_replacement()?;
13748            publication.upsert_scan_node_batch(&nodes)?;
13749            publication.finish_scan_replacement()?;
13750            publication.replace_repository_graph(project, &[], &[], &[], &[])?;
13751            publication.complete()?;
13752        }
13753        let generation_state = store.conditionally_set_purpose(
13754            task,
13755            "src/generation.rs",
13756            &generation.work_key,
13757            &generation.state_token,
13758            "Prior-generation answer",
13759        )?;
13760        require_eq(
13761            &generation_state,
13762            &PurposeConditionalApplyState::Stale,
13763            "generation-bound state",
13764        )?;
13765
13766        let current = store.load_nodes_by_paths(&[
13767            "src/changed.rs".to_string(),
13768            "src/accepted.rs".to_string(),
13769            "src/generation.rs".to_string(),
13770        ])?;
13771        let purposes = current
13772            .iter()
13773            .map(|node| {
13774                (
13775                    node.node.path.as_str(),
13776                    node.purpose.purpose.as_deref(),
13777                    node.purpose.status,
13778                )
13779            })
13780            .collect::<Vec<_>>();
13781        require_eq(
13782            &purposes,
13783            &vec![
13784                (
13785                    "src/accepted.rs",
13786                    Some("Concurrent accepted purpose"),
13787                    PurposeStatus::Approved,
13788                ),
13789                (
13790                    "src/changed.rs",
13791                    Some("Concurrent suggestion"),
13792                    PurposeStatus::Suggested,
13793                ),
13794                ("src/generation.rs", None, PurposeStatus::Missing),
13795            ],
13796            "conflicting rows remain untouched",
13797        )?;
13798
13799        let rollback_batch = store.load_purpose_curation_batch(
13800            task,
13801            &[
13802                "src/rollback-a.rs".to_string(),
13803                "src/rollback-b.rs".to_string(),
13804            ],
13805        )?;
13806        let rollback_requests = rollback_batch
13807            .items
13808            .iter()
13809            .map(|candidate| {
13810                conditional_purpose_request(
13811                    task,
13812                    candidate,
13813                    &format!("Reviewed {}", candidate.node.node.path),
13814                )
13815            })
13816            .collect::<Vec<_>>();
13817        store.connection.execute_batch(
13818            "
13819            CREATE TEMP TRIGGER abort_second_conditional_purpose_update
13820            BEFORE UPDATE ON purposes
13821            WHEN OLD.node_id = (
13822                SELECT id FROM nodes WHERE path = 'src/rollback-b.rs'
13823            )
13824            BEGIN
13825                SELECT RAISE(ABORT, 'forced conditional purpose rollback');
13826            END;
13827            ",
13828        )?;
13829        if store.conditionally_set_purposes(&rollback_requests).is_ok() {
13830            return Err(io::Error::other("injected conditional batch failure succeeded").into());
13831        }
13832        drop(store);
13833        let reopened = AtlasStore::open_for_project(&database, &root)?;
13834        let rolled_back = reopened.load_nodes_by_paths(&[
13835            "src/rollback-a.rs".to_string(),
13836            "src/rollback-b.rs".to_string(),
13837        ])?;
13838        require_eq(
13839            &rolled_back
13840                .iter()
13841                .map(|node| node.purpose.status)
13842                .collect::<Vec<_>>(),
13843            &vec![PurposeStatus::Missing, PurposeStatus::Missing],
13844            "failed conditional batch rolled back after reopen",
13845        )?;
13846        Ok(())
13847    }
13848
13849    #[test]
13850    fn concurrent_purpose_curators_cannot_overwrite_the_winner() -> Result<(), Box<dyn Error>> {
13851        let temp = tempfile::tempdir()?;
13852        let root = temp.path().join("project");
13853        fs::create_dir(&root)?;
13854        let database = temp.path().join("projectatlas.db");
13855        let mut store = AtlasStore::open_for_project(&database, &root)?;
13856        store.replace_scan(&[test_file_node("src/main.rs", "hash-main")])?;
13857        let candidate = store
13858            .load_purpose_curation_batch("concurrent-curators", &["src/main.rs".to_string()])?
13859            .items
13860            .into_iter()
13861            .next()
13862            .ok_or_else(|| io::Error::other("concurrent candidate missing"))?;
13863        drop(store);
13864
13865        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
13866        let mut handles = Vec::new();
13867        for purpose in ["First reviewed purpose", "Second reviewed purpose"] {
13868            let database = database.clone();
13869            let root = root.clone();
13870            let barrier = std::sync::Arc::clone(&barrier);
13871            let candidate = candidate.clone();
13872            handles.push(std::thread::spawn(move || {
13873                let store = AtlasStore::open_for_project(&database, &root)?;
13874                barrier.wait();
13875                store.conditionally_set_purpose(
13876                    "concurrent-curators",
13877                    "src/main.rs",
13878                    &candidate.work_key,
13879                    &candidate.state_token,
13880                    purpose,
13881                )
13882            }));
13883        }
13884        let outcomes = handles
13885            .into_iter()
13886            .map(|handle| {
13887                handle
13888                    .join()
13889                    .map_err(|_panic| io::Error::other("curator thread panicked"))?
13890                    .map_err(io::Error::other)
13891            })
13892            .collect::<Result<Vec<_>, io::Error>>()?;
13893        require_eq(
13894            &outcomes
13895                .iter()
13896                .filter(|state| **state == PurposeConditionalApplyState::Applied)
13897                .count(),
13898            &1,
13899            "single concurrent winner",
13900        )?;
13901        require_eq(
13902            &outcomes
13903                .iter()
13904                .filter(|state| **state == PurposeConditionalApplyState::Accepted)
13905                .count(),
13906            &1,
13907            "accepted concurrent loser",
13908        )?;
13909
13910        let reopened = AtlasStore::open_for_project(&database, &root)?;
13911        let node = reopened
13912            .load_node_by_path("src/main.rs")?
13913            .ok_or_else(|| io::Error::other("concurrent path disappeared"))?;
13914        require_eq(
13915            &node.purpose.status,
13916            &PurposeStatus::Approved,
13917            "persisted concurrent winner status",
13918        )?;
13919        require_eq(
13920            &matches!(
13921                node.purpose.purpose.as_deref(),
13922                Some("First reviewed purpose" | "Second reviewed purpose")
13923            ),
13924            &true,
13925            "persisted concurrent winner value",
13926        )?;
13927        Ok(())
13928    }
13929
13930    #[test]
13931    fn purpose_curation_hydration_uses_existing_unique_indexes() -> Result<(), Box<dyn Error>> {
13932        let mut store = AtlasStore::in_memory()?;
13933        store.replace_scan(&[
13934            test_file_node("src/a.rs", "hash-a"),
13935            test_file_node("src/b.rs", "hash-b"),
13936        ])?;
13937        let sql = format!("EXPLAIN QUERY PLAN {}", load_nodes_by_paths_sql(2));
13938        let mut statement = store.connection.prepare(&sql)?;
13939        let details = statement
13940            .query_map(params!["src/a.rs", "src/b.rs"], |row| {
13941                row.get::<_, String>(3)
13942            })?
13943            .collect::<Result<Vec<_>, _>>()?;
13944        let plan = details.join("\n");
13945        require_eq(
13946            &plan.contains("SEARCH n USING INDEX sqlite_autoindex_nodes_"),
13947            &true,
13948            "unique path index query plan",
13949        )?;
13950        require_eq(
13951            &plan.contains("SEARCH p USING INTEGER PRIMARY KEY"),
13952            &true,
13953            "purpose primary-key query plan",
13954        )?;
13955        require_eq(&plan.contains("SCAN n"), &false, "no node-table scan")?;
13956        Ok(())
13957    }
13958
13959    #[test]
13960    fn updates_content_summary_without_approving_purpose() -> Result<(), Box<dyn Error>> {
13961        let mut store = AtlasStore::in_memory()?;
13962        let node = Node {
13963            path: "src/lib.rs".to_string(),
13964            kind: NodeKind::File,
13965            parent_path: normalized_parent("src/lib.rs"),
13966            extension: Some(".rs".to_string()),
13967            language: Some("rust".to_string()),
13968            size_bytes: Some(24),
13969            mtime_ns: Some(10),
13970            content_hash: Some("def".to_string()),
13971        };
13972        store.replace_scan(&[node])?;
13973        store.set_node_summary(
13974            "src/lib.rs",
13975            "rust source defining library entry functions.",
13976        )?;
13977        let nodes = store.load_nodes()?;
13978        require_eq(
13979            &nodes[0].summary,
13980            &Some("rust source defining library entry functions.".to_string()),
13981            "updated content summary",
13982        )?;
13983        require_eq(
13984            &nodes[0].purpose.status,
13985            &PurposeStatus::Missing,
13986            "summary update does not approve purpose",
13987        )?;
13988        require_eq(
13989            &store.has_agent_approved_purpose()?,
13990            &false,
13991            "missing purpose does not activate graph hydration",
13992        )?;
13993        store.set_purpose(
13994            "src/lib.rs",
13995            "Owns the library entry points.",
13996            PurposeSource::Agent,
13997        )?;
13998        require_eq(
13999            &store.has_agent_approved_purpose()?,
14000            &true,
14001            "agent-approved purpose activates graph hydration",
14002        )?;
14003        Ok(())
14004    }
14005
14006    #[test]
14007    fn replaces_symbol_graph_idempotently() -> Result<(), Box<dyn Error>> {
14008        let mut store = AtlasStore::in_memory()?;
14009        let graph = SymbolGraph {
14010            path: "src/main.rs".to_string(),
14011            language: Some("rust".to_string()),
14012            parser: ParserKind::TreeSitter,
14013            symbols: vec![CodeSymbol {
14014                path: "src/main.rs".to_string(),
14015                language: Some("rust".to_string()),
14016                name: "main".to_string(),
14017                kind: SymbolKind::Function,
14018                signature: "fn main()".to_string(),
14019                exported: true,
14020                documentation: Some("Run the application.".to_string()),
14021                line_start: 1,
14022                line_end: 3,
14023                source_selector: None,
14024                parent: None,
14025                parser: ParserKind::TreeSitter,
14026                detail: Some("function_item".to_string()),
14027            }],
14028            relations: vec![SymbolRelation {
14029                path: "src/main.rs".to_string(),
14030                source_name: "main".to_string(),
14031                target_name: "println!".to_string(),
14032                kind: RelationKind::Calls,
14033                line: 2,
14034                context: "println!(\"hello\")".to_string(),
14035                parser: ParserKind::TreeSitter,
14036            }],
14037        };
14038
14039        store.replace_symbol_graph(&graph)?;
14040        store.replace_symbol_graph(&graph)?;
14041        let symbols = store.load_symbols(Some("src/main.rs"), Some("main"), 10)?;
14042        let relations = store.load_symbol_relations(Some("src/main.rs"), Some("println"), 10)?;
14043        let metadata = store
14044            .load_source_parse_metadata("src/main.rs")?
14045            .ok_or_else(|| io::Error::other("missing source parse metadata"))?;
14046        require_eq(&symbols.len(), &1, "symbol count after replace")?;
14047        require_eq(&relations.len(), &1, "relation count after replace")?;
14048        require_eq(&metadata.parser, &ParserKind::TreeSitter, "metadata parser")?;
14049        require_eq(&metadata.symbol_count, &1, "metadata symbol count")?;
14050        require_eq(&metadata.relation_count, &1, "metadata relation count")?;
14051        require_eq(&symbols[0].exported, &true, "exported metadata")?;
14052        require_eq(
14053            &symbols[0].documentation,
14054            &Some("Run the application.".to_string()),
14055            "documentation metadata",
14056        )?;
14057        Ok(())
14058    }
14059
14060    #[test]
14061    fn symbol_source_selectors_round_trip_all_read_paths_and_reopen() -> Result<(), Box<dyn Error>>
14062    {
14063        let temp = tempfile::tempdir()?;
14064        let root = temp.path().join("repository");
14065        fs::create_dir(&root)?;
14066        let database = temp.path().join("projectatlas.db");
14067        let path = "docs/guide.md";
14068        let expected = SymbolSourceSelector {
14069            byte_start: 17,
14070            byte_end: 31,
14071            column_start: 4,
14072            column_end: 2,
14073        };
14074        let graph = SymbolGraph {
14075            path: path.to_string(),
14076            language: Some("markdown".to_string()),
14077            parser: ParserKind::Structural,
14078            symbols: vec![
14079                CodeSymbol {
14080                    path: path.to_string(),
14081                    language: Some("markdown".to_string()),
14082                    name: "Overview".to_string(),
14083                    kind: SymbolKind::Heading,
14084                    signature: "overview#1".to_string(),
14085                    exported: false,
14086                    documentation: None,
14087                    line_start: 2,
14088                    line_end: 3,
14089                    source_selector: Some(expected),
14090                    parent: None,
14091                    parser: ParserKind::Structural,
14092                    detail: Some("level=2".to_string()),
14093                },
14094                CodeSymbol {
14095                    path: path.to_string(),
14096                    language: Some("markdown".to_string()),
14097                    name: "Compatibility".to_string(),
14098                    kind: SymbolKind::Heading,
14099                    signature: "compatibility#1".to_string(),
14100                    exported: false,
14101                    documentation: None,
14102                    line_start: 6,
14103                    line_end: 6,
14104                    source_selector: None,
14105                    parent: None,
14106                    parser: ParserKind::Structural,
14107                    detail: Some("level=2".to_string()),
14108                },
14109            ],
14110            relations: Vec::new(),
14111        };
14112
14113        let mut store = AtlasStore::open_for_project(&database, &root)?;
14114        store.replace_scan(&[test_file_node(path, "guide-hash")])?;
14115        store.replace_symbol_graph(&graph)?;
14116        let mut publication = store.begin_index_publication("symbol-source-selectors")?;
14117        publication.upsert_file_content_classification_batch(&[FileContentClassification {
14118            path: path.to_string(),
14119            classification: ContentClassification::Documentation,
14120        }])?;
14121        publication.complete()?;
14122        verify_symbol_source_selector_read_paths(&store, path, expected)?;
14123        drop(store);
14124
14125        let reopened = AtlasStore::open_for_project(&database, &root)?;
14126        verify_symbol_source_selector_read_paths(&reopened, path, expected)?;
14127        drop(reopened);
14128
14129        let read_only = AtlasStore::open_read_only_for_project(&database, &root)?;
14130        verify_symbol_source_selector_read_paths(&read_only, path, expected)?;
14131        read_only.finish_index_read_snapshot()?;
14132        Ok(())
14133    }
14134
14135    #[test]
14136    fn symbol_source_selector_constraints_and_corrupt_reads_fail_closed()
14137    -> Result<(), Box<dyn Error>> {
14138        let mut store = AtlasStore::in_memory()?;
14139        let path = "docs/guide.md";
14140        store.replace_symbol_graph(&SymbolGraph {
14141            path: path.to_string(),
14142            language: Some("markdown".to_string()),
14143            parser: ParserKind::Structural,
14144            symbols: vec![CodeSymbol {
14145                path: path.to_string(),
14146                language: Some("markdown".to_string()),
14147                name: "Overview".to_string(),
14148                kind: SymbolKind::Heading,
14149                signature: "overview#1".to_string(),
14150                exported: false,
14151                documentation: None,
14152                line_start: 2,
14153                line_end: 2,
14154                source_selector: None,
14155                parent: None,
14156                parser: ParserKind::Structural,
14157                detail: None,
14158            }],
14159            relations: Vec::new(),
14160        })?;
14161
14162        for invalid_update in [
14163            "UPDATE symbols SET source_byte_start = 0 WHERE path = 'docs/guide.md'",
14164            "UPDATE symbols SET source_byte_start = -1, source_byte_end = 4,
14165                                source_column_start = 0, source_column_end = 4
14166              WHERE path = 'docs/guide.md'",
14167            "UPDATE symbols SET source_byte_start = 5, source_byte_end = 4,
14168                                source_column_start = 0, source_column_end = 4
14169              WHERE path = 'docs/guide.md'",
14170            "UPDATE symbols SET source_byte_start = 0, source_byte_end = 4,
14171                                source_column_start = 5, source_column_end = 4
14172              WHERE path = 'docs/guide.md'",
14173        ] {
14174            require(
14175                store.connection.execute(invalid_update, []).is_err(),
14176                "invalid source selector bypassed its table constraint",
14177            )?;
14178        }
14179        store.connection.execute(
14180            "UPDATE symbols
14181                SET source_byte_start = 0, source_byte_end = 4,
14182                    source_column_start = 0, source_column_end = 4
14183              WHERE path = ?1",
14184            [path],
14185        )?;
14186        require_symbol_source_selectors(
14187            &store.load_symbols(Some(path), None, 10)?,
14188            Some(SymbolSourceSelector {
14189                byte_start: 0,
14190                byte_end: 4,
14191                column_start: 0,
14192                column_end: 4,
14193            }),
14194            "valid constrained selector",
14195        )?;
14196
14197        store.connection.execute_batch(
14198            "PRAGMA ignore_check_constraints = ON;
14199             UPDATE symbols
14200                SET source_byte_start = 0, source_byte_end = NULL,
14201                    source_column_start = 0, source_column_end = 4
14202              WHERE path = 'docs/guide.md';
14203             PRAGMA ignore_check_constraints = OFF;",
14204        )?;
14205        let corrupt = store.load_symbols(Some(path), None, 10);
14206        require(
14207            matches!(corrupt, Err(DbError::Sqlite(_))),
14208            "partially populated source selector did not fail closed",
14209        )?;
14210        Ok(())
14211    }
14212
14213    #[test]
14214    fn symbol_source_selector_write_failure_rolls_back_graph() -> Result<(), Box<dyn Error>> {
14215        let mut store = AtlasStore::in_memory()?;
14216        let path = "docs/guide.md";
14217        let previous = SymbolGraph {
14218            path: path.to_string(),
14219            language: Some("markdown".to_string()),
14220            parser: ParserKind::Structural,
14221            symbols: vec![batch_test_symbol(path, "previous", 1, 0)],
14222            relations: Vec::new(),
14223        };
14224        store.replace_symbol_graph(&previous)?;
14225
14226        let mut valid = batch_test_symbol(path, "valid", 2, 0);
14227        valid.source_selector = Some(SymbolSourceSelector {
14228            byte_start: 4,
14229            byte_end: 9,
14230            column_start: 0,
14231            column_end: 5,
14232        });
14233        let mut invalid = batch_test_symbol(path, "invalid", 3, 0);
14234        invalid.source_selector = Some(SymbolSourceSelector {
14235            byte_start: 12,
14236            byte_end: 11,
14237            column_start: 0,
14238            column_end: 5,
14239        });
14240        let replacement = SymbolGraph {
14241            path: path.to_string(),
14242            language: Some("markdown".to_string()),
14243            parser: ParserKind::Structural,
14244            symbols: vec![valid, invalid],
14245            relations: Vec::new(),
14246        };
14247        let Err(error) = store.replace_symbol_graph(&replacement) else {
14248            return Err(io::Error::other("invalid selector replacement committed").into());
14249        };
14250        require(
14251            matches!(error, DbError::SymbolGraphRowShape { .. }),
14252            "invalid selector replacement returned the wrong error",
14253        )?;
14254        require_eq(
14255            &store.load_symbol_graphs_for_paths(&[path.to_string()])?,
14256            &vec![previous],
14257            "selector failure transaction rollback",
14258        )?;
14259        Ok(())
14260    }
14261
14262    #[test]
14263    fn classified_symbol_list_filters_before_limit_and_preserves_legacy_order()
14264    -> Result<(), Box<dyn Error>> {
14265        let mut store = AtlasStore::in_memory()?;
14266        let classified_paths = [
14267            (
14268                "config/first.toml",
14269                ContentClassification::ConfigurationData,
14270            ),
14271            ("docs/guide.md", ContentClassification::Documentation),
14272            ("other/notes.txt", ContentClassification::OtherText),
14273            ("src/lib.rs", ContentClassification::Source),
14274            ("src/second.rs", ContentClassification::Source),
14275        ];
14276        let nodes = classified_paths
14277            .iter()
14278            .enumerate()
14279            .map(|(index, (path, _))| test_file_node(path, &format!("hash-{index}")))
14280            .collect::<Vec<_>>();
14281        store.replace_scan(&nodes)?;
14282        for (index, (path, _)) in classified_paths.iter().enumerate() {
14283            store.replace_symbol_graph(&SymbolGraph {
14284                path: (*path).to_string(),
14285                language: Some("rust".to_string()),
14286                parser: ParserKind::TreeSitter,
14287                symbols: vec![batch_test_symbol(path, &format!("needle_{index}"), 1, 0)],
14288                relations: Vec::new(),
14289            })?;
14290        }
14291        let classifications = classified_paths
14292            .iter()
14293            .map(|(path, classification)| FileContentClassification {
14294                path: (*path).to_string(),
14295                classification: *classification,
14296            })
14297            .collect::<Vec<_>>();
14298        let mut publication = store.begin_index_publication("classified-symbol-list")?;
14299        publication.upsert_file_content_classification_batch(&classifications)?;
14300        publication.complete()?;
14301
14302        for (file, query, limit) in [
14303            (None, None, 3),
14304            (None, Some("needle"), 3),
14305            (Some("src/second.rs"), None, 10),
14306            (Some("src/second.rs"), Some("needle_4"), 10),
14307            (Some("src/second.rs"), Some("second"), 10),
14308            (None, None, 0),
14309        ] {
14310            let legacy = store.load_symbols(file, query, limit)?;
14311            let classified = store.load_classified_symbols(
14312                file,
14313                query,
14314                ContentSelection::UnspecifiedLegacy,
14315                limit,
14316            )?;
14317            require_eq(
14318                &classified
14319                    .into_iter()
14320                    .map(|row| row.symbol)
14321                    .collect::<Vec<_>>(),
14322                &legacy,
14323                "omitted classified symbol candidates and order",
14324            )?;
14325        }
14326        let omitted = store.load_classified_symbols(
14327            None,
14328            Some("needle"),
14329            ContentSelection::UnspecifiedLegacy,
14330            3,
14331        )?;
14332        require_eq(
14333            &omitted
14334                .iter()
14335                .map(|row| row.classification)
14336                .collect::<Vec<_>>(),
14337            &vec![
14338                ContentClassification::ConfigurationData,
14339                ContentClassification::Documentation,
14340                ContentClassification::OtherText,
14341            ],
14342            "additive legacy classifications",
14343        )?;
14344
14345        let source =
14346            store.load_classified_symbols(None, Some("needle"), ContentSelection::Source, 1)?;
14347        require_eq(
14348            &source.first().map(|row| row.symbol.path.as_str()),
14349            &Some("src/lib.rs"),
14350            "source selection before limit",
14351        )?;
14352        let documentation = store.load_classified_symbols(
14353            None,
14354            Some("needle"),
14355            ContentSelection::Documentation,
14356            1,
14357        )?;
14358        require_eq(
14359            &documentation.first().map(|row| row.symbol.path.as_str()),
14360            &Some("docs/guide.md"),
14361            "documentation selection before limit",
14362        )?;
14363        let both =
14364            store.load_classified_symbols(None, Some("needle"), ContentSelection::Both, 2)?;
14365        require_eq(
14366            &both
14367                .iter()
14368                .map(|row| row.symbol.path.as_str())
14369                .collect::<Vec<_>>(),
14370            &vec!["docs/guide.md", "src/lib.rs"],
14371            "combined selection order",
14372        )?;
14373        let file_query = store.load_classified_symbols(
14374            Some("src/second.rs"),
14375            Some("needle_4"),
14376            ContentSelection::Source,
14377            10,
14378        )?;
14379        require_eq(&file_query.len(), &1, "file/query classified symbol filter")?;
14380        Ok(())
14381    }
14382
14383    #[test]
14384    fn classified_symbol_list_rejects_missing_owner_and_uses_existing_indexes()
14385    -> Result<(), Box<dyn Error>> {
14386        let mut store = AtlasStore::in_memory()?;
14387        store.replace_scan(&[test_file_node("src/lib.rs", "hash")])?;
14388        store.replace_symbol_graph(&SymbolGraph {
14389            path: "src/lib.rs".to_string(),
14390            language: Some("rust".to_string()),
14391            parser: ParserKind::TreeSitter,
14392            symbols: vec![batch_test_symbol("src/lib.rs", "needle", 1, 0)],
14393            relations: Vec::new(),
14394        })?;
14395
14396        for selection in [
14397            ContentSelection::UnspecifiedLegacy,
14398            ContentSelection::Source,
14399            ContentSelection::Both,
14400        ] {
14401            let (sql, bindings) = classified_symbols_sql(None, Some("needle"), selection, 1);
14402            let mut statement = store
14403                .connection
14404                .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
14405            let plan = statement
14406                .query_map(params_from_iter(bindings.iter()), |row| {
14407                    row.get::<_, String>(3)
14408                })?
14409                .collect::<Result<Vec<_>, _>>()?
14410                .join("\n");
14411            require(
14412                plan.contains("idx_symbols_path")
14413                    && plan.contains("sqlite_autoindex_file_content_classifications_1")
14414                    && !plan.contains("SCAN classification"),
14415                &format!("classified symbol query missed existing indexes: {plan}"),
14416            )?;
14417        }
14418
14419        store.connection.execute(
14420            "DELETE FROM file_content_classifications WHERE path = 'src/lib.rs'",
14421            [],
14422        )?;
14423        for selection in [
14424            ContentSelection::UnspecifiedLegacy,
14425            ContentSelection::Source,
14426            ContentSelection::Both,
14427        ] {
14428            let Err(error) = store.load_classified_symbols(Some("src/lib.rs"), None, selection, 10)
14429            else {
14430                return Err(
14431                    io::Error::other("symbol without a file classification was accepted").into(),
14432                );
14433            };
14434            require(
14435                matches!(error, DbError::FileContentClassificationMissing { .. }),
14436                "missing symbol classification returned the wrong error",
14437            )?;
14438        }
14439        Ok(())
14440    }
14441
14442    #[test]
14443    fn import_alias_lookup_uses_kind_first_covering_index() -> Result<(), Box<dyn Error>> {
14444        let mut store = AtlasStore::in_memory()?;
14445        store.replace_symbol_graph(&SymbolGraph {
14446            path: "src/main.rs".to_string(),
14447            language: Some("rust".to_string()),
14448            parser: ParserKind::TreeSitter,
14449            symbols: Vec::new(),
14450            relations: vec![
14451                SymbolRelation {
14452                    path: "src/main.rs".to_string(),
14453                    source_name: "main".to_string(),
14454                    target_name: "use crate::service::run".to_string(),
14455                    kind: RelationKind::Imports,
14456                    line: 1,
14457                    context: "use crate::service::run;".to_string(),
14458                    parser: ParserKind::TreeSitter,
14459                },
14460                SymbolRelation {
14461                    path: "src/main.rs".to_string(),
14462                    source_name: "main".to_string(),
14463                    target_name: "use crate::other::Thing".to_string(),
14464                    kind: RelationKind::Imports,
14465                    line: 2,
14466                    context: "use crate::other::Thing;".to_string(),
14467                    parser: ParserKind::TreeSitter,
14468                },
14469            ],
14470        })?;
14471
14472        let relations =
14473            store.load_import_relations_matching_targets(&["service".to_string()], 10)?;
14474        require_eq(&relations.len(), &1, "matched import relation count")?;
14475        require_eq(
14476            &store
14477                .load_import_relations_for_path("src/main.rs", 10)?
14478                .len(),
14479            &2,
14480            "exact-path import relation count",
14481        )?;
14482        let mut statement = store.connection.prepare(
14483            "EXPLAIN QUERY PLAN
14484             SELECT path, source_name, target_name, line
14485             FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
14486             WHERE kind = 'imports' AND target_name LIKE '%service%' ESCAPE '\\'
14487             ORDER BY path, line, source_name, target_name
14488             LIMIT 10",
14489        )?;
14490        let plan = statement
14491            .query_map([], |row| row.get::<_, String>(3))?
14492            .collect::<Result<Vec<_>, _>>()?
14493            .join("\n");
14494        require(
14495            plan.contains(
14496                "SEARCH symbol_relations USING COVERING INDEX idx_symbol_import_alias_lookup (kind=?)",
14497            ),
14498            &format!("import alias lookup missed kind-first covering index: {plan}"),
14499        )?;
14500        Ok(())
14501    }
14502
14503    #[test]
14504    fn file_scoped_symbol_kind_lookup_uses_path_index() -> Result<(), Box<dyn Error>> {
14505        let store = AtlasStore::in_memory()?;
14506        for sql in [
14507            "EXPLAIN QUERY PLAN
14508             SELECT path, language, name, kind, signature, line_start, line_end,
14509                    parent, parser, detail, exported, documentation,
14510                    source_byte_start, source_byte_end,
14511                    source_column_start, source_column_end
14512             FROM symbols INDEXED BY idx_symbols_path
14513             WHERE path = 'src/lib.rs' AND kind IN ('function')
14514             ORDER BY path, line_start, name
14515             LIMIT 25",
14516            "EXPLAIN QUERY PLAN
14517             SELECT COUNT(*) FROM symbols INDEXED BY idx_symbols_path
14518             WHERE path = 'src/lib.rs' AND kind IN ('function')",
14519        ] {
14520            let mut statement = store.connection.prepare(sql)?;
14521            let plan = statement
14522                .query_map([], |row| row.get::<_, String>(3))?
14523                .collect::<Result<Vec<_>, _>>()?
14524                .join("\n");
14525            require(
14526                plan.contains("SEARCH symbols USING INDEX idx_symbols_path (path=?)"),
14527                &format!("file-scoped symbol lookup missed exact-path index: {plan}"),
14528            )?;
14529        }
14530        Ok(())
14531    }
14532
14533    #[test]
14534    fn preserves_source_parse_and_fact_parser_provenance_independently()
14535    -> Result<(), Box<dyn Error>> {
14536        let temp = tempfile::tempdir()?;
14537        let database = temp.path().join("projectatlas.db");
14538        let mut store = AtlasStore::open(&database)?;
14539        let graph = SymbolGraph {
14540            path: "src/optional.lang".to_string(),
14541            language: Some("optional-language".to_string()),
14542            parser: ParserKind::Fallback,
14543            symbols: vec![CodeSymbol {
14544                path: "src/optional.lang".to_string(),
14545                language: Some("optional-language".to_string()),
14546                name: "entry".to_string(),
14547                kind: SymbolKind::Function,
14548                signature: "entry()".to_string(),
14549                exported: false,
14550                documentation: None,
14551                line_start: 1,
14552                line_end: 1,
14553                source_selector: None,
14554                parent: None,
14555                parser: ParserKind::Fallback,
14556                detail: None,
14557            }],
14558            relations: vec![SymbolRelation {
14559                path: "src/optional.lang".to_string(),
14560                source_name: "entry".to_string(),
14561                target_name: "helper".to_string(),
14562                kind: RelationKind::Calls,
14563                line: 1,
14564                context: "entry()".to_string(),
14565                parser: ParserKind::Fallback,
14566            }],
14567        };
14568        let metadata = SourceParseMetadata {
14569            path: graph.path.clone(),
14570            language: graph.language.clone(),
14571            parser: ParserKind::TreeSitter,
14572            symbol_count: graph.symbols.len(),
14573            relation_count: graph.relations.len(),
14574        };
14575
14576        store.replace_symbol_graph_with_metadata(&graph, &metadata)?;
14577
14578        let stored_metadata = store
14579            .load_source_parse_metadata(&graph.path)?
14580            .ok_or_else(|| io::Error::other("missing independent source parse metadata"))?;
14581        let symbols = store.load_symbols(Some(&graph.path), Some("entry"), 10)?;
14582        let relations = store.load_symbol_relations(Some(&graph.path), Some("helper"), 10)?;
14583        require_eq(
14584            &stored_metadata.parser,
14585            &ParserKind::TreeSitter,
14586            "grammar-backed source parser",
14587        )?;
14588        require_eq(
14589            &symbols[0].parser,
14590            &ParserKind::Fallback,
14591            "fallback symbol provenance",
14592        )?;
14593        require_eq(
14594            &relations[0].parser,
14595            &ParserKind::Fallback,
14596            "fallback relation provenance",
14597        )?;
14598
14599        let invalid_metadata = SourceParseMetadata {
14600            symbol_count: 0,
14601            ..metadata
14602        };
14603        let Err(error) = store.replace_symbol_graph_with_metadata(&graph, &invalid_metadata) else {
14604            return Err(io::Error::other("mismatched explicit metadata was accepted").into());
14605        };
14606        if !matches!(error, DbError::SymbolGraphRowShape { .. }) {
14607            return Err(io::Error::other(format!(
14608                "mismatched explicit metadata returned the wrong error: {error}"
14609            ))
14610            .into());
14611        }
14612
14613        let empty_graph = SymbolGraph {
14614            path: "src/empty.optional".to_string(),
14615            language: Some("optional-language".to_string()),
14616            parser: ParserKind::Fallback,
14617            symbols: Vec::new(),
14618            relations: Vec::new(),
14619        };
14620        let empty_metadata = SourceParseMetadata {
14621            path: empty_graph.path.clone(),
14622            language: empty_graph.language.clone(),
14623            parser: ParserKind::TreeSitter,
14624            symbol_count: 0,
14625            relation_count: 0,
14626        };
14627        store.replace_symbol_graph_with_metadata(&empty_graph, &empty_metadata)?;
14628        drop(store);
14629
14630        let reader = AtlasStore::open_read_only(&database)?;
14631        let reopened_metadata = reader
14632            .load_source_parse_metadata(&graph.path)?
14633            .ok_or_else(|| io::Error::other("missing reopened source parse metadata"))?;
14634        let metadata_paths = reader.source_parse_metadata_paths_for_paths(&[
14635            "src/missing.optional".to_string(),
14636            empty_graph.path.clone(),
14637            graph.path.clone(),
14638            graph.path.clone(),
14639        ])?;
14640        let reopened_graphs =
14641            reader.load_symbol_graphs_for_paths(&[graph.path.clone(), empty_graph.path.clone()])?;
14642        require_eq(
14643            &reopened_metadata.parser,
14644            &ParserKind::TreeSitter,
14645            "reopened source parser provenance",
14646        )?;
14647        require_eq(
14648            &metadata_paths,
14649            &HashSet::from([graph.path.clone(), empty_graph.path.clone()]),
14650            "batched source parse metadata paths",
14651        )?;
14652        require_eq(
14653            &reopened_graphs,
14654            &vec![empty_graph, graph],
14655            "reopened fact graph provenance",
14656        )?;
14657        reader.finish_index_read_snapshot()?;
14658        Ok(())
14659    }
14660
14661    #[test]
14662    fn reconstructs_exact_symbol_graph_batches_from_disk_and_fails_closed()
14663    -> Result<(), Box<dyn Error>> {
14664        let temp = tempfile::tempdir()?;
14665        let db_path = temp.path().join("projectatlas.db");
14666        let mut store = AtlasStore::open(&db_path)?;
14667        store.replace_scan(&[
14668            test_file_node("src/a.rs", "hash-a"),
14669            test_file_node("src/b.rs", "hash-b"),
14670        ])?;
14671        let graph = SymbolGraph {
14672            path: "src/a.rs".to_string(),
14673            language: Some("rust".to_string()),
14674            parser: ParserKind::TreeSitter,
14675            symbols: vec![CodeSymbol {
14676                path: "src/a.rs".to_string(),
14677                language: Some("rust".to_string()),
14678                name: "owner".to_string(),
14679                kind: SymbolKind::Function,
14680                signature: "fn owner()".to_string(),
14681                exported: true,
14682                documentation: None,
14683                line_start: 1,
14684                line_end: 2,
14685                source_selector: None,
14686                parent: None,
14687                parser: ParserKind::TreeSitter,
14688                detail: Some("function_item".to_string()),
14689            }],
14690            relations: vec![SymbolRelation {
14691                path: "src/a.rs".to_string(),
14692                source_name: "owner".to_string(),
14693                target_name: "dependency".to_string(),
14694                kind: RelationKind::Calls,
14695                line: 2,
14696                context: "dependency()".to_string(),
14697                parser: ParserKind::TreeSitter,
14698            }],
14699        };
14700        store.replace_symbol_graph(&graph)?;
14701        let empty_graph = SymbolGraph {
14702            path: "src/b.rs".to_string(),
14703            language: Some("rust".to_string()),
14704            parser: ParserKind::TreeSitter,
14705            symbols: Vec::new(),
14706            relations: Vec::new(),
14707        };
14708        store.replace_symbol_graph(&empty_graph)?;
14709        drop(store);
14710
14711        let reader = AtlasStore::open_read_only(&db_path)?;
14712        let loaded = reader.load_symbol_graphs_for_paths(&[
14713            "src/b.rs".to_string(),
14714            "src/a.rs".to_string(),
14715            "src/a.rs".to_string(),
14716            "src/missing.rs".to_string(),
14717        ])?;
14718        require_eq(
14719            &loaded,
14720            &vec![graph, empty_graph],
14721            "batched symbol graph round trip",
14722        )?;
14723        for (table, expected_index) in [
14724            (
14725                "source_parse_metadata",
14726                "sqlite_autoindex_source_parse_metadata_1",
14727            ),
14728            ("symbols", "idx_symbols_path"),
14729            ("symbol_relations", "idx_symbol_relations_path"),
14730        ] {
14731            let sql = format!(
14732                "EXPLAIN QUERY PLAN SELECT path FROM {table}
14733                  WHERE path IN ('src/a.rs', 'src/b.rs')"
14734            );
14735            let mut statement = reader.connection.prepare(&sql)?;
14736            let plan = statement
14737                .query_map([], |row| row.get::<_, String>(3))?
14738                .collect::<Result<Vec<_>, _>>()?
14739                .join("\n");
14740            if !plan.contains(expected_index) {
14741                return Err(io::Error::other(format!(
14742                    "{table} batch lookup missed {expected_index}: {plan}"
14743                ))
14744                .into());
14745            }
14746        }
14747        reader.finish_index_read_snapshot()?;
14748        drop(reader);
14749
14750        let writer = AtlasStore::open(&db_path)?;
14751        writer.connection.execute(
14752            "UPDATE source_parse_metadata SET symbol_count = 2 WHERE path = 'src/a.rs'",
14753            [],
14754        )?;
14755        let Err(error) = writer.load_symbol_graphs_for_paths(&["src/a.rs".to_string()]) else {
14756            return Err(io::Error::other("metadata count corruption was accepted").into());
14757        };
14758        if !matches!(error, DbError::SymbolGraphRowShape { .. }) {
14759            return Err(io::Error::other(format!(
14760                "metadata count corruption returned the wrong error: {error}"
14761            ))
14762            .into());
14763        }
14764        Ok(())
14765    }
14766
14767    #[test]
14768    fn bounded_symbol_batch_omits_document_coverage_before_limits() -> Result<(), Box<dyn Error>> {
14769        let mut store = AtlasStore::in_memory()?;
14770        store.replace_scan(&[test_file_node("docs/partial.docx", "hash-docx")])?;
14771        let mut symbol = batch_test_symbol("docs/partial.docx", "document-symbol-1", 2, 0);
14772        symbol.language = Some("docx".to_owned());
14773        symbol.kind = SymbolKind::Value;
14774        symbol.parser = ParserKind::Structural;
14775        let mut coverage = batch_test_symbol("docs/partial.docx", "document-text-coverage", 1, 0);
14776        coverage.language = Some("docx".to_owned());
14777        coverage.kind = SymbolKind::Unknown;
14778        coverage.parser = ParserKind::Structural;
14779        store.replace_symbol_graph(&SymbolGraph {
14780            path: "docs/partial.docx".to_owned(),
14781            language: Some("docx".to_owned()),
14782            parser: ParserKind::Structural,
14783            symbols: vec![coverage, symbol],
14784            relations: Vec::new(),
14785        })?;
14786        let result = store.load_symbols_for_paths_bounded(
14787            &["docs/partial.docx".to_owned()],
14788            SymbolBatchReadBudget::new(1, 1, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14789            None,
14790        )?;
14791        require_eq(&result.rows.len(), &1, "only the real document symbol")?;
14792        require_eq(
14793            &result.rows[0].name.as_str(),
14794            &"document-symbol-1",
14795            "visible row",
14796        )?;
14797        require_eq(
14798            &result.reached_limit,
14799            &None,
14800            "coverage does not consume row budget",
14801        )?;
14802        let exact_bytes = store.load_symbols_for_paths_bounded(
14803            &["docs/partial.docx".to_owned()],
14804            SymbolBatchReadBudget::new(1, 1, result.work.decoded_bytes)?,
14805            None,
14806        )?;
14807        require_eq(&exact_bytes.rows.len(), &1, "exact real-symbol byte budget")?;
14808        require_eq(
14809            &exact_bytes.reached_limit,
14810            &None,
14811            "coverage does not consume byte budget",
14812        )?;
14813        require_eq(
14814            &store.max_symbol_end_line_for_path("docs/partial.docx")?,
14815            &3,
14816            "coverage marker does not affect the maximum real line",
14817        )?;
14818        let mut coverage_only =
14819            batch_test_symbol("docs/partial.docx", "document-text-coverage", 1, 0);
14820        coverage_only.language = Some("docx".to_owned());
14821        coverage_only.kind = SymbolKind::Unknown;
14822        coverage_only.parser = ParserKind::Structural;
14823        store.replace_symbol_graph(&SymbolGraph {
14824            path: "docs/partial.docx".to_owned(),
14825            language: Some("docx".to_owned()),
14826            parser: ParserKind::Structural,
14827            symbols: vec![coverage_only],
14828            relations: Vec::new(),
14829        })?;
14830        require_eq(
14831            &store.max_symbol_end_line_for_path("docs/partial.docx")?,
14832            &0,
14833            "coverage-only documents have no inferred rendered lines",
14834        )?;
14835        let plan = store
14836            .connection
14837            .prepare("EXPLAIN QUERY PLAN SELECT path FROM symbols WHERE path IN ('docs/partial.docx') AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')")?
14838            .query_map([], |row| row.get::<_, String>(3))?
14839            .collect::<Result<Vec<_>, _>>()?
14840            .join("\n");
14841        if !plan.contains("idx_symbols_path") {
14842            return Err(io::Error::other(format!(
14843                "bounded document symbols missed the path index: {plan}"
14844            ))
14845            .into());
14846        }
14847        Ok(())
14848    }
14849
14850    #[test]
14851    fn bounded_symbol_batch_streams_exact_paths_and_uses_path_index() -> Result<(), Box<dyn Error>>
14852    {
14853        let mut store = AtlasStore::in_memory()?;
14854        store.replace_scan(&[
14855            test_file_node("src/a.rs", "hash-a"),
14856            test_file_node("src/b.rs", "hash-b"),
14857        ])?;
14858        store.replace_symbol_graph(&SymbolGraph {
14859            path: "src/a.rs".to_string(),
14860            language: Some("rust".to_string()),
14861            parser: ParserKind::TreeSitter,
14862            symbols: vec![
14863                batch_test_symbol("src/a.rs", "alpha", 1, 32),
14864                batch_test_symbol("src/a.rs", "beta", 2, 32),
14865            ],
14866            relations: Vec::new(),
14867        })?;
14868        store.replace_symbol_graph(&SymbolGraph {
14869            path: "src/b.rs".to_string(),
14870            language: Some("rust".to_string()),
14871            parser: ParserKind::TreeSitter,
14872            symbols: vec![batch_test_symbol("src/b.rs", "gamma", 1, 32)],
14873            relations: Vec::new(),
14874        })?;
14875
14876        let complete = store.load_symbols_for_paths_bounded(
14877            &[
14878                "src/b.rs".to_string(),
14879                "src/a.rs".to_string(),
14880                "src/a.rs".to_string(),
14881            ],
14882            SymbolBatchReadBudget::new(2, 3, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14883            None,
14884        )?;
14885        require_eq(
14886            &complete
14887                .rows
14888                .iter()
14889                .map(|symbol| symbol.name.as_str())
14890                .collect::<Vec<_>>(),
14891            &vec!["alpha", "beta", "gamma"],
14892            "deterministic exact-path symbol order",
14893        )?;
14894        require_eq(&complete.truncated, &false, "complete symbol batch")?;
14895        require_eq(&complete.reached_limit, &None, "complete batch limit")?;
14896        require_eq(&complete.work.requested_paths, &2, "unique admitted paths")?;
14897        require_eq(&complete.work.returned_rows, &3, "complete returned rows")?;
14898
14899        let row_limited = store.load_symbols_for_paths_bounded(
14900            &["src/b.rs".to_string(), "src/a.rs".to_string()],
14901            SymbolBatchReadBudget::new(2, 1, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14902            None,
14903        )?;
14904        require_eq(
14905            &row_limited.reached_limit,
14906            &Some(SymbolBatchReadLimit::Rows),
14907            "row-bound classification",
14908        )?;
14909        require_eq(&row_limited.rows.len(), &1, "row-bound retained rows")?;
14910
14911        let byte_limited = store.load_symbols_for_paths_bounded(
14912            &["src/a.rs".to_string()],
14913            SymbolBatchReadBudget::new(1, 3, 1)?,
14914            None,
14915        )?;
14916        require_eq(
14917            &byte_limited.reached_limit,
14918            &Some(SymbolBatchReadLimit::DecodedBytes),
14919            "decoded-byte-bound classification",
14920        )?;
14921        require_eq(
14922            &byte_limited.rows.len(),
14923            &0,
14924            "tiny byte budget retained no partial row",
14925        )?;
14926        require_eq(
14927            &byte_limited.work.decoded_bytes,
14928            &0,
14929            "tiny byte budget retained no row payload",
14930        )?;
14931
14932        let gamma_bytes = code_symbol_decoded_bytes(&complete.rows[2])?;
14933        let exact_byte_boundary = store.load_symbols_for_paths_bounded(
14934            &["src/b.rs".to_string()],
14935            SymbolBatchReadBudget::new(1, 1, gamma_bytes)?,
14936            None,
14937        )?;
14938        require_eq(
14939            &exact_byte_boundary.rows.len(),
14940            &1,
14941            "exact decoded-byte boundary retained the complete row",
14942        )?;
14943        require_eq(
14944            &exact_byte_boundary.work.decoded_bytes,
14945            &gamma_bytes,
14946            "exact decoded-byte boundary accounting",
14947        )?;
14948
14949        let oversized_signature =
14950            "x".repeat(usize::try_from(MAX_SYMBOL_BATCH_DECODED_BYTES)?.saturating_add(1));
14951        store.connection.execute(
14952            "UPDATE symbols SET signature = ?1, line_start = 'invalid' WHERE path = 'src/b.rs'",
14953            [&oversized_signature],
14954        )?;
14955        let oversized_row = store.load_symbols_for_paths_bounded(
14956            &["src/b.rs".to_string()],
14957            SymbolBatchReadBudget::new(1, 1, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14958            None,
14959        )?;
14960        require_eq(
14961            &oversized_row.reached_limit,
14962            &Some(SymbolBatchReadLimit::DecodedBytes),
14963            "oversized persisted row was rejected before hydration",
14964        )?;
14965        require_eq(
14966            &oversized_row.rows.len(),
14967            &0,
14968            "oversized persisted row retained no allocation",
14969        )?;
14970        store.connection.execute(
14971            "UPDATE symbols SET signature = 'fn gamma()', line_start = 1 WHERE path = 'src/b.rs'",
14972            [],
14973        )?;
14974
14975        let many_paths = (0..65)
14976            .rev()
14977            .map(|index| format!("generated/{index:04}.rs"))
14978            .collect::<Vec<_>>();
14979        for index in 0..65 {
14980            let path = format!("generated/{index:04}.rs");
14981            store.replace_symbol_graph(&SymbolGraph {
14982                path: path.clone(),
14983                language: Some("rust".to_string()),
14984                parser: ParserKind::TreeSitter,
14985                symbols: vec![batch_test_symbol(
14986                    &path,
14987                    &format!("generated_{index:04}"),
14988                    1,
14989                    0,
14990                )],
14991                relations: Vec::new(),
14992            })?;
14993        }
14994        let path_limited = store.load_symbols_for_paths_bounded(
14995            &many_paths,
14996            SymbolBatchReadBudget::new(
14997                MAX_SYMBOL_BATCH_PATHS,
14998                MAX_SYMBOL_BATCH_ROWS,
14999                MAX_SYMBOL_BATCH_DECODED_BYTES,
15000            )?,
15001            None,
15002        )?;
15003        require_eq(
15004            &path_limited.reached_limit,
15005            &Some(SymbolBatchReadLimit::Paths),
15006            "path-bound classification",
15007        )?;
15008        require_eq(
15009            &path_limited.work.requested_paths,
15010            &MAX_SYMBOL_BATCH_PATHS,
15011            "bounded path admission",
15012        )?;
15013        require_eq(
15014            &path_limited.rows.len(),
15015            &usize::try_from(MAX_SYMBOL_BATCH_PATHS)?,
15016            "all admitted paths loaded across binding chunks",
15017        )?;
15018        require(
15019            path_limited
15020                .rows
15021                .iter()
15022                .any(|symbol| symbol.name == "generated_0063"),
15023            "last deterministically admitted path was not loaded",
15024        )?;
15025        require(
15026            path_limited
15027                .rows
15028                .iter()
15029                .all(|symbol| symbol.name != "generated_0064"),
15030            "path outside the deterministic admission set was loaded",
15031        )?;
15032
15033        let expired = IndexWorkControl::with_deadline(
15034            projectatlas_core::IndexCancellation::new(),
15035            Instant::now(),
15036        );
15037        let expired_result = store.load_symbols_for_paths_bounded(
15038            &["src/a.rs".to_string()],
15039            SymbolBatchReadBudget::new(1, 3, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
15040            Some(&expired),
15041        );
15042        require(
15043            matches!(
15044                expired_result,
15045                Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
15046                    stage: IndexWorkStage::RepositoryTraversal
15047                }))
15048            ),
15049            "expired symbol control returned a partial batch instead of a typed error",
15050        )?;
15051
15052        let mut plan_statement = store.connection.prepare(
15053            "EXPLAIN QUERY PLAN
15054             SELECT
15055                 path,
15056                 language,
15057                 name,
15058                 kind,
15059                 signature,
15060                 line_start,
15061                 line_end,
15062                 parent,
15063                 parser,
15064                 detail,
15065                 exported,
15066                 documentation,
15067                 source_byte_start,
15068                 source_byte_end,
15069                 source_column_start,
15070                 source_column_end,
15071                 length(CAST(path AS BLOB))
15072                     + COALESCE(length(CAST(language AS BLOB)), 0)
15073                     + length(CAST(name AS BLOB))
15074                     + length(CAST(signature AS BLOB))
15075                     + COALESCE(length(CAST(parent AS BLOB)), 0)
15076                     + COALESCE(length(CAST(detail AS BLOB)), 0)
15077                     + COALESCE(length(CAST(documentation AS BLOB)), 0)
15078             FROM symbols
15079             WHERE path IN (?1, ?2)
15080             ORDER BY path, line_start, name
15081             LIMIT ?3",
15082        )?;
15083        let plan = plan_statement
15084            .query_map(params!["src/a.rs", "src/b.rs", 4], |row| {
15085                row.get::<_, String>(3)
15086            })?
15087            .collect::<Result<Vec<_>, _>>()?
15088            .join("\n");
15089        require(
15090            plan.contains("idx_symbols_path"),
15091            &format!("bounded symbol batch missed idx_symbols_path: {plan}"),
15092        )?;
15093        Ok(())
15094    }
15095
15096    #[test]
15097    fn full_scan_removal_clears_source_parse_metadata() -> Result<(), Box<dyn Error>> {
15098        let mut store = AtlasStore::in_memory()?;
15099        store.replace_scan(&[test_file_node("src/a.rs", "hash-a")])?;
15100        store.replace_symbol_graph(&SymbolGraph {
15101            path: "src/a.rs".to_string(),
15102            language: Some("rust".to_string()),
15103            parser: ParserKind::TreeSitter,
15104            symbols: Vec::new(),
15105            relations: Vec::new(),
15106        })?;
15107        require_eq(
15108            &store.load_source_parse_metadata("src/a.rs")?.is_some(),
15109            &true,
15110            "metadata exists before removal",
15111        )?;
15112
15113        store.replace_scan(&[test_file_node("src/b.rs", "hash-b")])?;
15114        require_eq(
15115            &store.load_source_parse_metadata("src/a.rs")?,
15116            &None,
15117            "metadata cleared after full scan removal",
15118        )?;
15119        Ok(())
15120    }
15121
15122    #[test]
15123    fn call_relations_are_limited_per_target() -> Result<(), Box<dyn Error>> {
15124        let mut store = AtlasStore::in_memory()?;
15125        let mut relations = Vec::new();
15126        for index in 0..5 {
15127            relations.push(SymbolRelation {
15128                path: format!("src/a{index}.rs"),
15129                source_name: format!("alpha_caller_{index}"),
15130                target_name: "alpha".to_string(),
15131                kind: RelationKind::Calls,
15132                line: index + 1,
15133                context: "alpha();".to_string(),
15134                parser: ParserKind::TreeSitter,
15135            });
15136        }
15137        relations.push(SymbolRelation {
15138            path: "src/z.rs".to_string(),
15139            source_name: "beta_caller".to_string(),
15140            target_name: "beta".to_string(),
15141            kind: RelationKind::Calls,
15142            line: 99,
15143            context: "beta();".to_string(),
15144            parser: ParserKind::TreeSitter,
15145        });
15146        store.replace_symbol_graph(&SymbolGraph {
15147            path: "src/main.rs".to_string(),
15148            language: Some("rust".to_string()),
15149            parser: ParserKind::TreeSitter,
15150            symbols: Vec::new(),
15151            relations,
15152        })?;
15153
15154        let loaded =
15155            store.load_call_relations_to_targets(&["alpha".to_string(), "beta".to_string()], 2)?;
15156        let alpha_count = loaded
15157            .iter()
15158            .filter(|relation| relation.target_name == "alpha")
15159            .count();
15160        let beta_count = loaded
15161            .iter()
15162            .filter(|relation| relation.target_name == "beta")
15163            .count();
15164        require_eq(&alpha_count, &2, "alpha per-target limit")?;
15165        require_eq(&beta_count, &1, "beta preserved despite alpha skew")?;
15166        let mut statement = store.connection.prepare(
15167            "EXPLAIN QUERY PLAN
15168             SELECT path, source_name, target_name, kind, line, context, parser
15169             FROM (
15170                 SELECT path, source_name, target_name, kind, line, context, parser,
15171                     ROW_NUMBER() OVER (
15172                         PARTITION BY target_name
15173                         ORDER BY path, line, source_name, target_name
15174                     ) AS target_row
15175                 FROM symbol_relations INDEXED BY idx_symbol_relations_target
15176                 WHERE kind = 'calls' AND target_name IN ('alpha', 'beta')
15177             )
15178             WHERE target_row <= 2
15179             ORDER BY path, line, source_name, target_name",
15180        )?;
15181        let plan = statement
15182            .query_map([], |row| row.get::<_, String>(3))?
15183            .collect::<Result<Vec<_>, _>>()?
15184            .join("\n");
15185        require(
15186            plan.contains(
15187                "SEARCH symbol_relations USING INDEX idx_symbol_relations_target (target_name=?)",
15188            ),
15189            &format!("call target lookup missed exact-target index: {plan}"),
15190        )?;
15191        Ok(())
15192    }
15193
15194    #[test]
15195    fn stores_health_resolution_ids() -> Result<(), Box<dyn Error>> {
15196        let mut store = AtlasStore::in_memory()?;
15197        store.replace_scan(&[
15198            test_file_node("src/a.rs", "hash-a"),
15199            test_file_node("src/b.rs", "hash-b"),
15200        ])?;
15201        store.set_purpose("src/a.rs", "Shared purpose", PurposeSource::Agent)?;
15202        store.set_purpose("src/b.rs", "Shared purpose", PurposeSource::Agent)?;
15203        let duplicate = store
15204            .unresolved_health_findings(&[])?
15205            .into_iter()
15206            .find(|finding| finding.category == "duplicate-purpose")
15207            .ok_or_else(|| io::Error::other("duplicate-purpose finding missing"))?;
15208        let duplicate_id = duplicate.id.clone();
15209        store.resolve_health_finding(&HealthResolution {
15210            finding_id: duplicate_id.clone(),
15211            category: duplicate.category,
15212            path: duplicate.path,
15213            related_path: duplicate.related_path,
15214            rationale: "Paths intentionally mirror agent skill variants.".to_string(),
15215        })?;
15216        let ids = store.resolved_health_ids()?;
15217        require_eq(&ids, &vec![duplicate_id], "resolved ids")?;
15218        Ok(())
15219    }
15220
15221    #[test]
15222    fn health_resolution_accepts_all_scope_agent_review_findings() -> Result<(), Box<dyn Error>> {
15223        let mut store = AtlasStore::in_memory()?;
15224        let mut asset_file = test_file_node("assets/logo.svg", "hash-logo");
15225        asset_file.extension = Some(".svg".to_string());
15226        asset_file.language = None;
15227        store.replace_scan(&[test_folder_node("assets"), asset_file])?;
15228        store.set_purpose(
15229            "assets/logo.svg",
15230            "Imported SVG brand asset purpose",
15231            PurposeSource::Imported,
15232        )?;
15233
15234        let page = store.unresolved_health_findings_page(
15235            &[],
15236            &HealthQuery {
15237                start_index: 0,
15238                limit: 20,
15239                category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
15240                severity: Some(Severity::Warning),
15241                path_prefix: Some(".".to_string()),
15242                summary_only: false,
15243                scope: HealthScope::all(),
15244            },
15245        )?;
15246        let finding = page
15247            .findings
15248            .iter()
15249            .find(|finding| finding.path == "assets/logo.svg")
15250            .ok_or_else(|| io::Error::other("asset review finding missing"))?;
15251        store.resolve_health_finding(&HealthResolution {
15252            finding_id: finding.id.clone(),
15253            category: finding.category.clone(),
15254            path: finding.path.clone(),
15255            related_path: finding.related_path.clone(),
15256            rationale: "Asset purpose imported from legacy metadata and intentionally accepted."
15257                .to_string(),
15258        })?;
15259
15260        let remaining = store.unresolved_health_findings_page(
15261            &store.resolved_health_ids()?,
15262            &HealthQuery {
15263                start_index: 0,
15264                limit: 20,
15265                category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
15266                severity: Some(Severity::Warning),
15267                path_prefix: Some(".".to_string()),
15268                summary_only: false,
15269                scope: HealthScope::all(),
15270            },
15271        )?;
15272        require_eq(
15273            &health_paths(&remaining).contains(&"assets/logo.svg"),
15274            &false,
15275            "resolved all-scope asset review finding",
15276        )?;
15277        Ok(())
15278    }
15279
15280    #[test]
15281    fn purpose_set_reports_unindexed_path_without_sqlite_leak() -> Result<(), Box<dyn Error>> {
15282        let mut store = AtlasStore::in_memory()?;
15283        store.replace_scan(&[test_file_node("src/main.rs", "hash")])?;
15284        let error = match store.set_purpose("no/such/file.rs", "Missing file", PurposeSource::Agent)
15285        {
15286            Ok(()) => return Err(io::Error::other("missing path should fail").into()),
15287            Err(error) => error,
15288        };
15289
15290        require_eq(
15291            &error.to_string().contains("no/such/file.rs"),
15292            &true,
15293            "path named in error",
15294        )?;
15295        require_eq(
15296            &error.to_string().contains("sqlite error"),
15297            &false,
15298            "raw sqlite error hidden",
15299        )?;
15300        store.replace_scan(&[])?;
15301        let error = match store.set_purpose("src/main.rs", "Removed file", PurposeSource::Agent) {
15302            Ok(()) => return Err(io::Error::other("stale indexed path should fail").into()),
15303            Err(error) => error,
15304        };
15305        require_eq(
15306            &error.to_string().contains("src/main.rs"),
15307            &true,
15308            "stale path named in error",
15309        )?;
15310        Ok(())
15311    }
15312
15313    #[test]
15314    fn health_resolution_requires_active_finding_tuple() -> Result<(), Box<dyn Error>> {
15315        let mut store = AtlasStore::in_memory()?;
15316        store.replace_scan(&[test_file_node("src/main.rs", "hash")])?;
15317        let error = match store.resolve_health_finding(&HealthResolution {
15318            finding_id: "missing-id".to_string(),
15319            category: "duplicate-purpose".to_string(),
15320            path: "no/such/file.rs".to_string(),
15321            related_path: None,
15322            rationale: "typo".to_string(),
15323        }) {
15324            Ok(()) => {
15325                return Err(io::Error::other("nonexistent health finding should fail").into());
15326            }
15327            Err(error) => error,
15328        };
15329
15330        require_eq(
15331            &error.to_string().contains("not active"),
15332            &true,
15333            "inactive finding rejected",
15334        )?;
15335        Ok(())
15336    }
15337
15338    /// Write released schema-8 source, authored, telemetry, and publication state.
15339    fn write_released_schema_eight_compatibility_fixture(
15340        db_path: &Path,
15341        root: &Path,
15342    ) -> Result<(), Box<dyn Error>> {
15343        write_schema_eight_compatibility_fixture(
15344            db_path,
15345            root,
15346            schema::create_released_schema_eight,
15347        )
15348    }
15349
15350    /// Write evolved released schema-8 source, authored, telemetry, and publication state.
15351    #[cfg(windows)]
15352    fn write_evolved_released_schema_eight_compatibility_fixture(
15353        db_path: &Path,
15354        root: &Path,
15355    ) -> Result<(), Box<dyn Error>> {
15356        write_schema_eight_compatibility_fixture(
15357            db_path,
15358            root,
15359            schema::create_evolved_released_schema_eight,
15360        )
15361    }
15362
15363    /// Write one captured schema-8 layout with representative durable state.
15364    fn write_schema_eight_compatibility_fixture(
15365        db_path: &Path,
15366        root: &Path,
15367        create_schema: fn(&Connection) -> DbResult<()>,
15368    ) -> Result<(), Box<dyn Error>> {
15369        let connection = Connection::open(db_path)?;
15370        create_schema(&connection)?;
15371        schema::configure_writable(&connection)?;
15372        connection.execute_batch("BEGIN IMMEDIATE")?;
15373        let write_result = (|| -> DbResult<()> {
15374            set_metadata(
15375                &connection,
15376                PROJECT_ROOT_KEY,
15377                &normalize_native_path_display(root),
15378            )?;
15379            set_metadata(&connection, "custom_setting", "preserved")?;
15380            set_metadata(&connection, INDEX_PUBLICATION_STATE_KEY, "complete")?;
15381            set_metadata(
15382                &connection,
15383                INDEX_PUBLICATION_FINGERPRINT_KEY,
15384                "untrusted-contract",
15385            )?;
15386            set_metadata(&connection, INDEX_PUBLICATION_GENERATION_KEY, "7")?;
15387            connection.execute_batch(
15388                "
15389                INSERT INTO nodes(
15390                    id, path, kind, parent_path, extension, language,
15391                    size_bytes, mtime_ns, content_hash
15392                )
15393                VALUES(1, 'src/lib.rs', 'file', 'src', '.rs', 'rust', 12, 10, 'hash-legacy');
15394
15395                INSERT INTO purposes(node_id, purpose, source, status, updated_by)
15396                VALUES(1, 'Schema compatibility source', 'agent', 'approved', 'agent');
15397
15398                INSERT INTO summaries(node_id, summary_level, subject, summary)
15399                VALUES(1, 'node', '', 'released schema source');
15400
15401                INSERT INTO file_texts(path, content_hash, byte_count, line_count, content)
15402                VALUES('src/lib.rs', 'hash-legacy', 6, 1, 'legacy');
15403                ",
15404            )?;
15405            connection.execute(
15406                "
15407                INSERT INTO health_resolutions(
15408                    finding_id, category, path, rationale
15409                )
15410                VALUES('schema-review', ?1, 'src/lib.rs', 'Reviewed schema fixture')
15411                ",
15412                [CATEGORY_DUPLICATE_PURPOSE],
15413            )?;
15414            record_released_schema_eight_usage_event(
15415                &connection,
15416                &usage_from_estimates(
15417                    "schema-session",
15418                    "summary",
15419                    Some("src/lib.rs".to_string()),
15420                    None,
15421                    100,
15422                    20,
15423                ),
15424            )
15425        })();
15426        match write_result {
15427            Ok(()) => connection.execute_batch("COMMIT")?,
15428            Err(error) => {
15429                if let Err(rollback) = connection.execute_batch("ROLLBACK") {
15430                    return Err(DbError::TransactionRollback {
15431                        operation: Box::new(error),
15432                        rollback,
15433                    }
15434                    .into());
15435                }
15436                return Err(error.into());
15437            }
15438        }
15439        Ok(())
15440    }
15441
15442    /// Write representative source, authored, telemetry, and publication state at one schema.
15443    fn write_schema_compatibility_fixture(
15444        db_path: &Path,
15445        root: &Path,
15446        schema_version: i64,
15447        label: &str,
15448    ) -> Result<(), Box<dyn Error>> {
15449        let mut store = AtlasStore::open(db_path)?;
15450        store.set_project_root(root)?;
15451        populate_schema_compatibility_fixture(&mut store, label)?;
15452        set_metadata(
15453            &store.connection,
15454            SCHEMA_VERSION_KEY,
15455            &schema_version.to_string(),
15456        )?;
15457        store
15458            .connection
15459            .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
15460        Ok(())
15461    }
15462
15463    /// Populate representative source, authored, telemetry, and publication state.
15464    pub(crate) fn populate_schema_compatibility_fixture(
15465        store: &mut AtlasStore,
15466        label: &str,
15467    ) -> Result<(), Box<dyn Error>> {
15468        {
15469            let mut publication = store.begin_index_publication("untrusted-contract")?;
15470            write_test_projection(&mut publication, label)?;
15471            publication.complete()?;
15472        }
15473        store.set_purpose(
15474            "src/lib.rs",
15475            "Schema compatibility source",
15476            PurposeSource::Agent,
15477        )?;
15478        store.connection.execute(
15479            "
15480            INSERT INTO health_resolutions(
15481                finding_id,
15482                category,
15483                path,
15484                rationale
15485            )
15486            VALUES('schema-review', ?1, 'src/lib.rs', 'Reviewed schema fixture')
15487            ",
15488            [CATEGORY_DUPLICATE_PURPOSE],
15489        )?;
15490        store.record_usage(&usage_from_estimates(
15491            "schema-session",
15492            "summary",
15493            Some("src/lib.rs".to_string()),
15494            None,
15495            100,
15496            20,
15497        ))?;
15498        set_metadata(&store.connection, "custom_setting", "preserved")?;
15499        Ok(())
15500    }
15501
15502    /// Build a representative Rust file node for store tests.
15503    fn test_file_node(path: &str, hash: &str) -> Node {
15504        Node {
15505            path: path.to_string(),
15506            kind: NodeKind::File,
15507            parent_path: normalized_parent(path),
15508            extension: Some(".rs".to_string()),
15509            language: Some("rust".to_string()),
15510            size_bytes: Some(12),
15511            mtime_ns: Some(10),
15512            content_hash: Some(hash.to_string()),
15513        }
15514    }
15515
15516    /// Build one persisted symbol with configurable retained payload.
15517    fn batch_test_symbol(
15518        path: &str,
15519        name: &str,
15520        line_start: usize,
15521        documentation_bytes: usize,
15522    ) -> CodeSymbol {
15523        CodeSymbol {
15524            path: path.to_string(),
15525            language: Some("rust".to_string()),
15526            name: name.to_string(),
15527            kind: SymbolKind::Function,
15528            signature: format!("fn {name}()"),
15529            exported: false,
15530            documentation: Some("d".repeat(documentation_bytes)),
15531            line_start,
15532            line_end: line_start.saturating_add(1),
15533            source_selector: None,
15534            parent: None,
15535            parser: ParserKind::TreeSitter,
15536            detail: Some("function_item".to_string()),
15537        }
15538    }
15539
15540    /// Assert the exact selector on the heading row and the legacy row remains absent.
15541    fn require_symbol_source_selectors(
15542        symbols: &[CodeSymbol],
15543        expected: Option<SymbolSourceSelector>,
15544        context: &str,
15545    ) -> Result<(), Box<dyn Error>> {
15546        let heading = symbols
15547            .iter()
15548            .find(|symbol| symbol.name == "Overview")
15549            .ok_or_else(|| io::Error::other(format!("{context} omitted the heading symbol")))?;
15550        require_eq(&heading.source_selector, &expected, context)?;
15551        if let Some(compatibility) = symbols.iter().find(|symbol| symbol.name == "Compatibility") {
15552            require_eq(
15553                &compatibility.source_selector,
15554                &None,
15555                &format!("{context} changed the selector-free symbol"),
15556            )?;
15557        }
15558        Ok(())
15559    }
15560
15561    /// Exercise every DB-owned symbol row decoder against one persisted selector.
15562    fn verify_symbol_source_selector_read_paths(
15563        store: &AtlasStore,
15564        path: &str,
15565        expected: SymbolSourceSelector,
15566    ) -> Result<(), Box<dyn Error>> {
15567        require_symbol_source_selectors(
15568            &store.load_symbols(Some(path), None, 10)?,
15569            Some(expected),
15570            "symbol list",
15571        )?;
15572        require_symbol_source_selectors(
15573            &store.load_symbols_by_kinds(path, &[SymbolKind::Heading], 10)?,
15574            Some(expected),
15575            "kind-filtered symbols",
15576        )?;
15577        require_symbol_source_selectors(
15578            &store.load_symbols_by_names(&["Overview".to_string()])?,
15579            Some(expected),
15580            "name-filtered symbols",
15581        )?;
15582        let named = store
15583            .load_symbol_by_name(path, "Overview")?
15584            .ok_or_else(|| io::Error::other("exact-name symbol lookup omitted the heading"))?;
15585        require_eq(
15586            &named.source_selector,
15587            &Some(expected),
15588            "single exact-name symbol",
15589        )?;
15590        require_symbol_source_selectors(
15591            &store.load_symbols_by_exact_file_and_name(path, "Overview")?,
15592            Some(expected),
15593            "exact-file-and-name symbols",
15594        )?;
15595        require_symbol_source_selectors(
15596            &store
15597                .load_symbols_for_paths_bounded(
15598                    &[path.to_string()],
15599                    SymbolBatchReadBudget::new(1, 10, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
15600                    None,
15601                )?
15602                .rows,
15603            Some(expected),
15604            "bounded exact-path symbols",
15605        )?;
15606        let classified =
15607            store.load_classified_symbols(Some(path), None, ContentSelection::Documentation, 10)?;
15608        require(
15609            classified
15610                .iter()
15611                .all(|row| row.classification == ContentClassification::Documentation),
15612            "classified symbol read changed the owning file classification",
15613        )?;
15614        require_symbol_source_selectors(
15615            &classified
15616                .into_iter()
15617                .map(|row| row.symbol)
15618                .collect::<Vec<_>>(),
15619            Some(expected),
15620            "classified symbols",
15621        )?;
15622        let graphs = store.load_symbol_graphs_for_paths(&[path.to_string()])?;
15623        require_eq(&graphs.len(), &1, "reconstructed graph count")?;
15624        require_symbol_source_selectors(
15625            &graphs[0].symbols,
15626            Some(expected),
15627            "reconstructed symbol graph",
15628        )?;
15629        Ok(())
15630    }
15631
15632    /// Copy one selected queue candidate into the public conditional-write contract.
15633    fn conditional_purpose_request(
15634        task: &str,
15635        candidate: &PurposeCurationCandidate,
15636        purpose: &str,
15637    ) -> PurposeConditionalApplyRequest {
15638        PurposeConditionalApplyRequest {
15639            task: task.to_string(),
15640            path: candidate.node.node.path.clone(),
15641            work_key: candidate.work_key.clone(),
15642            state_token: candidate.state_token.clone(),
15643            purpose: purpose.to_string(),
15644        }
15645    }
15646
15647    /// Replace every source-derived projection used by publication tests.
15648    fn write_test_projection(store: &mut AtlasStore, label: &str) -> DbResult<()> {
15649        let path = "src/lib.rs";
15650        let hash = format!("hash-{label}");
15651        store.replace_scan(&[test_file_node(path, &hash)])?;
15652        store.replace_file_texts_for_paths(
15653            &[path.to_string()],
15654            &[IndexedFileText {
15655                path: path.to_string(),
15656                content_hash: Some(hash),
15657                byte_count: label.len(),
15658                line_count: 1,
15659                content: label.to_string(),
15660            }],
15661        )?;
15662        store.replace_symbol_graph(&SymbolGraph {
15663            path: path.to_string(),
15664            language: Some("rust".to_string()),
15665            parser: ParserKind::TreeSitter,
15666            symbols: vec![CodeSymbol {
15667                path: path.to_string(),
15668                language: Some("rust".to_string()),
15669                name: format!("{label}_symbol"),
15670                kind: SymbolKind::Function,
15671                signature: format!("fn {label}_symbol()"),
15672                exported: true,
15673                documentation: None,
15674                line_start: 1,
15675                line_end: 1,
15676                source_selector: None,
15677                parent: None,
15678                parser: ParserKind::TreeSitter,
15679                detail: Some("function_item".to_string()),
15680            }],
15681            relations: vec![SymbolRelation {
15682                path: path.to_string(),
15683                source_name: format!("{label}_symbol"),
15684                target_name: format!("{label}_target"),
15685                kind: RelationKind::Calls,
15686                line: 1,
15687                context: format!("{label}_target();"),
15688                parser: ParserKind::TreeSitter,
15689            }],
15690        })?;
15691        store.set_node_summary(path, &format!("{label} summary"))?;
15692        Ok(())
15693    }
15694
15695    /// Assert that one snapshot exposes a coherent projection generation.
15696    fn require_test_projection(
15697        store: &AtlasStore,
15698        generation: u64,
15699        label: &str,
15700    ) -> Result<(), Box<dyn Error>> {
15701        let path = "src/lib.rs";
15702        let publication = store
15703            .index_publication()?
15704            .ok_or_else(|| io::Error::other("publication missing"))?;
15705        require_eq(
15706            &publication.generation,
15707            &IndexGeneration::new(generation),
15708            "publication generation",
15709        )?;
15710        let node = store
15711            .load_node_by_path(path)?
15712            .ok_or_else(|| io::Error::other("projection node missing"))?;
15713        require_eq(
15714            &node.node.content_hash,
15715            &Some(format!("hash-{label}")),
15716            "projection node hash",
15717        )?;
15718        require_eq(
15719            &node.summary,
15720            &Some(format!("{label} summary")),
15721            "projection node summary",
15722        )?;
15723        let text = store
15724            .load_file_text(path)?
15725            .ok_or_else(|| io::Error::other("projection text missing"))?;
15726        require_eq(&text.content, &label.to_string(), "projection text")?;
15727        let symbols = store.load_symbols(Some(path), None, 10)?;
15728        require_eq(
15729            &symbols.first().map(|symbol| symbol.name.as_str()),
15730            &Some(format!("{label}_symbol")).as_deref(),
15731            "projection symbol",
15732        )?;
15733        let relations = store.load_symbol_relations(Some(path), None, 10)?;
15734        require_eq(
15735            &relations
15736                .first()
15737                .map(|relation| relation.target_name.as_str()),
15738            &Some(format!("{label}_target")).as_deref(),
15739            "projection relation",
15740        )?;
15741        let metadata = store
15742            .load_source_parse_metadata(path)?
15743            .ok_or_else(|| io::Error::other("projection parse metadata missing"))?;
15744        require_eq(&metadata.symbol_count, &1, "projection symbol metadata")?;
15745        require_eq(&metadata.relation_count, &1, "projection relation metadata")?;
15746        Ok(())
15747    }
15748
15749    /// Assert the connection settings required for every production writer.
15750    fn require_writable_connection_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
15751        let foreign_keys =
15752            connection.pragma_query_value(None, "foreign_keys", |row| row.get::<_, i64>(0))?;
15753        require_eq(&foreign_keys, &1, "writable foreign-key enforcement")?;
15754        require_wal_profile(connection)?;
15755        let synchronous =
15756            connection.pragma_query_value(None, "synchronous", |row| row.get::<_, i64>(0))?;
15757        require_eq(&synchronous, &2, "writable FULL synchronous mode")?;
15758        require_busy_timeout(connection)
15759    }
15760
15761    /// Assert the connection settings required for every production reader.
15762    fn require_read_connection_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
15763        let query_only =
15764            connection.pragma_query_value(None, "query_only", |row| row.get::<_, i64>(0))?;
15765        require_eq(&query_only, &1, "read query-only mode")?;
15766        require_wal_profile(connection)?;
15767        require_busy_timeout(connection)
15768    }
15769
15770    /// Assert that a connection observes the selected durable journal mode.
15771    fn require_wal_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
15772        let journal_mode =
15773            connection.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))?;
15774        require_eq(
15775            &journal_mode.to_ascii_lowercase(),
15776            &"wal".to_string(),
15777            "WAL journal mode",
15778        )
15779    }
15780
15781    /// Assert the bounded contention wait shared by ordinary connections.
15782    fn require_busy_timeout(connection: &Connection) -> Result<(), Box<dyn Error>> {
15783        let busy_timeout =
15784            connection.pragma_query_value(None, "busy_timeout", |row| row.get::<_, i64>(0))?;
15785        require_eq(
15786            &u128::try_from(busy_timeout)?,
15787            &SQLITE_BUSY_TIMEOUT.as_millis(),
15788            "bounded connection busy timeout",
15789        )
15790    }
15791
15792    /// Build a representative folder node for store tests.
15793    fn test_folder_node(path: &str) -> Node {
15794        Node {
15795            path: path.to_string(),
15796            kind: NodeKind::Folder,
15797            parent_path: normalized_parent(path),
15798            extension: None,
15799            language: None,
15800            size_bytes: None,
15801            mtime_ns: Some(10),
15802            content_hash: None,
15803        }
15804    }
15805
15806    /// Return the default low-cost purpose review query used by agent linting.
15807    fn low_query() -> HealthQuery {
15808        HealthQuery {
15809            start_index: 0,
15810            limit: 20,
15811            category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
15812            severity: Some(Severity::Warning),
15813            path_prefix: Some(".".to_string()),
15814            summary_only: false,
15815            scope: HealthScope::purpose_default(),
15816        }
15817    }
15818
15819    /// Collect health finding paths in returned order.
15820    fn health_paths(page: &HealthFindingsPage) -> Vec<&str> {
15821        page.findings
15822            .iter()
15823            .map(|finding| finding.path.as_str())
15824            .collect()
15825    }
15826
15827    /// Require a test condition without panicking.
15828    fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
15829        if condition {
15830            Ok(())
15831        } else {
15832            Err(io::Error::other(message).into())
15833        }
15834    }
15835
15836    /// Require two test values to be equal without panicking.
15837    fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
15838    where
15839        T: Debug + PartialEq,
15840    {
15841        if actual == expected {
15842            Ok(())
15843        } else {
15844            Err(io::Error::other(format!(
15845                "{label} mismatch: expected {expected:?}, got {actual:?}"
15846            ))
15847            .into())
15848        }
15849    }
15850}