1mod content_classification;
4mod derived_snapshot;
5mod diagnostics;
6mod hydration;
7mod project_identity;
8mod repository_graph;
9mod schema;
10mod sqlite_profile;
11mod telemetry;
12mod worktree_registry;
13
14pub use content_classification::{
15 FileContentClassification, FileContentClassificationPage,
16 MAX_FILE_CONTENT_CLASSIFICATION_PAGE_ROWS, MAX_FILE_CONTENT_CLASSIFICATION_PATHS,
17};
18pub use derived_snapshot::{
19 DerivedGraphSnapshot, DerivedGraphSnapshotImport, DerivedGraphSnapshotMetadata,
20 DerivedSnapshotContent, MAX_DERIVED_SNAPSHOT_JSON_BYTES,
21};
22pub use diagnostics::{
23 DatabaseCoverageSample, DatabaseCoverageSummary, DatabaseCoverageTotalState,
24 DatabaseFilesystemSupport, DatabaseOperatingProfileReport, DatabasePublicationContractState,
25 DatabasePublicationReport, DatabaseSchemaCompatibility, DatabaseSchemaReport,
26 DatabaseSettingsReport, SqliteCompileOptionsIdentity, SqliteRuntimeReport,
27 database_settings_report,
28};
29pub use hydration::{
30 PreparedWorktreeHydrationCandidate, WorktreeHydrationActivation, WorktreeHydrationCandidate,
31};
32pub use project_identity::{ProjectRootTransition, ProjectRootTransitionResult};
33pub use repository_graph::{
34 MAX_REPOSITORY_GRAPH_FRONTIER, RepositoryAffectedSourceFootprint, RepositoryCoverageQuery,
35 RepositoryCoverageRow, RepositoryGraphAdjacencyContinuation, RepositoryGraphAdjacencyPage,
36 RepositoryGraphAdjacencyReadPage, RepositoryGraphAdjacencyRow,
37 RepositoryGraphClassifiedRelationRow, RepositoryGraphDirection, RepositoryGraphPage,
38 RepositoryGraphReadBatch, RepositoryGraphReadBudget, RepositoryGraphReadPage,
39 RepositoryGraphReadPages, RepositoryGraphReadWork, RepositoryGraphRelationQuery,
40 RepositoryGraphRelationRow, RepositoryGraphStagingGuard, RepositoryNavigationConnections,
41 RepositoryNavigationNode, RepositoryResolutionCandidate,
42};
43pub use sqlite_profile::validate_database_location;
44pub use telemetry::{
45 PlannerStatisticsPolicy, PlannerStatisticsState, SpillCleanupState, TelemetryCheckpointState,
46 TelemetryRetentionPolicy, TelemetryRetentionState, WorktreeUsageSnapshot,
47 WorktreeUsageSyncState,
48};
49pub use worktree_registry::{
50 ActiveWorktreeRegistrationGuard, MAIN_WORKTREE_ALIAS, MAX_WORKTREE_ALIAS_BYTES, WorktreeAlias,
51 WorktreeRegistration, WorktreeRegistrationState,
52};
53
54use blake3::Hasher;
55use projectatlas_core::graph::{GraphContractError, ProjectInstanceId};
56use projectatlas_core::health::{
57 CATEGORY_DUPLICATE_PURPOSE, CATEGORY_MISSING_PURPOSE, CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
58 CATEGORY_REPEATED_TEMPORARY_FOLDER, CATEGORY_STALE_PURPOSE, CATEGORY_SUGGESTED_PURPOSE_REVIEW,
59 HealthFinding, MESSAGE_MISSING_PURPOSE, MESSAGE_PURPOSE_AGENT_REVIEW_REQUIRED,
60 MESSAGE_STALE_PURPOSE, MESSAGE_SUGGESTED_PURPOSE_REVIEW, RECOMMENDATION_DUPLICATE_PURPOSE,
61 RECOMMENDATION_MISSING_PURPOSE_QUEUE, RECOMMENDATION_PURPOSE_AGENT_REVIEW_REQUIRED,
62 RECOMMENDATION_REPEATED_TEMPORARY_FOLDER, RECOMMENDATION_STALE_PURPOSE,
63 RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW_QUEUE, STRUCTURAL_HEALTH_CATEGORIES, Severity,
64 TEMP_FOLDER_BUCKETS, finding_id,
65};
66use projectatlas_core::language::{ContentClassification, ContentSelection};
67use projectatlas_core::symbols::{
68 CodeSymbol, ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
69 SymbolRelation, SymbolSourceSelector,
70};
71use projectatlas_core::telemetry::{
72 TelemetryContractError, TokenOverview, TokenTrendReport, TokenTrendWindow, UsageEvent,
73 UsageInstanceId, UsageInstanceOwner,
74};
75use projectatlas_core::{
76 AGENT_REVIEWED_SOURCE_VALUES, CanonicalProjectRoot, CoreError, HIGH_IMPACT_FILE_NAMES,
77 HIGH_IMPACT_PATH_PREFIXES, HIGH_IMPACT_PATH_SEGMENTS, IndexGeneration, IndexWorkControl,
78 IndexWorkFailure, IndexWorkStage, IndexedNode, LEGACY_HUMAN_PURPOSE_SOURCE, Node, NodeKind,
79 Overview, Purpose, PurposeSource, PurposeStatus, normalize_native_path_display,
80 normalize_repo_path_prefix,
81};
82use rusqlite::types::Value;
83use rusqlite::{
84 Connection, ErrorCode, OpenFlags, OptionalExtension, TransactionBehavior, params,
85 params_from_iter,
86};
87use serde::{Deserialize, Serialize};
88use std::cell::{Cell, RefCell};
89use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
90use std::num::{ParseIntError, TryFromIntError};
91use std::ops::{Deref, DerefMut};
92use std::path::{Path, PathBuf};
93use std::time::Duration;
94use thiserror::Error;
95
96#[cfg(all(test, windows))]
97use schema::PREVIOUS_SCHEMA_VERSION;
98use schema::{
99 FILE_TEXT_FTS_PROJECTION_REVISION_KEY, FILE_TEXT_FTS_SOURCE_REVISION_KEY,
100 INDEX_PUBLICATION_FINGERPRINT_KEY, INDEX_PUBLICATION_GENERATION_KEY,
101 INDEX_PUBLICATION_STATE_KEY, PROJECT_ROOT_KEY, SchemaState,
102};
103#[cfg(test)]
104use schema::{SCHEMA_VERSION, SCHEMA_VERSION_KEY, sqlite_sidecar_path};
105use sqlite_profile::{
106 DatabaseLocation, JournalModePolicy, SQLITE_BUSY_TIMEOUT, open_writable_connection,
107};
108
109const MAX_SYMBOL_SEARCH_SUMMARY_CHARS: usize = 16_000;
111const SQLITE_PUBLICATION_ACQUIRE_TIMEOUT: Duration = Duration::ZERO;
113const SQLITE_TELEMETRY_BUSY_TIMEOUT: Duration = Duration::from_millis(25);
115pub const MAX_PURPOSE_CURATION_BATCH_ROWS: usize = 200;
117pub const CURRENT_SCHEMA_VERSION: i64 = schema::SCHEMA_VERSION;
119pub const MAX_FILE_TEXT_FTS_CANDIDATES: usize = 4_096;
121const FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL: &str = "
123 SELECT text.path, text.content_hash, text.byte_count, text.line_count,
124 classification.classification
125 FROM file_texts AS text
126 LEFT JOIN file_content_classifications AS classification
127 ON classification.path = text.path
128 WHERE text.path = ?1 OR (text.path >= ?2 AND text.path < ?3)
129 ORDER BY text.path
130";
131const FILE_TEXT_FALLBACK_ALL_METADATA_SQL: &str = "
133 SELECT text.path, text.content_hash, text.byte_count, text.line_count,
134 classification.classification
135 FROM file_texts AS text
136 LEFT JOIN file_content_classifications AS classification
137 ON classification.path = text.path
138 ORDER BY text.path
139";
140const FILE_TEXT_FALLBACK_CONTENT_SQL: &str = "SELECT content FROM file_texts WHERE path = ?1";
142const MAX_PURPOSE_CURATION_TASK_BYTES: usize = 256;
144const PURPOSE_CURATION_ITEM_KEY_DOMAIN: &str = "projectatlas:purpose-curation:item:v1";
146const PURPOSE_CURATION_STATE_TOKEN_DOMAIN: &str = "projectatlas:purpose-curation:state:v1";
148const PURPOSE_CURATION_BATCH_KEY_DOMAIN: &str = "projectatlas:purpose-curation:batch:v1";
150const AUTHORED_PURPOSE_REVISION_KEY: &str = "purpose.authored_revision";
152fn writable_open_flags(state: SchemaState, database_exists: bool) -> OpenFlags {
154 match (state, database_exists) {
155 (SchemaState::Fresh, false) => {
156 OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE
157 }
158 (SchemaState::Fresh | SchemaState::Current | SchemaState::UpgradeRequired, true)
159 | (SchemaState::Current | SchemaState::UpgradeRequired, false) => {
160 OpenFlags::SQLITE_OPEN_READ_WRITE
161 }
162 }
163}
164
165const fn writable_journal_policy(state: SchemaState) -> JournalModePolicy {
167 match state {
168 SchemaState::Current => JournalModePolicy::RequireWal,
169 SchemaState::Fresh | SchemaState::UpgradeRequired => JournalModePolicy::EnsureWal,
170 }
171}
172
173#[derive(Debug, Error)]
175pub enum DbError {
176 #[error("sqlite error: {0}")]
178 Sqlite(#[from] rusqlite::Error),
179 #[error(
181 "database path {path:?} is on an unsupported filesystem (mount: {mount_point:?}, type: {filesystem_type:?}); live SQLite WAL requires supported local storage"
182 )]
183 DatabaseFilesystemUnsupported {
184 path: PathBuf,
186 mount_point: Option<PathBuf>,
188 filesystem_type: Option<String>,
190 },
191 #[error(
193 "database path {path:?} has uncertain filesystem placement (mount: {mount_point:?}, type: {filesystem_type:?}): {reason}"
194 )]
195 DatabaseFilesystemUncertain {
196 path: PathBuf,
198 mount_point: Option<PathBuf>,
200 filesystem_type: Option<String>,
202 reason: String,
204 },
205 #[error("SQLite operating profile mismatch for {setting}: expected {expected}, found {found}")]
207 DatabaseOperatingProfile {
208 setting: &'static str,
210 expected: String,
212 found: String,
214 },
215 #[error("repository graph contract error: {0}")]
217 GraphContract(#[from] GraphContractError),
218 #[error("telemetry contract error: {0}")]
220 TelemetryContract(#[from] TelemetryContractError),
221 #[error("invalid worktree alias {alias:?}: {reason}")]
223 InvalidWorktreeAlias {
224 alias: String,
226 reason: &'static str,
228 },
229 #[error("active worktree registration {alias:?} was not found")]
231 WorktreeRegistrationNotFound {
232 alias: String,
234 },
235 #[error("worktree registration {field} conflicts with active value {value:?}")]
237 WorktreeRegistrationConflict {
238 field: &'static str,
240 value: String,
242 },
243 #[error(
245 "worktree registration migration found duplicate active native {field} identities in registrations {first_registration_id} and {second_registration_id}; repair one legacy row and retry"
246 )]
247 WorktreeRegistrationMigrationConflict {
248 field: &'static str,
250 first_registration_id: i64,
252 second_registration_id: i64,
254 },
255 #[error(
257 "worktree registration migration cannot establish native {field} identity for registration {registration_id}; repair the legacy row and retry"
258 )]
259 WorktreeRegistrationMigrationIdentityUnavailable {
260 field: &'static str,
262 registration_id: i64,
264 },
265 #[error("worktree registration capacity {limit} is exhausted")]
267 WorktreeRegistrationCapacity {
268 limit: usize,
270 },
271 #[error("invalid worktree registration {field} path {path:?}")]
273 InvalidWorktreeRegistrationPath {
274 field: &'static str,
276 path: String,
278 },
279 #[error("invalid worktree registration row: {reason}")]
281 WorktreeRegistrationRow {
282 reason: &'static str,
284 },
285 #[error("worktree telemetry project identity does not match registration {registration_id}")]
287 WorktreeTelemetryProjectMismatch {
288 registration_id: i64,
290 },
291 #[error("worktree telemetry snapshot {resource} exceeded limit {limit}: observed {observed}")]
293 WorktreeTelemetrySnapshotLimit {
294 resource: &'static str,
296 limit: usize,
298 observed: usize,
300 },
301 #[error("telemetry runtime instance has a conflicting worktree origin")]
303 WorktreeTelemetryOriginConflict,
304 #[error("invalid worktree hydration request: {reason}")]
306 WorktreeHydrationInvalid {
307 reason: &'static str,
309 },
310 #[error("worktree hydration destination already exists: {path:?}")]
312 WorktreeHydrationDestinationExists {
313 path: PathBuf,
315 },
316 #[error("worktree hydration I/O failed for {path:?}: {source}")]
318 WorktreeHydrationIo {
319 path: PathBuf,
321 #[source]
323 source: std::io::Error,
324 },
325 #[error("worktree hydration backup remained busy after {attempts} attempts")]
327 WorktreeHydrationBackupBusy {
328 attempts: usize,
330 },
331 #[error(
333 "worktree hydration candidate is not reconciled: baseline generation {baseline}, found {found}"
334 )]
335 WorktreeHydrationNotReconciled {
336 baseline: IndexGeneration,
338 found: IndexGeneration,
340 },
341 #[error(
343 "repository graph project identity {found} does not match selected identity {expected}"
344 )]
345 GraphProjectIdentityMismatch {
346 expected: String,
348 found: String,
350 },
351 #[error("repository graph is unavailable without a complete published generation")]
353 GraphPublicationUnavailable,
354 #[error("invalid {table} row: {reason}")]
356 GraphRowShape {
357 table: &'static str,
359 reason: &'static str,
361 },
362 #[error("invalid derived graph snapshot: {reason}")]
364 DerivedSnapshotInvalid {
365 reason: &'static str,
367 },
368 #[error(
370 "derived graph snapshot {resource} exceeds the limit: found {found}, maximum {maximum}"
371 )]
372 DerivedSnapshotLimit {
373 resource: &'static str,
375 found: u64,
377 maximum: u64,
379 },
380 #[error("derived graph snapshot I/O failed for {path:?}: {source}")]
382 DerivedSnapshotIo {
383 path: PathBuf,
385 #[source]
387 source: std::io::Error,
388 },
389 #[error("derived graph snapshot JSON is invalid: {0}")]
391 DerivedSnapshotJson(#[from] serde_json::Error),
392 #[error("invalid persisted symbol graph for {path:?}: {reason}")]
394 SymbolGraphRowShape {
395 path: String,
397 reason: &'static str,
399 },
400 #[error("resolution-key collision in {domain} for digest {digest:?}")]
402 ResolutionKeyCollision {
403 domain: &'static str,
405 digest: [u8; 32],
407 },
408 #[error("invalid {field} blob length {found}; expected {expected}")]
410 InvalidBlobLength {
411 field: &'static str,
413 expected: usize,
415 found: usize,
417 },
418 #[error("graph count for {field} exceeds SQLite integer range: {value}")]
420 GraphCountOverflow {
421 field: &'static str,
423 value: u64,
425 },
426 #[error("unsupported schema version {found}, expected {expected}")]
428 SchemaVersion {
429 found: i64,
431 expected: i64,
433 },
434 #[error("existing database is missing schema_version metadata")]
436 SchemaVersionMissing,
437 #[error("incompatible schema object {object:?}: expected {expected}, found {found}")]
439 SchemaShape {
440 object: String,
442 expected: String,
444 found: String,
446 },
447 #[error("database integrity check failed: {message}")]
449 IntegrityCheck {
450 message: String,
452 },
453 #[error("schema migration did not reach expected version {expected}")]
455 SchemaPostcondition {
456 expected: i64,
458 },
459 #[error("existing database is missing project_root metadata")]
461 ProjectRootMissing,
462 #[error("database project root {found:?} does not match selected root {expected:?}")]
464 ProjectRootMismatch {
465 expected: String,
467 found: String,
469 identities: Option<Box<ProjectRootMismatchIdentities>>,
471 },
472 #[error("project-root identity error: {0}")]
474 ProjectRootIdentity(#[from] CoreError),
475 #[error("bound project database is missing canonical project-root identity")]
477 ProjectRootIdentityMissing,
478 #[error(
480 "legacy root adoption requires an intact schema-19 database at the selected root's .projectatlas/projectatlas.db, without native root identity"
481 )]
482 LegacyRootAdoptionUnavailable,
483 #[error(
485 "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}"
486 )]
487 LegacyRootAdoptionCommittedLocationChanged {
488 #[source]
490 source: Box<DbError>,
491 },
492 #[error("invalid project root transition destination {root:?}: {source}")]
494 ProjectRootDestinationInvalid {
495 root: String,
497 #[source]
499 source: std::io::Error,
500 },
501 #[error("project root transition requires an existing bound root")]
503 ProjectRootTransitionRequiresExistingRoot,
504 #[error("project root move destination {root:?} matches the existing root")]
506 ProjectRootTransitionRequiresDifferentRoot {
507 root: String,
509 },
510 #[error("project root {root:?} still exists; use detach for an independent copy")]
512 ProjectRootStillPresent {
513 root: String,
515 },
516 #[error("cannot prove project root {root:?} is absent: {source}")]
518 ProjectRootAbsenceUncertain {
519 root: String,
521 #[source]
523 source: std::io::Error,
524 },
525 #[error(
527 "project root transition state changed: root {expected_root:?} -> {found_root:?}, identity {expected_identity:?} -> {found_identity:?}"
528 )]
529 ProjectRootTransitionChanged {
530 expected_root: Option<String>,
532 found_root: Option<String>,
534 expected_identity: Option<String>,
536 found_identity: Option<String>,
538 },
539 #[error("bound project database is missing project instance identity")]
541 ProjectInstanceIdentityMissing,
542 #[error("failed to generate a distinct nonzero project instance identity")]
544 ProjectInstanceIdentityGenerationFailed,
545 #[error("{operation}; rollback also failed: {rollback}")]
547 TransactionRollback {
548 #[source]
550 operation: Box<DbError>,
551 rollback: rusqlite::Error,
553 },
554 #[error(
556 "publication writer acquisition failed: {operation}; restoring the standard busy policy also failed: {restore}"
557 )]
558 PublicationAcquirePolicyRestore {
559 #[source]
561 operation: Box<rusqlite::Error>,
562 restore: Box<rusqlite::Error>,
564 },
565 #[error("invalid {field} value in database: {value}")]
567 InvalidEnum {
568 field: &'static str,
570 value: String,
572 },
573 #[error("literal token cannot use FTS acceleration: {reason}")]
575 FileTextFtsTokenUnsafe {
576 reason: &'static str,
578 },
579 #[error("FTS candidate request {requested} exceeds the maximum {maximum}")]
581 FileTextFtsCandidateLimit {
582 requested: usize,
584 maximum: usize,
586 },
587 #[error("invalid FTS BM25 score for {path:?}")]
589 FileTextFtsScoreInvalid {
590 path: String,
592 },
593 #[error("invalid FTS synchronization state: {reason}")]
595 FileTextFtsStateInvalid {
596 reason: &'static str,
598 },
599 #[error("file text {path:?} has invalid {field}: recorded {recorded}, actual {actual}")]
601 FileTextMetadataMismatch {
602 path: String,
604 field: &'static str,
606 recorded: usize,
608 actual: usize,
610 },
611 #[error("{0}")]
613 IndexWork(#[from] IndexWorkFailure),
614 #[error("invalid count for {field}: {value}")]
616 InvalidCount {
617 field: &'static str,
619 value: i64,
621 source: TryFromIntError,
623 },
624 #[error("invalid integer metadata for {field}: {value:?}: {source}")]
626 InvalidInteger {
627 field: &'static str,
629 value: String,
631 source: ParseIntError,
633 },
634 #[error("integer metadata for {field} overflowed at {value}")]
636 IntegerMetadataOverflow {
637 field: &'static str,
639 value: u64,
641 },
642 #[error("path {path:?} is not indexed; run scan, fix the path, or choose an indexed path")]
644 PathNotIndexed {
645 path: String,
647 },
648 #[error("file classification batch requested {requested} paths; maximum is {maximum}")]
650 FileContentClassificationBatchTooLarge {
651 requested: usize,
653 maximum: usize,
655 },
656 #[error("file classification batch repeats path {path:?}")]
658 FileContentClassificationDuplicatePath {
659 path: String,
661 },
662 #[error("current file {path:?} has no content classification")]
664 FileContentClassificationMissing {
665 path: String,
667 },
668 #[error("content classification for {path:?} does not belong to a current file")]
670 FileContentClassificationNotCurrent {
671 path: String,
673 },
674 #[error("file classification page requested {requested} rows; maximum is {maximum}")]
676 FileContentClassificationLimit {
677 requested: u32,
679 maximum: u32,
681 },
682 #[error("invalid purpose-curation task label: {reason}")]
684 PurposeCurationTaskInvalid {
685 reason: &'static str,
687 },
688 #[error("purpose-curation batch requested {requested} paths; maximum is {maximum}")]
690 PurposeCurationBatchTooLarge {
691 requested: usize,
693 maximum: usize,
695 },
696 #[error(
698 "health finding {finding_id:?} with category {category:?} and path {path:?} is not active; run health-check and use an exact finding id/path/category"
699 )]
700 HealthFindingNotActive {
701 finding_id: String,
703 category: String,
705 path: String,
707 },
708 #[error("index publication contract changed during projection refresh")]
710 PublicationContractChanged,
711 #[error("index publication base generation changed: expected {expected}, found {found}")]
713 PublicationBaseGenerationChanged {
714 expected: IndexGeneration,
716 found: IndexGeneration,
718 },
719 #[error("index publication generation overflowed")]
721 PublicationGenerationOverflow,
722 #[error("index publication cannot complete before scan replacement finishes")]
724 ScanReplacementIncomplete,
725 #[error("index read snapshot is already active on this store")]
727 IndexReadSnapshotActive,
728 #[error("read-only store has no database path for telemetry persistence")]
730 TelemetryPathUnavailable,
731 #[error("telemetry field {field} uses {bytes} UTF-8 bytes; limit is {limit}")]
733 TelemetryFieldTooLarge {
734 field: &'static str,
736 bytes: usize,
738 limit: usize,
740 },
741 #[error("invalid telemetry retention limit for {field}: {value}")]
743 TelemetryLimitInvalid {
744 field: &'static str,
746 value: usize,
748 },
749 #[error("telemetry integer overflow for {field}")]
751 TelemetryIntegerOverflow {
752 field: &'static str,
754 },
755 #[error("telemetry usage instance is sealed or expired")]
757 TelemetryInstanceInactive,
758 #[error("telemetry runtime identity is unavailable")]
760 TelemetryIdentityUnavailable,
761 #[error("telemetry runtime identity does not match its retained owner or caller label")]
763 TelemetryInstanceMismatch,
764 #[error("telemetry active-instance capacity is exhausted")]
766 TelemetryInstanceCapacity,
767 #[error("telemetry modeled-baseline capacity is exhausted")]
769 TelemetryBaselineCapacity,
770 #[error("telemetry modeled-baseline key collided with different witness material")]
772 TelemetryBaselineCollision,
773}
774
775impl DbError {
776 #[must_use]
778 pub fn supported_schema_migration(&self) -> Option<(i64, i64, u32)> {
779 match self {
780 Self::SchemaVersion { found, expected } => schema::migration_steps_remaining(*found)
781 .filter(|steps| *steps > 0)
782 .map(|steps| (*found, *expected, steps)),
783 _ => None,
784 }
785 }
786
787 #[must_use]
789 pub fn unsupported_schema_version(&self) -> Option<(i64, i64)> {
790 match self {
791 Self::SchemaVersion { found, expected }
792 if schema::migration_steps_remaining(*found).is_none() =>
793 {
794 Some((*found, *expected))
795 }
796 _ => None,
797 }
798 }
799
800 #[must_use]
803 pub fn is_write_unavailable(&self) -> bool {
804 match self {
805 Self::Sqlite(error) => matches!(
806 error.sqlite_error_code(),
807 Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked | ErrorCode::ReadOnly)
808 ),
809 _ => false,
810 }
811 }
812}
813
814#[derive(Clone, Debug, Eq, PartialEq)]
816pub struct ProjectRootMismatchIdentities {
817 pub expected: CanonicalProjectRoot,
819 pub found: CanonicalProjectRoot,
821}
822
823pub type DbResult<T> = Result<T, DbError>;
825
826pub const MAX_SYMBOL_BATCH_PATHS: u32 = 64;
828pub const MAX_SYMBOL_BATCH_ROWS: u32 = 4_096;
830pub const MAX_SYMBOL_BATCH_DECODED_BYTES: u64 = 4 * 1_024 * 1_024;
832const SYMBOL_BATCH_BIND_PATHS: usize = 48;
834
835#[derive(Clone, Debug, Eq, PartialEq)]
837pub struct ClassifiedSymbol {
838 pub symbol: CodeSymbol,
840 pub classification: ContentClassification,
842}
843
844#[derive(Clone, Copy, Debug, Eq, PartialEq)]
846pub struct SymbolBatchReadBudget {
847 paths: u32,
849 rows: u32,
851 decoded_bytes: u64,
853}
854
855impl SymbolBatchReadBudget {
856 pub fn new(paths: u32, rows: u32, decoded_bytes: u64) -> DbResult<Self> {
862 if paths == 0
863 || paths > MAX_SYMBOL_BATCH_PATHS
864 || rows == 0
865 || rows > MAX_SYMBOL_BATCH_ROWS
866 || decoded_bytes == 0
867 || decoded_bytes > MAX_SYMBOL_BATCH_DECODED_BYTES
868 {
869 return Err(GraphContractError::InvalidLimits {
870 reason: "symbol batch limit is zero or above the product ceiling",
871 }
872 .into());
873 }
874 Ok(Self {
875 paths,
876 rows,
877 decoded_bytes,
878 })
879 }
880
881 #[must_use]
883 pub const fn paths(self) -> u32 {
884 self.paths
885 }
886
887 #[must_use]
889 pub const fn rows(self) -> u32 {
890 self.rows
891 }
892
893 #[must_use]
895 pub const fn decoded_bytes(self) -> u64 {
896 self.decoded_bytes
897 }
898}
899
900#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
902pub struct SymbolBatchReadWork {
903 pub requested_paths: u32,
905 pub returned_rows: u32,
907 pub decoded_bytes: u64,
909}
910
911#[derive(Clone, Debug, Default, Eq, PartialEq)]
913pub struct SymbolBatchRead {
914 pub rows: Vec<CodeSymbol>,
916 pub truncated: bool,
918 pub reached_limit: Option<SymbolBatchReadLimit>,
920 pub work: SymbolBatchReadWork,
922}
923
924#[derive(Clone, Copy, Debug, Eq, PartialEq)]
926pub enum SymbolBatchReadLimit {
927 Paths,
929 Rows,
931 DecodedBytes,
933}
934
935#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
937#[serde(rename_all = "snake_case")]
938pub enum IndexPublicationState {
939 Updating,
941 Complete,
943}
944
945impl IndexPublicationState {
946 const fn as_str(self) -> &'static str {
948 match self {
949 Self::Updating => "updating",
950 Self::Complete => "complete",
951 }
952 }
953
954 fn from_db(value: String) -> DbResult<Self> {
956 match value.as_str() {
957 "updating" => Ok(Self::Updating),
958 "complete" => Ok(Self::Complete),
959 _ => Err(DbError::InvalidEnum {
960 field: INDEX_PUBLICATION_STATE_KEY,
961 value,
962 }),
963 }
964 }
965}
966
967#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
969pub struct IndexPublication {
970 pub state: IndexPublicationState,
972 pub contract_fingerprint: Option<String>,
974 pub generation: IndexGeneration,
976}
977
978enum PublicationContract {
980 Full(String),
982 Projection(String),
984}
985
986pub struct IndexPublicationGuard<'store> {
988 store: &'store mut AtlasStore,
990 contract: PublicationContract,
992 previous_generation: IndexGeneration,
994 scan_replacement_pending: bool,
996 active: bool,
998}
999
1000pub struct AtlasStore {
1002 connection: Connection,
1004 read_snapshot_active: Cell<bool>,
1006 database_path: Option<PathBuf>,
1008 database_location: Option<DatabaseLocation>,
1010 read_only: bool,
1012 validated_project_root: Option<String>,
1014 validated_project_root_identity: Option<CanonicalProjectRoot>,
1016 validated_project_instance_id: Option<ProjectInstanceId>,
1018 library_usage_instances: RefCell<HashMap<String, Option<UsageInstanceId>>>,
1020}
1021
1022pub struct PurposeMutationTransaction<'connection> {
1027 transaction: rusqlite::Transaction<'connection>,
1029}
1030
1031impl PurposeMutationTransaction<'_> {
1032 pub fn commit(self) -> DbResult<()> {
1038 self.transaction.commit().map_err(Into::into)
1039 }
1040
1041 pub fn rollback(self) -> DbResult<()> {
1047 self.transaction.rollback().map_err(Into::into)
1048 }
1049}
1050
1051#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1053pub struct CapturedProjectBinding {
1054 pub project_instance_id: ProjectInstanceId,
1056 pub project_root: Option<String>,
1062 #[serde(skip)]
1064 pub project_root_identity: CanonicalProjectRoot,
1065}
1066
1067#[derive(Clone, Debug, Eq, PartialEq)]
1069pub struct StoredImportRelation {
1070 pub path: String,
1072 pub source_name: String,
1074 pub target_name: String,
1076 pub line: usize,
1078}
1079
1080#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1082pub struct PurposeCurationCandidate {
1083 pub node: IndexedNode,
1085 pub work_key: String,
1087 pub state_token: String,
1089}
1090
1091#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1093pub struct PurposeCurationBatch {
1094 pub project_instance_id: ProjectInstanceId,
1096 pub active_generation: IndexGeneration,
1098 pub task: String,
1100 pub work_key: String,
1102 pub items: Vec<PurposeCurationCandidate>,
1104}
1105
1106#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1108#[serde(rename_all = "snake_case")]
1109pub enum PurposeConditionalApplyState {
1110 Applied,
1112 Stale,
1114 Accepted,
1116 PathUnavailable,
1118}
1119
1120#[derive(Clone, Debug, Eq, PartialEq)]
1122pub struct PurposeConditionalApplyRequest {
1123 pub task: String,
1125 pub path: String,
1127 pub work_key: String,
1129 pub state_token: String,
1131 pub purpose: String,
1133}
1134
1135#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1137pub struct PurposeConditionalApplyResult {
1138 pub path: String,
1140 pub state: PurposeConditionalApplyState,
1142 pub current_purpose: Option<Purpose>,
1144}
1145
1146#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1148enum ProjectIdentityRequirement {
1149 Required,
1151 TransitionOwned,
1153}
1154
1155impl ProjectIdentityRequirement {
1156 const fn is_required(self) -> bool {
1158 matches!(self, Self::Required)
1159 }
1160}
1161
1162impl Deref for IndexPublicationGuard<'_> {
1163 type Target = AtlasStore;
1164
1165 fn deref(&self) -> &Self::Target {
1166 self.store
1167 }
1168}
1169
1170impl DerefMut for IndexPublicationGuard<'_> {
1171 fn deref_mut(&mut self) -> &mut Self::Target {
1172 self.store
1173 }
1174}
1175
1176impl IndexPublicationGuard<'_> {
1177 pub fn begin_scan_replacement(&mut self) -> DbResult<()> {
1187 mark_all_scan_nodes_absent(&self.store.connection)?;
1188 self.scan_replacement_pending = true;
1189 Ok(())
1190 }
1191
1192 pub fn upsert_scan_node_batch(&mut self, nodes: &[Node]) -> DbResult<()> {
1198 upsert_nodes(&self.store.connection, nodes)
1199 }
1200
1201 pub fn finish_scan_replacement(&mut self) -> DbResult<()> {
1207 delete_absent_scan_projections(&self.store.connection)?;
1208 content_classification::validate_complete_file_content_classifications(
1209 &self.store.connection,
1210 )?;
1211 self.scan_replacement_pending = false;
1212 Ok(())
1213 }
1214
1215 pub fn complete(mut self) -> DbResult<()> {
1223 if self.scan_replacement_pending {
1224 return Err(DbError::ScanReplacementIncomplete);
1225 }
1226 content_classification::validate_complete_file_content_classifications(
1227 &self.store.connection,
1228 )?;
1229 repository_graph::validate_complete_document_unresolved_reasons(&self.store.connection)?;
1230 let next_generation = self
1231 .previous_generation
1232 .checked_next()
1233 .ok_or(DbError::PublicationGenerationOverflow)?;
1234 match &self.contract {
1235 PublicationContract::Full(contract_fingerprint) => {
1236 set_metadata(
1237 &self.store.connection,
1238 INDEX_PUBLICATION_FINGERPRINT_KEY,
1239 contract_fingerprint,
1240 )?;
1241 }
1242 PublicationContract::Projection(contract_fingerprint) => {
1243 let matches =
1244 load_index_publication(&self.store.connection)?.is_some_and(|publication| {
1245 publication.state == IndexPublicationState::Updating
1246 && publication.contract_fingerprint.as_deref()
1247 == Some(contract_fingerprint.as_str())
1248 && publication.generation == self.previous_generation
1249 });
1250 if !matches {
1251 return Err(DbError::PublicationContractChanged);
1252 }
1253 }
1254 }
1255 set_metadata(
1256 &self.store.connection,
1257 INDEX_PUBLICATION_GENERATION_KEY,
1258 &next_generation.to_string(),
1259 )?;
1260 set_metadata(
1261 &self.store.connection,
1262 INDEX_PUBLICATION_STATE_KEY,
1263 IndexPublicationState::Complete.as_str(),
1264 )?;
1265 self.store.connection.execute_batch("COMMIT")?;
1266 self.active = false;
1267 Ok(())
1268 }
1269}
1270
1271impl Drop for IndexPublicationGuard<'_> {
1272 fn drop(&mut self) {
1273 if self.active {
1274 let _rollback_result = self.store.connection.execute_batch("ROLLBACK");
1275 }
1276 }
1277}
1278
1279#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1281pub struct IndexedFileText {
1282 pub path: String,
1284 pub content_hash: Option<String>,
1286 pub byte_count: usize,
1288 pub line_count: usize,
1290 pub content: String,
1292}
1293
1294#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1296pub struct FileTextFtsQuery<'query> {
1297 pub literal_token: &'query str,
1299 pub path_prefix: Option<&'query str>,
1301 pub limit: usize,
1303}
1304
1305#[derive(Clone, Debug, PartialEq)]
1307pub struct FileTextFtsCandidate {
1308 pub path: String,
1310 pub content_hash: Option<String>,
1312 pub byte_count: usize,
1314 pub line_count: usize,
1316 pub bm25: f64,
1318}
1319
1320#[derive(Clone, Debug, PartialEq)]
1322pub struct FileTextFtsPage {
1323 pub candidates: Vec<FileTextFtsCandidate>,
1325 pub overflow: bool,
1327}
1328
1329#[derive(Clone, Debug, Eq, PartialEq)]
1331pub struct FileTextMetadata {
1332 pub path: String,
1334 pub content_hash: Option<String>,
1336 pub byte_count: usize,
1338 pub line_count: usize,
1340 pub classification: ContentClassification,
1342}
1343
1344#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1346pub enum FileTextAdmission {
1347 Read,
1349 Skip,
1351 Stop,
1353}
1354
1355#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1357pub struct FileTextFtsState {
1358 pub source_rows: usize,
1360 pub indexed_rows: usize,
1362 pub synchronized: bool,
1364}
1365
1366#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1368pub struct HealthResolution {
1369 pub finding_id: String,
1371 pub category: String,
1373 pub path: String,
1375 pub related_path: Option<String>,
1377 pub rationale: String,
1379}
1380
1381#[derive(Clone, Debug, Eq, PartialEq)]
1383pub struct HealthQuery {
1384 pub start_index: usize,
1386 pub limit: usize,
1388 pub category: Option<String>,
1390 pub severity: Option<Severity>,
1392 pub path_prefix: Option<String>,
1394 pub summary_only: bool,
1396 pub scope: HealthScope,
1398}
1399
1400#[derive(Clone, Copy)]
1402enum HealthResolutionFilter<'a> {
1403 Explicit(&'a [String]),
1405 Stored,
1407}
1408
1409#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1411pub enum HealthScope {
1412 All,
1414 SourceOnly,
1416 PurposeDefault,
1418 PurposeWithAssets,
1420 PurposeWithSourceFiles,
1422 PurposeStrict,
1424}
1425
1426impl HealthScope {
1427 pub fn all() -> Self {
1429 Self::All
1430 }
1431
1432 pub fn source_only() -> Self {
1434 Self::SourceOnly
1435 }
1436
1437 pub fn purpose_default() -> Self {
1439 Self::PurposeDefault
1440 }
1441
1442 pub fn purpose_with_assets() -> Self {
1444 Self::PurposeWithAssets
1445 }
1446
1447 pub fn purpose_with_source_files() -> Self {
1449 Self::PurposeWithSourceFiles
1450 }
1451
1452 pub fn purpose_strict() -> Self {
1454 Self::PurposeStrict
1455 }
1456
1457 pub fn is_source_focused(self) -> bool {
1459 self.source_only_filter()
1460 }
1461
1462 pub fn is_purpose_queue(self) -> bool {
1464 self.high_impact_queue()
1465 }
1466
1467 fn source_only_filter(self) -> bool {
1469 matches!(
1470 self,
1471 Self::SourceOnly | Self::PurposeDefault | Self::PurposeWithSourceFiles
1472 )
1473 }
1474
1475 fn high_impact_queue(self) -> bool {
1477 matches!(
1478 self,
1479 Self::PurposeDefault
1480 | Self::PurposeWithAssets
1481 | Self::PurposeWithSourceFiles
1482 | Self::PurposeStrict
1483 )
1484 }
1485
1486 fn include_assets(self) -> bool {
1488 matches!(self, Self::PurposeWithAssets)
1489 }
1490
1491 fn include_source_files(self) -> bool {
1493 matches!(self, Self::PurposeWithSourceFiles | Self::PurposeStrict)
1494 }
1495
1496 fn include_all_files(self) -> bool {
1498 matches!(self, Self::PurposeStrict)
1499 }
1500}
1501
1502#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1504pub struct HealthFindingsPage {
1505 pub total: usize,
1507 pub unfiltered_total: usize,
1509 pub returned: usize,
1511 pub start_index: usize,
1513 pub limit: usize,
1515 pub findings: Vec<HealthFinding>,
1517}
1518
1519#[derive(Clone, Copy, Debug)]
1521struct PurposeHealthSpec {
1522 status: &'static str,
1524 category: &'static str,
1526 message: &'static str,
1528 recommendation: &'static str,
1530}
1531
1532const PURPOSE_HEALTH_SPECS: [PurposeHealthSpec; 3] = [
1534 PurposeHealthSpec {
1535 status: PurposeStatus::Missing.as_str(),
1536 category: CATEGORY_MISSING_PURPOSE,
1537 message: MESSAGE_MISSING_PURPOSE,
1538 recommendation: RECOMMENDATION_MISSING_PURPOSE_QUEUE,
1539 },
1540 PurposeHealthSpec {
1541 status: PurposeStatus::Suggested.as_str(),
1542 category: CATEGORY_SUGGESTED_PURPOSE_REVIEW,
1543 message: MESSAGE_SUGGESTED_PURPOSE_REVIEW,
1544 recommendation: RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW_QUEUE,
1545 },
1546 PurposeHealthSpec {
1547 status: PurposeStatus::Stale.as_str(),
1548 category: CATEGORY_STALE_PURPOSE,
1549 message: MESSAGE_STALE_PURPOSE,
1550 recommendation: RECOMMENDATION_STALE_PURPOSE,
1551 },
1552];
1553
1554#[cfg(test)]
1556fn with_validated_write_transaction<T>(
1557 connection: &Connection,
1558 expected_root: Option<&str>,
1559 expected_identity: Option<ProjectInstanceId>,
1560 operation: impl FnOnce(&Connection) -> DbResult<T>,
1561) -> DbResult<T> {
1562 let transaction =
1563 rusqlite::Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?;
1564 let result = schema::validate_active_binding(&transaction, expected_root, expected_identity)
1565 .and_then(|()| operation(&transaction));
1566 match result {
1567 Ok(value) => {
1568 transaction.commit()?;
1569 Ok(value)
1570 }
1571 Err(operation) => match transaction.rollback() {
1572 Ok(()) => Err(operation),
1573 Err(rollback) => Err(DbError::TransactionRollback {
1574 operation: Box::new(operation),
1575 rollback,
1576 }),
1577 },
1578 }
1579}
1580
1581fn with_validated_native_write_transaction<T>(
1583 connection: &Connection,
1584 expected_root: Option<&CanonicalProjectRoot>,
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 =
1591 schema::validate_active_native_binding(&transaction, expected_root, expected_identity)
1592 .and_then(|()| operation(&transaction));
1593 match result {
1594 Ok(value) => {
1595 transaction.commit()?;
1596 Ok(value)
1597 }
1598 Err(operation) => match transaction.rollback() {
1599 Ok(()) => Err(operation),
1600 Err(rollback) => Err(DbError::TransactionRollback {
1601 operation: Box::new(operation),
1602 rollback,
1603 }),
1604 },
1605 }
1606}
1607
1608impl AtlasStore {
1609 fn require_mutation_scope(&self) -> DbResult<()> {
1611 if self.read_snapshot_active.get() {
1612 Err(DbError::IndexReadSnapshotActive)
1613 } else {
1614 Ok(())
1615 }
1616 }
1617
1618 fn validated_savepoint(&mut self) -> DbResult<rusqlite::Savepoint<'_>> {
1620 self.require_mutation_scope()?;
1621 let validate_binding = self.connection.is_autocommit();
1622 let expected_root_identity = self.validated_project_root_identity.clone();
1623 let expected_identity = self.validated_project_instance_id;
1624 let savepoint = self.connection.savepoint()?;
1625 if validate_binding {
1626 schema::validate_active_native_binding(
1627 &savepoint,
1628 expected_root_identity.as_ref(),
1629 expected_identity,
1630 )?;
1631 }
1632 Ok(savepoint)
1633 }
1634
1635 fn with_validated_write<T>(
1637 &self,
1638 operation: impl FnOnce(&Connection) -> DbResult<T>,
1639 ) -> DbResult<T> {
1640 self.require_mutation_scope()?;
1641 if !self.connection.is_autocommit() {
1642 return operation(&self.connection);
1643 }
1644 with_validated_native_write_transaction(
1645 &self.connection,
1646 self.validated_project_root_identity.as_ref(),
1647 self.validated_project_instance_id,
1648 operation,
1649 )
1650 }
1651
1652 pub fn begin_purpose_mutation(&self) -> DbResult<PurposeMutationTransaction<'_>> {
1664 self.require_mutation_scope()?;
1665 let transaction =
1666 rusqlite::Transaction::new_unchecked(&self.connection, TransactionBehavior::Immediate)?;
1667 schema::validate_active_native_binding(
1668 &transaction,
1669 self.validated_project_root_identity.as_ref(),
1670 self.validated_project_instance_id,
1671 )?;
1672 Ok(PurposeMutationTransaction { transaction })
1673 }
1674
1675 fn with_telemetry_connection<T>(
1682 &self,
1683 operation: impl FnOnce(&Connection) -> DbResult<T>,
1684 ) -> DbResult<T> {
1685 if self.read_only {
1686 self.finish_index_read_snapshot()?;
1687 } else {
1688 self.require_mutation_scope()?;
1689 }
1690 let (Some(path), Some(location)) =
1691 (self.database_path.as_ref(), self.database_location.as_ref())
1692 else {
1693 if self.database_path.is_none() && self.database_location.is_none() {
1694 return operation(&self.connection);
1695 }
1696 return Err(DbError::TelemetryPathUnavailable);
1697 };
1698 let _ = schema::preflight(path, None)?;
1699 let connection = open_writable_connection(
1700 path,
1701 OpenFlags::SQLITE_OPEN_READ_WRITE,
1702 location,
1703 SQLITE_TELEMETRY_BUSY_TIMEOUT,
1704 JournalModePolicy::RequireWal,
1705 )?;
1706 operation(&connection)
1707 }
1708
1709 pub fn open(path: &Path) -> DbResult<Self> {
1715 Self::open_with_project_root(path, None)
1716 }
1717
1718 pub fn open_for_project(path: &Path, root: &Path) -> DbResult<Self> {
1729 let expected_identity = CanonicalProjectRoot::from_path(root)?;
1730 Self::open_with_binding_requirement(
1731 path,
1732 None,
1733 Some(&expected_identity),
1734 ProjectIdentityRequirement::Required,
1735 )
1736 }
1737
1738 fn open_with_project_root(path: &Path, expected_root: Option<&str>) -> DbResult<Self> {
1740 Self::open_with_binding_requirement(
1741 path,
1742 expected_root,
1743 None,
1744 ProjectIdentityRequirement::Required,
1745 )
1746 }
1747
1748 fn open_for_root_transition(path: &Path) -> DbResult<Self> {
1750 Self::open_with_binding_requirement(
1751 path,
1752 None,
1753 None,
1754 ProjectIdentityRequirement::TransitionOwned,
1755 )
1756 }
1757
1758 fn open_with_binding_requirement(
1760 path: &Path,
1761 expected_root: Option<&str>,
1762 expected_identity: Option<&CanonicalProjectRoot>,
1763 identity_requirement: ProjectIdentityRequirement,
1764 ) -> DbResult<Self> {
1765 let (preflight, location) = if let Some(expected_identity) = expected_identity {
1766 schema::preflight_for_project(path, expected_identity)?
1767 } else {
1768 schema::preflight(path, None)?
1769 };
1770 if expected_identity.is_none()
1771 && identity_requirement.is_required()
1772 && preflight.state == SchemaState::UpgradeRequired
1773 && schema::legacy_root_requires_native_authority(preflight.project_root.as_deref())
1774 {
1775 return Err(DbError::ProjectRootIdentityMissing);
1779 }
1780 let validated_project_root = expected_identity
1781 .and_then(|identity| identity.display_string().ok())
1782 .or_else(|| expected_root.map(str::to_owned))
1783 .or_else(|| {
1784 expected_identity
1785 .is_none()
1786 .then(|| preflight.project_root.clone())
1787 .flatten()
1788 });
1789 if preflight.state == SchemaState::Current
1790 && expected_identity.is_some()
1791 && identity_requirement.is_required()
1792 && preflight.project_instance_id.is_none()
1793 {
1794 return Err(DbError::ProjectInstanceIdentityMissing);
1795 }
1796 let connection = open_writable_connection(
1797 path,
1798 writable_open_flags(preflight.state, location.database_exists),
1799 &location,
1800 SQLITE_BUSY_TIMEOUT,
1801 writable_journal_policy(preflight.state),
1802 )?;
1803 let transition_transaction = preflight.state == SchemaState::UpgradeRequired
1804 && identity_requirement == ProjectIdentityRequirement::TransitionOwned;
1805 if transition_transaction {
1806 connection.execute_batch("BEGIN IMMEDIATE")?;
1807 }
1808 let opened = (|| {
1809 if preflight.state == SchemaState::Current
1810 && let Some(identity) = expected_identity
1811 {
1812 project_identity::ensure_project_root_identity(&connection, identity)?;
1813 }
1814 let validated_project_instance_id = if preflight.state == SchemaState::Current {
1815 if let Some(expected_identity) = expected_identity {
1816 schema::revalidate_current_native_binding(
1817 &connection,
1818 expected_identity,
1819 identity_requirement.is_required(),
1820 )?
1821 } else if identity_requirement == ProjectIdentityRequirement::TransitionOwned {
1822 project_identity::load_project_identity(&connection)?
1827 } else if let Some(stored_identity) =
1828 project_identity::load_project_root_identity(&connection)?
1829 {
1830 schema::revalidate_current_native_binding(
1831 &connection,
1832 &stored_identity,
1833 identity_requirement.is_required(),
1834 )?
1835 } else {
1836 let stored_instance_id = project_identity::load_project_identity(&connection)?;
1837 schema::validate_binding_completeness(
1838 validated_project_root.as_deref(),
1839 stored_instance_id,
1840 identity_requirement.is_required(),
1841 )?;
1842 if stored_instance_id.is_some() {
1843 return Err(DbError::ProjectRootIdentityMissing);
1844 }
1845 None
1846 }
1847 } else {
1848 if let Some(expected_identity) = expected_identity {
1849 let expected_display = expected_identity.display_string().ok();
1850 schema::initialize_with_project_root(
1851 &connection,
1852 expected_display.as_deref(),
1853 Some(expected_identity),
1854 )?;
1855 } else {
1856 let initialization_root = expected_root.or(preflight.project_root.as_deref());
1857 if transition_transaction {
1858 schema::initialize_with_project_root_in_transaction(
1859 &connection,
1860 initialization_root,
1861 None,
1862 )?;
1863 } else {
1864 schema::initialize_with_project_root(
1865 &connection,
1866 initialization_root,
1867 None,
1868 )?;
1869 }
1870 }
1871 project_identity::load_project_identity(&connection)?
1872 };
1873 let validated_project_root_identity =
1874 project_identity::load_project_root_identity(&connection)?;
1875 if identity_requirement.is_required()
1876 && (validated_project_root.is_some() || validated_project_root_identity.is_some())
1877 && validated_project_instance_id.is_none()
1878 {
1879 return Err(DbError::ProjectInstanceIdentityMissing);
1880 }
1881 let database_location = if location.database_exists {
1882 location
1883 } else {
1884 sqlite_profile::inspect_database_location(path)?
1885 };
1886 Ok((
1887 database_location,
1888 validated_project_root,
1889 validated_project_root_identity,
1890 validated_project_instance_id,
1891 ))
1892 })();
1893 if transition_transaction {
1894 match opened {
1895 Ok((
1896 database_location,
1897 validated_project_root,
1898 validated_project_root_identity,
1899 validated_project_instance_id,
1900 )) => Ok(Self {
1901 connection,
1902 read_snapshot_active: Cell::new(false),
1903 database_path: Some(path.to_path_buf()),
1904 database_location: Some(database_location),
1905 read_only: false,
1906 validated_project_root,
1907 validated_project_root_identity,
1908 validated_project_instance_id,
1909 library_usage_instances: RefCell::new(HashMap::new()),
1910 }),
1911 Err(error) => Err(schema::rollback_after_error(&connection, error)),
1912 }
1913 } else {
1914 let (
1915 database_location,
1916 validated_project_root,
1917 validated_project_root_identity,
1918 validated_project_instance_id,
1919 ) = opened?;
1920 Ok(Self {
1921 connection,
1922 read_snapshot_active: Cell::new(false),
1923 database_path: Some(path.to_path_buf()),
1924 database_location: Some(database_location),
1925 read_only: false,
1926 validated_project_root,
1927 validated_project_root_identity,
1928 validated_project_instance_id,
1929 library_usage_instances: RefCell::new(HashMap::new()),
1930 })
1931 }
1932 }
1933
1934 pub fn open_read_only(path: &Path) -> DbResult<Self> {
1941 Self::open_read_only_with_project_root(path, None)
1942 }
1943
1944 pub fn open_read_only_for_project(path: &Path, root: &Path) -> DbResult<Self> {
1951 let expected_identity = CanonicalProjectRoot::from_path(root)?;
1952 Self::open_read_only_with_project_root(path, Some(&expected_identity))
1953 }
1954
1955 #[must_use]
1957 pub const fn is_read_only(&self) -> bool {
1958 self.read_only
1959 }
1960
1961 #[must_use]
1963 pub fn has_active_read_snapshot(&self) -> bool {
1964 self.read_snapshot_active.get()
1965 }
1966
1967 fn open_read_only_with_project_root(
1969 path: &Path,
1970 expected_identity: Option<&CanonicalProjectRoot>,
1971 ) -> DbResult<Self> {
1972 let (connection, preflight) = schema::open_current_read_only(path, None)?;
1973 let validated_project_instance_id = project_identity::load_project_identity(&connection)?;
1974 let validated_project_root_identity =
1975 project_identity::load_project_root_identity(&connection)?;
1976 if expected_identity.is_some() && validated_project_root_identity.is_none() {
1977 return Err(DbError::ProjectRootIdentityMissing);
1978 }
1979 if let (Some(expected), Some(found)) =
1980 (expected_identity, validated_project_root_identity.as_ref())
1981 {
1982 project_identity::prove_existing_root_equivalence(expected.as_path(), found.as_path())?;
1983 }
1984 if validated_project_root_identity.is_none() {
1985 schema::validate_binding_completeness(
1986 preflight.project_root.as_deref(),
1987 validated_project_instance_id,
1988 true,
1989 )?;
1990 } else if validated_project_instance_id.is_none() {
1991 return Err(DbError::ProjectInstanceIdentityMissing);
1992 }
1993 let validated_project_root = match validated_project_root_identity.as_ref() {
1994 Some(identity) => identity.display_string().ok(),
1995 None => preflight.project_root,
1996 };
1997 let database_location = sqlite_profile::inspect_database_location(path)?;
1998 Ok(Self {
1999 connection,
2000 read_snapshot_active: Cell::new(true),
2001 database_path: Some(path.to_path_buf()),
2002 database_location: Some(database_location),
2003 read_only: true,
2004 validated_project_root,
2005 validated_project_root_identity,
2006 validated_project_instance_id,
2007 library_usage_instances: RefCell::new(HashMap::new()),
2008 })
2009 }
2010
2011 pub fn in_memory() -> DbResult<Self> {
2017 let store = Self {
2018 connection: Connection::open_in_memory()?,
2019 read_snapshot_active: Cell::new(false),
2020 database_path: None,
2021 database_location: None,
2022 read_only: false,
2023 validated_project_root: None,
2024 validated_project_root_identity: None,
2025 validated_project_instance_id: None,
2026 library_usage_instances: RefCell::new(HashMap::new()),
2027 };
2028 schema::initialize(&store.connection, None)?;
2029 Ok(store)
2030 }
2031
2032 pub fn initialize_schema(&self) -> DbResult<()> {
2038 schema::initialize(&self.connection, None)
2039 }
2040
2041 pub fn replace_scan(&mut self, nodes: &[Node]) -> DbResult<()> {
2047 let savepoint = self.validated_savepoint()?;
2048 mark_all_scan_nodes_absent(&savepoint)?;
2049 upsert_nodes(&savepoint, nodes)?;
2050 delete_absent_scan_projections(&savepoint)?;
2051 savepoint.commit()?;
2052 Ok(())
2053 }
2054
2055 pub fn upsert_scan_nodes(&mut self, nodes: &[Node]) -> DbResult<()> {
2061 let savepoint = self.validated_savepoint()?;
2062 upsert_nodes(&savepoint, nodes)?;
2063 savepoint.commit()?;
2064 Ok(())
2065 }
2066
2067 pub fn mark_paths_absent(&mut self, paths: &[String]) -> DbResult<()> {
2073 let savepoint = self.validated_savepoint()?;
2074 let fts_revision = begin_file_text_fts_update(&savepoint)?;
2075 {
2076 let mut mark_nodes = savepoint.prepare_cached(
2077 "UPDATE nodes SET exists_now = 0 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2078 )?;
2079 let mut delete_classifications = savepoint.prepare_cached(
2080 "DELETE FROM file_content_classifications
2081 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2082 )?;
2083 let mut delete_relations = savepoint.prepare_cached(
2084 "DELETE FROM symbol_relations WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2085 )?;
2086 let mut delete_symbols = savepoint.prepare_cached(
2087 "DELETE FROM symbols WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2088 )?;
2089 let mut delete_parse_metadata = savepoint.prepare_cached(
2090 "DELETE FROM source_parse_metadata WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2091 )?;
2092 let mut delete_text_fts = savepoint.prepare_cached(
2093 "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
2094 SELECT 'delete', rowid, content FROM file_texts \
2095 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2096 )?;
2097 let mut delete_text = savepoint.prepare_cached(
2098 "DELETE FROM file_texts WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2099 )?;
2100 for path in paths {
2101 if path == "." || path.is_empty() {
2102 continue;
2103 }
2104 let descendant_pattern = sqlite_descendant_pattern(path);
2105 delete_classifications.execute(params![path, descendant_pattern])?;
2106 mark_nodes.execute(params![path, descendant_pattern])?;
2107 delete_relations.execute(params![path, descendant_pattern])?;
2108 delete_symbols.execute(params![path, descendant_pattern])?;
2109 delete_parse_metadata.execute(params![path, descendant_pattern])?;
2110 delete_text_fts.execute(params![path, descendant_pattern])?;
2111 delete_text.execute(params![path, descendant_pattern])?;
2112 }
2113 }
2114 complete_file_text_fts_update(&savepoint, fts_revision)?;
2115 savepoint.commit()?;
2116 Ok(())
2117 }
2118
2119 pub fn replace_file_texts_for_paths<'text>(
2129 &mut self,
2130 paths: &[String],
2131 texts: impl IntoIterator<Item = &'text IndexedFileText>,
2132 ) -> DbResult<()> {
2133 let texts = texts.into_iter().collect::<Vec<_>>();
2134 for text in &texts {
2135 validate_indexed_file_text(text)?;
2136 }
2137 let savepoint = self.validated_savepoint()?;
2138 let fts_revision = begin_file_text_fts_update(&savepoint)?;
2139 {
2140 let mut delete_fts = savepoint.prepare_cached(
2141 "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
2142 SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
2143 )?;
2144 let mut delete = savepoint.prepare_cached("DELETE FROM file_texts WHERE path = ?1")?;
2145 for path in paths {
2146 delete_fts.execute([path])?;
2147 delete.execute([path])?;
2148 }
2149 }
2150 {
2151 let mut delete_current_fts = savepoint.prepare_cached(
2152 "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
2153 SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
2154 )?;
2155 let mut upsert = savepoint.prepare_cached(
2156 "
2157 INSERT INTO file_texts(path, content_hash, byte_count, line_count, content, updated_at)
2158 VALUES(?1, ?2, ?3, ?4, ?5, CURRENT_TIMESTAMP)
2159 ON CONFLICT(path) DO UPDATE SET
2160 content_hash = excluded.content_hash,
2161 byte_count = excluded.byte_count,
2162 line_count = excluded.line_count,
2163 content = excluded.content,
2164 updated_at = CURRENT_TIMESTAMP
2165 ",
2166 )?;
2167 let mut index = savepoint.prepare_cached(
2168 "INSERT INTO file_text_fts(rowid, content) \
2169 SELECT rowid, content FROM file_texts WHERE path = ?1",
2170 )?;
2171 for text in texts {
2172 delete_current_fts.execute([&text.path])?;
2173 upsert.execute(params![
2174 text.path,
2175 text.content_hash.as_deref(),
2176 usize_to_i64(text.byte_count),
2177 usize_to_i64(text.line_count),
2178 text.content
2179 ])?;
2180 index.execute([&text.path])?;
2181 }
2182 }
2183 complete_file_text_fts_update(&savepoint, fts_revision)?;
2184 savepoint.commit()?;
2185 Ok(())
2186 }
2187
2188 pub fn load_file_text(&self, path: &str) -> DbResult<Option<IndexedFileText>> {
2194 let mut statement = self.connection.prepare(
2195 "
2196 SELECT path, content_hash, byte_count, line_count, content
2197 FROM file_texts
2198 WHERE path = ?1
2199 ",
2200 )?;
2201 let mut rows = statement.query([path])?;
2202 rows.next()?.map(file_text_from_row).transpose()
2203 }
2204
2205 pub fn query_file_text_fts_candidates(
2217 &self,
2218 query: &FileTextFtsQuery<'_>,
2219 control: Option<&IndexWorkControl>,
2220 ) -> DbResult<FileTextFtsPage> {
2221 validate_file_text_fts_token(query.literal_token)?;
2222 if query.limit > MAX_FILE_TEXT_FTS_CANDIDATES {
2223 return Err(DbError::FileTextFtsCandidateLimit {
2224 requested: query.limit,
2225 maximum: MAX_FILE_TEXT_FTS_CANDIDATES,
2226 });
2227 }
2228 let match_expression = format!("\"{}\"", query.literal_token);
2229 let row_limit = usize_to_i64(query.limit.saturating_add(1));
2230 with_file_text_progress(&self.connection, control, || {
2231 let mut candidates = Vec::with_capacity(query.limit.saturating_add(1));
2232 if let Some(path_prefix) = normalized_file_text_path_prefix(query.path_prefix) {
2233 let descendant_pattern = sqlite_descendant_pattern(path_prefix);
2234 let mut statement = self.connection.prepare_cached(
2235 "
2236 SELECT
2237 f.path,
2238 f.content_hash,
2239 f.byte_count,
2240 f.line_count,
2241 bm25(file_text_fts)
2242 FROM file_text_fts
2243 JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
2244 WHERE file_text_fts MATCH ?1
2245 AND (f.path = ?2 OR f.path LIKE ?3 ESCAPE '\\')
2246 ORDER BY bm25(file_text_fts), f.path
2247 LIMIT ?4
2248 ",
2249 )?;
2250 let mut rows = statement.query(params![
2251 match_expression,
2252 path_prefix,
2253 descendant_pattern,
2254 row_limit,
2255 ])?;
2256 collect_file_text_fts_candidates(&mut rows, control, &mut candidates)?;
2257 } else {
2258 let mut statement = self.connection.prepare_cached(
2259 "
2260 SELECT
2261 f.path,
2262 f.content_hash,
2263 f.byte_count,
2264 f.line_count,
2265 bm25(file_text_fts)
2266 FROM file_text_fts
2267 JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
2268 WHERE file_text_fts MATCH ?1
2269 ORDER BY bm25(file_text_fts), f.path
2270 LIMIT ?2
2271 ",
2272 )?;
2273 let mut rows = statement.query(params![match_expression, row_limit])?;
2274 collect_file_text_fts_candidates(&mut rows, control, &mut candidates)?;
2275 }
2276 let overflow = candidates.len() > query.limit;
2277 candidates.truncate(query.limit);
2278 Ok(FileTextFtsPage {
2279 candidates,
2280 overflow,
2281 })
2282 })
2283 }
2284
2285 pub fn visit_file_texts_for_fallback<A, V>(
2297 &self,
2298 path_prefix: Option<&str>,
2299 control: Option<&IndexWorkControl>,
2300 mut admit: A,
2301 mut visitor: V,
2302 ) -> DbResult<()>
2303 where
2304 A: FnMut(&FileTextMetadata) -> DbResult<FileTextAdmission>,
2305 V: FnMut(IndexedFileText) -> DbResult<bool>,
2306 {
2307 with_file_text_progress(&self.connection, control, || {
2308 let mut content_statement = self
2309 .connection
2310 .prepare_cached(FILE_TEXT_FALLBACK_CONTENT_SQL)?;
2311 if let Some(path_prefix) = normalized_file_text_path_prefix(path_prefix) {
2312 let (descendant_start, descendant_end) = file_text_descendant_range(path_prefix);
2313 let mut statement = self
2314 .connection
2315 .prepare_cached(FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL)?;
2316 let mut rows =
2317 statement.query(params![path_prefix, descendant_start, descendant_end])?;
2318 visit_file_text_fallback_rows(
2319 &mut rows,
2320 &mut content_statement,
2321 control,
2322 &mut admit,
2323 &mut visitor,
2324 )
2325 } else {
2326 let mut statement = self
2327 .connection
2328 .prepare_cached(FILE_TEXT_FALLBACK_ALL_METADATA_SQL)?;
2329 let mut rows = statement.query([])?;
2330 visit_file_text_fallback_rows(
2331 &mut rows,
2332 &mut content_statement,
2333 control,
2334 &mut admit,
2335 &mut visitor,
2336 )
2337 }
2338 })
2339 }
2340
2341 pub fn file_text_fts_ready(&self) -> DbResult<bool> {
2350 Ok(load_file_text_fts_revisions(&self.connection)?
2351 .is_some_and(|(source, projection)| source == projection))
2352 }
2353
2354 pub fn file_text_fts_state(&self) -> DbResult<FileTextFtsState> {
2361 let revision_synchronized = self.file_text_fts_ready()?;
2362 let (source_rows, indexed_rows, synchronized) = self.connection.query_row(
2363 "
2364 SELECT
2365 (SELECT COUNT(*) FROM file_texts),
2366 (SELECT COUNT(*) FROM file_text_fts_docsize),
2367 NOT EXISTS(
2368 SELECT 1
2369 FROM file_texts AS f
2370 LEFT JOIN file_text_fts_docsize AS d ON d.id = f.rowid
2371 WHERE d.id IS NULL
2372 )
2373 AND NOT EXISTS(
2374 SELECT 1
2375 FROM file_text_fts_docsize AS d
2376 LEFT JOIN file_texts AS f ON f.rowid = d.id
2377 WHERE f.rowid IS NULL
2378 )
2379 ",
2380 [],
2381 |row| {
2382 Ok((
2383 row.get::<_, i64>(0)?,
2384 row.get::<_, i64>(1)?,
2385 row.get::<_, i64>(2)? != 0,
2386 ))
2387 },
2388 )?;
2389 Ok(FileTextFtsState {
2390 source_rows: count_to_usize("file_texts", source_rows)?,
2391 indexed_rows: count_to_usize("file_text_fts", indexed_rows)?,
2392 synchronized: revision_synchronized && synchronized,
2393 })
2394 }
2395
2396 pub fn load_file_texts_for_search(
2407 &self,
2408 literal_pattern: Option<&str>,
2409 case_sensitive: bool,
2410 ) -> DbResult<Vec<IndexedFileText>> {
2411 let mut texts = Vec::new();
2412 self.visit_file_texts_for_search(literal_pattern, case_sensitive, |text| {
2413 texts.push(text);
2414 Ok(true)
2415 })?;
2416 Ok(texts)
2417 }
2418
2419 pub fn visit_file_texts_for_search<F>(
2430 &self,
2431 literal_pattern: Option<&str>,
2432 case_sensitive: bool,
2433 mut visitor: F,
2434 ) -> DbResult<()>
2435 where
2436 F: FnMut(IndexedFileText) -> DbResult<bool>,
2437 {
2438 if let Some(pattern) = literal_pattern.filter(|pattern| !pattern.is_empty()) {
2439 if case_sensitive {
2440 let mut statement = self.connection.prepare(
2441 "
2442 SELECT path, content_hash, byte_count, line_count, content
2443 FROM file_texts
2444 WHERE instr(content, ?1) > 0
2445 ORDER BY path
2446 ",
2447 )?;
2448 let mut rows = statement.query([pattern])?;
2449 while let Some(row) = rows.next()? {
2450 if !visitor(file_text_from_row(row)?)? {
2451 return Ok(());
2452 }
2453 }
2454 } else {
2455 let pattern = pattern.to_ascii_lowercase();
2456 let mut statement = self.connection.prepare(
2457 "
2458 SELECT path, content_hash, byte_count, line_count, content
2459 FROM file_texts
2460 WHERE instr(lower(content), ?1) > 0
2461 ORDER BY path
2462 ",
2463 )?;
2464 let mut rows = statement.query([pattern])?;
2465 while let Some(row) = rows.next()? {
2466 if !visitor(file_text_from_row(row)?)? {
2467 return Ok(());
2468 }
2469 }
2470 }
2471 } else {
2472 let mut statement = self.connection.prepare(
2473 "
2474 SELECT path, content_hash, byte_count, line_count, content
2475 FROM file_texts
2476 ORDER BY path
2477 ",
2478 )?;
2479 let mut rows = statement.query([])?;
2480 while let Some(row) = rows.next()? {
2481 if !visitor(file_text_from_row(row)?)? {
2482 return Ok(());
2483 }
2484 }
2485 }
2486 Ok(())
2487 }
2488
2489 pub fn file_text_count(&self) -> DbResult<usize> {
2495 let count = self
2496 .connection
2497 .query_row("SELECT COUNT(*) FROM file_texts", [], |row| {
2498 row.get::<_, i64>(0)
2499 })?;
2500 count_to_usize("file_texts", count)
2501 }
2502
2503 pub fn file_text_byte_count(&self) -> DbResult<usize> {
2509 let count = self.connection.query_row(
2510 "SELECT COALESCE(SUM(byte_count), 0) FROM file_texts",
2511 [],
2512 |row| row.get::<_, i64>(0),
2513 )?;
2514 count_to_usize("file_text_bytes", count)
2515 }
2516
2517 pub fn set_project_root(&mut self, root: &Path) -> DbResult<()> {
2523 let identity = CanonicalProjectRoot::from_path(root)?;
2524 let value = identity.display_string().ok();
2525 let previous_identity = self.validated_project_instance_id;
2526 let savepoint = self.validated_savepoint()?;
2527 let found = savepoint
2528 .query_row(
2529 "SELECT value FROM metadata WHERE key = ?1",
2530 [PROJECT_ROOT_KEY],
2531 |row| row.get::<_, String>(0),
2532 )
2533 .optional()?;
2534 let found_identity = project_identity::load_project_root_identity(&savepoint)?;
2535 if found.is_none() && found_identity.is_none() {
2536 project_identity::set_project_root_identity(&savepoint, &identity)?;
2537 project_identity::set_project_root_metadata(&savepoint, &identity)?;
2538 } else {
2539 project_identity::ensure_project_root_identity_in_transaction(&savepoint, &identity)?;
2540 }
2541 let (project_identity, _) = project_identity::ensure_project_identity(&savepoint)?;
2542 let identity_changed = previous_identity != Some(project_identity);
2543 savepoint.commit()?;
2544 self.validated_project_root = value;
2545 self.validated_project_root_identity = Some(identity);
2546 self.validated_project_instance_id = Some(project_identity);
2547 if identity_changed {
2548 self.library_usage_instances.get_mut().clear();
2549 }
2550 Ok(())
2551 }
2552
2553 pub fn project_root(&self) -> DbResult<Option<String>> {
2559 self.connection
2560 .query_row(
2561 "SELECT value FROM metadata WHERE key = ?1",
2562 [PROJECT_ROOT_KEY],
2563 |row| row.get::<_, String>(0),
2564 )
2565 .optional()
2566 .map_err(DbError::from)
2567 }
2568
2569 pub fn captured_project_binding(&self) -> DbResult<CapturedProjectBinding> {
2578 Ok(CapturedProjectBinding {
2579 project_instance_id: self
2580 .validated_project_instance_id
2581 .ok_or(DbError::ProjectInstanceIdentityMissing)?,
2582 project_root: self.validated_project_root.clone(),
2583 project_root_identity: self
2584 .validated_project_root_identity
2585 .clone()
2586 .ok_or(DbError::ProjectRootIdentityMissing)?,
2587 })
2588 }
2589
2590 pub fn project_root_identity(&self) -> DbResult<Option<CanonicalProjectRoot>> {
2597 project_identity::load_project_root_identity(&self.connection)
2598 }
2599
2600 #[must_use]
2608 pub fn project_root_identity_matches(&self, selected: &CanonicalProjectRoot) -> bool {
2609 self.validated_project_root_identity
2610 .as_ref()
2611 .is_some_and(|persisted| {
2612 project_identity::prove_existing_root_equivalence(
2613 selected.as_path(),
2614 persisted.as_path(),
2615 )
2616 .is_ok()
2617 })
2618 }
2619
2620 pub fn revalidate_captured_project_binding(&self) -> DbResult<()> {
2632 let binding = self.captured_project_binding()?;
2633 let Some(path) = self.database_path.as_deref() else {
2634 return schema::validate_active_native_binding(
2635 &self.connection,
2636 Some(&binding.project_root_identity),
2637 Some(binding.project_instance_id),
2638 );
2639 };
2640 let (connection, _) = schema::open_current_read_only(path, None)?;
2641 schema::validate_active_native_binding(
2642 &connection,
2643 Some(&binding.project_root_identity),
2644 Some(binding.project_instance_id),
2645 )
2646 }
2647
2648 fn purpose_curation_context(
2650 &self,
2651 connection: &Connection,
2652 ) -> DbResult<(ProjectInstanceId, IndexGeneration)> {
2653 let selected = self
2654 .validated_project_instance_id
2655 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
2656 let current = project_identity::load_project_identity(connection)?
2657 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
2658 if current != selected {
2659 return Err(DbError::GraphProjectIdentityMismatch {
2660 expected: selected.to_string(),
2661 found: current.to_string(),
2662 });
2663 }
2664 let generation =
2665 project_identity::load_graph_generation(connection)?.unwrap_or(IndexGeneration::ZERO);
2666 Ok((current, generation))
2667 }
2668
2669 pub fn begin_index_publication(
2679 &mut self,
2680 contract_fingerprint: &str,
2681 ) -> DbResult<IndexPublicationGuard<'_>> {
2682 let base_generation = self
2683 .index_publication()?
2684 .map_or(IndexGeneration::ZERO, |publication| publication.generation);
2685 self.begin_index_publication_from(contract_fingerprint, base_generation)
2686 }
2687
2688 pub fn begin_index_publication_from(
2700 &mut self,
2701 contract_fingerprint: &str,
2702 expected_base_generation: IndexGeneration,
2703 ) -> DbResult<IndexPublicationGuard<'_>> {
2704 self.begin_publication(
2705 PublicationContract::Full(contract_fingerprint.to_string()),
2706 Some(expected_base_generation),
2707 )
2708 }
2709
2710 pub fn probe_index_publication_writer(&mut self) -> DbResult<()> {
2722 begin_immediate_publication(&self.connection)?;
2723 self.connection.execute_batch("ROLLBACK")?;
2724 Ok(())
2725 }
2726
2727 pub fn begin_index_projection_refresh(
2735 &mut self,
2736 contract_fingerprint: &str,
2737 ) -> DbResult<IndexPublicationGuard<'_>> {
2738 let base_generation = self
2739 .index_publication()?
2740 .map_or(IndexGeneration::ZERO, |publication| publication.generation);
2741 self.begin_index_projection_refresh_from(contract_fingerprint, base_generation)
2742 }
2743
2744 pub fn begin_index_projection_refresh_from(
2753 &mut self,
2754 contract_fingerprint: &str,
2755 expected_base_generation: IndexGeneration,
2756 ) -> DbResult<IndexPublicationGuard<'_>> {
2757 self.begin_publication(
2758 PublicationContract::Projection(contract_fingerprint.to_string()),
2759 Some(expected_base_generation),
2760 )
2761 }
2762
2763 fn begin_publication(
2765 &mut self,
2766 contract: PublicationContract,
2767 expected_base_generation: Option<IndexGeneration>,
2768 ) -> DbResult<IndexPublicationGuard<'_>> {
2769 begin_immediate_publication(&self.connection)?;
2770 let setup = (|| {
2771 schema::validate_active_native_binding(
2772 &self.connection,
2773 self.validated_project_root_identity.as_ref(),
2774 self.validated_project_instance_id,
2775 )?;
2776 let previous = load_index_publication(&self.connection)?;
2777 if let Some(expected) = expected_base_generation {
2778 let base_matches = match previous.as_ref() {
2779 None => expected == IndexGeneration::ZERO,
2780 Some(publication) => {
2781 publication.state == IndexPublicationState::Complete
2782 && publication.generation != IndexGeneration::ZERO
2783 && publication.generation == expected
2784 }
2785 };
2786 if !base_matches {
2787 return Err(DbError::PublicationBaseGenerationChanged {
2788 expected,
2789 found: previous
2790 .as_ref()
2791 .map_or(IndexGeneration::ZERO, |publication| publication.generation),
2792 });
2793 }
2794 }
2795 if let PublicationContract::Projection(expected) = &contract {
2796 let matches = previous.as_ref().is_some_and(|publication| {
2797 publication.state == IndexPublicationState::Complete
2798 && publication.contract_fingerprint.as_deref() == Some(expected.as_str())
2799 });
2800 if !matches {
2801 return Err(DbError::PublicationContractChanged);
2802 }
2803 }
2804 set_metadata(
2805 &self.connection,
2806 INDEX_PUBLICATION_STATE_KEY,
2807 IndexPublicationState::Updating.as_str(),
2808 )?;
2809 Ok(previous.map_or(IndexGeneration::ZERO, |publication| publication.generation))
2810 })();
2811 let previous_generation = match setup {
2812 Ok(generation) => generation,
2813 Err(error) => {
2814 self.connection.execute_batch("ROLLBACK")?;
2815 return Err(error);
2816 }
2817 };
2818 Ok(IndexPublicationGuard {
2819 store: self,
2820 contract,
2821 previous_generation,
2822 scan_replacement_pending: false,
2823 active: true,
2824 })
2825 }
2826
2827 pub fn begin_index_read_snapshot(&self) -> DbResult<()> {
2835 if self.read_snapshot_active.replace(true) {
2836 return Err(DbError::IndexReadSnapshotActive);
2837 }
2838 if let Err(error) = self.connection.execute_batch("BEGIN DEFERRED") {
2839 self.read_snapshot_active.set(false);
2840 return Err(error.into());
2841 }
2842 Ok(())
2843 }
2844
2845 pub fn finish_index_read_snapshot(&self) -> DbResult<()> {
2854 if !self.read_snapshot_active.get() {
2855 return Ok(());
2856 }
2857 self.connection.execute_batch("COMMIT")?;
2858 self.read_snapshot_active.set(false);
2859 Ok(())
2860 }
2861
2862 pub fn index_publication(&self) -> DbResult<Option<IndexPublication>> {
2868 load_index_publication(&self.connection)
2869 }
2870
2871 pub fn replace_symbol_graph(&mut self, graph: &SymbolGraph) -> DbResult<()> {
2877 let metadata = SourceParseMetadata::from_graph(graph);
2878 self.replace_symbol_graph_with_metadata(graph, &metadata)
2879 }
2880
2881 pub fn replace_symbol_graph_with_metadata(
2890 &mut self,
2891 graph: &SymbolGraph,
2892 metadata: &SourceParseMetadata,
2893 ) -> DbResult<()> {
2894 if metadata.path != graph.path
2895 || metadata.language != graph.language
2896 || metadata.symbol_count != graph.symbols.len()
2897 || metadata.relation_count != graph.relations.len()
2898 {
2899 return Err(DbError::SymbolGraphRowShape {
2900 path: graph.path.clone(),
2901 reason: "source parse metadata identity or fact counts differ from the graph",
2902 });
2903 }
2904 let savepoint = self.validated_savepoint()?;
2905 let node_id = {
2906 let mut delete_symbols =
2907 savepoint.prepare_cached("DELETE FROM symbols WHERE path = ?1")?;
2908 let mut delete_relations =
2909 savepoint.prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?;
2910 delete_symbols.execute([&graph.path])?;
2911 delete_relations.execute([&graph.path])?;
2912
2913 let mut upsert_metadata = savepoint.prepare_cached(
2914 "
2915 INSERT INTO source_parse_metadata(
2916 path,
2917 language,
2918 source_parser,
2919 fact_parser,
2920 symbol_count,
2921 relation_count,
2922 updated_at
2923 )
2924 VALUES(?1, ?2, ?3, ?4, ?5, ?6, CURRENT_TIMESTAMP)
2925 ON CONFLICT(path) DO UPDATE SET
2926 language = excluded.language,
2927 source_parser = excluded.source_parser,
2928 fact_parser = excluded.fact_parser,
2929 symbol_count = excluded.symbol_count,
2930 relation_count = excluded.relation_count,
2931 updated_at = CURRENT_TIMESTAMP
2932 ",
2933 )?;
2934 upsert_metadata.execute(params![
2935 metadata.path,
2936 metadata.language.as_deref(),
2937 metadata.parser.to_string(),
2938 graph.parser.to_string(),
2939 usize_to_i64(metadata.symbol_count),
2940 usize_to_i64(metadata.relation_count),
2941 ])?;
2942 let mut select_node = savepoint
2943 .prepare_cached("SELECT id FROM nodes WHERE path = ?1 AND exists_now = 1")?;
2944 let node_id = select_node
2945 .query_row([&graph.path], |row| row.get::<_, i64>(0))
2946 .optional()?;
2947
2948 let mut insert_symbol = savepoint.prepare_cached(
2949 "
2950 INSERT INTO symbols(
2951 path,
2952 language,
2953 name,
2954 kind,
2955 signature,
2956 exported,
2957 documentation,
2958 line_start,
2959 line_end,
2960 source_byte_start,
2961 source_byte_end,
2962 source_column_start,
2963 source_column_end,
2964 parent,
2965 parser,
2966 detail
2967 )
2968 VALUES(
2969 ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
2970 ?13, ?14, ?15, ?16
2971 )
2972 ",
2973 )?;
2974 for symbol in &graph.symbols {
2975 let selector = symbol_source_selector_values(symbol)?;
2976 insert_symbol.execute(params![
2977 symbol.path,
2978 symbol.language.as_deref(),
2979 symbol.name,
2980 symbol.kind.to_string(),
2981 symbol.signature,
2982 symbol.exported,
2983 symbol.documentation.as_deref(),
2984 usize_to_i64(symbol.line_start),
2985 usize_to_i64(symbol.line_end),
2986 selector[0],
2987 selector[1],
2988 selector[2],
2989 selector[3],
2990 symbol.parent.as_deref(),
2991 symbol.parser.to_string(),
2992 symbol.detail.as_deref(),
2993 ])?;
2994 }
2995
2996 let mut insert_relation = savepoint.prepare_cached(
2997 "
2998 INSERT INTO symbol_relations(
2999 path,
3000 source_name,
3001 target_name,
3002 kind,
3003 line,
3004 context,
3005 parser
3006 )
3007 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)
3008 ",
3009 )?;
3010 for relation in &graph.relations {
3011 insert_relation.execute(params![
3012 relation.path,
3013 relation.source_name,
3014 relation.target_name,
3015 relation.kind.to_string(),
3016 usize_to_i64(relation.line),
3017 relation.context,
3018 relation.parser.to_string(),
3019 ])?;
3020 }
3021 node_id
3022 };
3023 if let Some(node_id) = node_id {
3024 replace_symbol_search_summary(
3025 &savepoint,
3026 node_id,
3027 symbol_search_summary(graph).as_deref(),
3028 )?;
3029 }
3030 savepoint.commit()?;
3031 Ok(())
3032 }
3033
3034 pub fn clear_source_index_for_path(&self, path: &str) -> DbResult<()> {
3043 self.with_validated_write(|connection| {
3044 let node_id = self.node_id_for_path(path)?;
3045 connection
3046 .prepare_cached("DELETE FROM symbols WHERE path = ?1")?
3047 .execute([path])?;
3048 connection
3049 .prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?
3050 .execute([path])?;
3051 connection
3052 .prepare_cached("DELETE FROM source_parse_metadata WHERE path = ?1")?
3053 .execute([path])?;
3054 connection
3055 .prepare_cached(
3056 "
3057 DELETE FROM summaries
3058 WHERE node_id = ?1
3059 AND (
3060 (summary_level = 'node' AND subject = '')
3061 OR (summary_level = 'search' AND subject = 'symbols')
3062 )
3063 ",
3064 )?
3065 .execute([node_id])?;
3066 Ok(())
3067 })
3068 }
3069
3070 pub fn clear_symbol_graph_for_path(&self, path: &str) -> DbResult<()> {
3076 self.with_validated_write(|connection| {
3077 let node_id = self.node_id_for_path(path)?;
3078 connection
3079 .prepare_cached("DELETE FROM symbols WHERE path = ?1")?
3080 .execute([path])?;
3081 connection
3082 .prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?
3083 .execute([path])?;
3084 connection
3085 .prepare_cached("DELETE FROM source_parse_metadata WHERE path = ?1")?
3086 .execute([path])?;
3087 connection
3088 .prepare_cached(
3089 "
3090 DELETE FROM summaries
3091 WHERE node_id = ?1
3092 AND summary_level = 'search'
3093 AND subject = 'symbols'
3094 ",
3095 )?
3096 .execute([node_id])?;
3097 Ok(())
3098 })
3099 }
3100
3101 pub fn set_node_summary(&self, path: &str, summary: &str) -> DbResult<()> {
3107 self.with_validated_write(|connection| {
3108 let node_id = self.node_id_for_path(path)?;
3109 connection
3110 .prepare_cached(
3111 "
3112 INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
3113 VALUES(?1, 'node', '', ?2, CURRENT_TIMESTAMP)
3114 ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
3115 summary_level = 'node',
3116 subject = '',
3117 summary = excluded.summary,
3118 updated_at = CURRENT_TIMESTAMP
3119 ",
3120 )?
3121 .execute(params![node_id, summary])?;
3122 Ok(())
3123 })
3124 }
3125
3126 pub fn clear_node_summary(&self, path: &str) -> DbResult<()> {
3132 self.with_validated_write(|connection| {
3133 let node_id = self.node_id_for_path(path)?;
3134 connection
3135 .prepare_cached(
3136 "
3137 DELETE FROM summaries
3138 WHERE node_id = ?1
3139 AND summary_level = 'node'
3140 AND subject = ''
3141 ",
3142 )?
3143 .execute([node_id])?;
3144 Ok(())
3145 })
3146 }
3147
3148 pub fn load_symbols(
3154 &self,
3155 file: Option<&str>,
3156 query: Option<&str>,
3157 limit: usize,
3158 ) -> DbResult<Vec<CodeSymbol>> {
3159 let max_rows = usize_to_i64(limit.max(1));
3160 match (file, query) {
3161 (Some(file), Some(query)) => self.query_symbols(
3162 "
3163 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3164 source_byte_start, source_byte_end, source_column_start, source_column_end
3165 FROM symbols
3166 WHERE path = ?1 AND (name LIKE ?2 OR signature LIKE ?2 OR documentation LIKE ?2)
3167 ORDER BY path, line_start, name
3168 LIMIT ?3
3169 ",
3170 params![file, like_query(query), max_rows],
3171 ),
3172 (Some(file), None) => self.query_symbols(
3173 "
3174 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3175 source_byte_start, source_byte_end, source_column_start, source_column_end
3176 FROM symbols
3177 WHERE path = ?1
3178 ORDER BY path, line_start, name
3179 LIMIT ?2
3180 ",
3181 params![file, max_rows],
3182 ),
3183 (None, Some(query)) => self.query_symbols(
3184 "
3185 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3186 source_byte_start, source_byte_end, source_column_start, source_column_end
3187 FROM symbols
3188 WHERE name LIKE ?1 OR signature LIKE ?1 OR documentation LIKE ?1 OR path LIKE ?1
3189 ORDER BY path, line_start, name
3190 LIMIT ?2
3191 ",
3192 params![like_query(query), max_rows],
3193 ),
3194 (None, None) => self.query_symbols(
3195 "
3196 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3197 source_byte_start, source_byte_end, source_column_start, source_column_end
3198 FROM symbols
3199 ORDER BY path, line_start, name
3200 LIMIT ?1
3201 ",
3202 params![max_rows],
3203 ),
3204 }
3205 }
3206
3207 pub fn load_classified_symbols(
3216 &self,
3217 file: Option<&str>,
3218 query: Option<&str>,
3219 selection: ContentSelection,
3220 limit: usize,
3221 ) -> DbResult<Vec<ClassifiedSymbol>> {
3222 let (sql, bindings) = classified_symbols_sql(file, query, selection, limit);
3223 let mut statement = self.connection.prepare(&sql)?;
3224 let mut rows = statement.query(params_from_iter(bindings.iter()))?;
3225 let mut symbols = Vec::new();
3226 while let Some(row) = rows.next()? {
3227 symbols.push(classified_symbol_from_row(row)?);
3228 }
3229 Ok(symbols)
3230 }
3231
3232 pub fn load_symbols_for_paths_bounded(
3244 &self,
3245 paths: &[String],
3246 budget: SymbolBatchReadBudget,
3247 control: Option<&IndexWorkControl>,
3248 ) -> DbResult<SymbolBatchRead> {
3249 let path_limit =
3250 usize::try_from(budget.paths()).map_err(|source| DbError::InvalidCount {
3251 field: "symbol batch paths",
3252 value: i64::from(budget.paths()),
3253 source,
3254 })?;
3255 let mut exact_paths = BTreeSet::new();
3256 let mut reached_limit = None;
3257 for path in paths {
3258 if let Some(control) = control {
3259 control.check(IndexWorkStage::RepositoryTraversal)?;
3260 }
3261 exact_paths.insert(path.clone());
3262 if exact_paths.len() > path_limit {
3263 exact_paths.pop_last();
3264 reached_limit.get_or_insert(SymbolBatchReadLimit::Paths);
3265 }
3266 }
3267 if exact_paths.is_empty() {
3268 return Ok(SymbolBatchRead::default());
3269 }
3270 let exact_paths = exact_paths.into_iter().collect::<Vec<_>>();
3271 let row_limit = usize::try_from(budget.rows()).map_err(|source| DbError::InvalidCount {
3272 field: "symbol batch rows",
3273 value: i64::from(budget.rows()),
3274 source,
3275 })?;
3276 let mut symbols = Vec::new();
3277 let mut decoded_bytes = 0_u64;
3278 'chunks: for chunk in exact_paths.chunks(SYMBOL_BATCH_BIND_PATHS) {
3279 if let Some(control) = control {
3280 control.check(IndexWorkStage::RepositoryTraversal)?;
3281 }
3282 let remaining_rows = row_limit.saturating_sub(symbols.len());
3283 if remaining_rows == 0 {
3284 reached_limit.get_or_insert(SymbolBatchReadLimit::Rows);
3285 break;
3286 }
3287 let placeholders = numbered_placeholders(1, chunk.len());
3288 let limit_parameter = chunk.len().saturating_add(1);
3289 let sql = format!(
3290 "
3291 SELECT
3292 path,
3293 language,
3294 name,
3295 kind,
3296 signature,
3297 line_start,
3298 line_end,
3299 parent,
3300 parser,
3301 detail,
3302 exported,
3303 documentation,
3304 source_byte_start,
3305 source_byte_end,
3306 source_column_start,
3307 source_column_end,
3308 length(CAST(path AS BLOB))
3309 + COALESCE(length(CAST(language AS BLOB)), 0)
3310 + length(CAST(name AS BLOB))
3311 + length(CAST(signature AS BLOB))
3312 + COALESCE(length(CAST(parent AS BLOB)), 0)
3313 + COALESCE(length(CAST(detail AS BLOB)), 0)
3314 + COALESCE(length(CAST(documentation AS BLOB)), 0)
3315 FROM symbols
3316 WHERE path IN ({placeholders})
3317 ORDER BY path, line_start, name
3318 LIMIT ?{limit_parameter}
3319 "
3320 );
3321 let mut bindings = chunk.iter().cloned().map(Value::Text).collect::<Vec<_>>();
3322 bindings.push(Value::Integer(usize_to_i64(
3323 remaining_rows.saturating_add(1),
3324 )));
3325 with_sqlite_read_progress(
3326 &self.connection,
3327 control,
3328 IndexWorkStage::RepositoryTraversal,
3329 || {
3330 let mut statement = self.connection.prepare(&sql)?;
3331 let mut rows = statement.query(params_from_iter(bindings.iter()))?;
3332 while let Some(row) = rows.next()? {
3333 if let Some(control) = control {
3334 control.check(IndexWorkStage::RepositoryTraversal)?;
3335 }
3336 if symbols.len() >= row_limit {
3337 reached_limit.get_or_insert(SymbolBatchReadLimit::Rows);
3338 break;
3339 }
3340 let preflight_bytes = code_symbol_preflight_bytes(row)?;
3341 if decoded_bytes.saturating_add(preflight_bytes) > budget.decoded_bytes() {
3342 reached_limit.get_or_insert(SymbolBatchReadLimit::DecodedBytes);
3343 break;
3344 }
3345 let symbol = code_symbol_from_row(row)?;
3346 let row_bytes = code_symbol_decoded_bytes(&symbol)?;
3347 if decoded_bytes.saturating_add(row_bytes) > budget.decoded_bytes() {
3348 reached_limit.get_or_insert(SymbolBatchReadLimit::DecodedBytes);
3349 break;
3350 }
3351 decoded_bytes = decoded_bytes.saturating_add(row_bytes);
3352 symbols.push(symbol);
3353 }
3354 Ok(())
3355 },
3356 )?;
3357 if matches!(
3358 reached_limit,
3359 Some(SymbolBatchReadLimit::Rows | SymbolBatchReadLimit::DecodedBytes)
3360 ) {
3361 break 'chunks;
3362 }
3363 }
3364 symbols.sort_by(|left, right| {
3365 (&left.path, left.line_start, &left.name, &left.signature).cmp(&(
3366 &right.path,
3367 right.line_start,
3368 &right.name,
3369 &right.signature,
3370 ))
3371 });
3372 let symbols = symbols.into_boxed_slice().into_vec();
3373 Ok(SymbolBatchRead {
3374 work: SymbolBatchReadWork {
3375 requested_paths: u32::try_from(exact_paths.len()).unwrap_or(u32::MAX),
3376 returned_rows: u32::try_from(symbols.len()).unwrap_or(u32::MAX),
3377 decoded_bytes,
3378 },
3379 rows: symbols,
3380 truncated: reached_limit.is_some(),
3381 reached_limit,
3382 })
3383 }
3384
3385 pub fn load_symbols_by_kinds(
3391 &self,
3392 file: &str,
3393 kinds: &[SymbolKind],
3394 limit: usize,
3395 ) -> DbResult<Vec<CodeSymbol>> {
3396 if kinds.is_empty() {
3397 return Ok(Vec::new());
3398 }
3399 let max_rows = usize_to_i64(limit.max(1));
3400 let placeholders = numbered_placeholders(2, kinds.len());
3401 let sql = format!(
3402 "
3403 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3404 source_byte_start, source_byte_end, source_column_start, source_column_end
3405 FROM symbols INDEXED BY idx_symbols_path
3406 WHERE path = ?1 AND kind IN ({placeholders})
3407 ORDER BY path, line_start, name
3408 LIMIT {max_rows}
3409 "
3410 );
3411 let mut values = Vec::with_capacity(kinds.len() + 1);
3412 values.push(file.to_string());
3413 values.extend(kinds.iter().map(ToString::to_string));
3414 self.query_symbols(&sql, params_from_iter(values.iter()))
3415 }
3416
3417 pub fn count_symbols_by_kinds(&self, file: &str, kinds: &[SymbolKind]) -> DbResult<usize> {
3423 if kinds.is_empty() {
3424 return Ok(0);
3425 }
3426 let placeholders = numbered_placeholders(2, kinds.len());
3427 let sql = format!(
3428 "SELECT COUNT(*) FROM symbols INDEXED BY idx_symbols_path
3429 WHERE path = ?1 AND kind IN ({placeholders})"
3430 );
3431 let mut values = Vec::with_capacity(kinds.len() + 1);
3432 values.push(file.to_string());
3433 values.extend(kinds.iter().map(ToString::to_string));
3434 let count = self
3435 .connection
3436 .query_row(&sql, params_from_iter(values.iter()), |row| {
3437 row.get::<_, i64>(0)
3438 })?;
3439 Ok(i64_to_usize(count))
3440 }
3441
3442 pub fn symbol_name_counts(&self, names: &[String]) -> DbResult<HashMap<String, usize>> {
3448 if names.is_empty() {
3449 return Ok(HashMap::new());
3450 }
3451 let placeholders = numbered_placeholders(1, names.len());
3452 let sql = format!(
3453 "SELECT name, COUNT(*) FROM symbols WHERE name IN ({placeholders}) GROUP BY name"
3454 );
3455 let mut statement = self.connection.prepare(&sql)?;
3456 let rows = statement.query_map(params_from_iter(names.iter()), |row| {
3457 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
3458 })?;
3459 let mut counts = HashMap::new();
3460 for row in rows {
3461 let (name, count) = row?;
3462 counts.insert(name, i64_to_usize(count));
3463 }
3464 Ok(counts)
3465 }
3466
3467 pub fn load_symbols_by_names(&self, names: &[String]) -> DbResult<Vec<CodeSymbol>> {
3473 if names.is_empty() {
3474 return Ok(Vec::new());
3475 }
3476 let placeholders = numbered_placeholders(1, names.len());
3477 let sql = format!(
3478 "
3479 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3480 source_byte_start, source_byte_end, source_column_start, source_column_end
3481 FROM symbols
3482 WHERE name IN ({placeholders})
3483 ORDER BY path, line_start, name
3484 "
3485 );
3486 self.query_symbols(&sql, params_from_iter(names.iter()))
3487 }
3488
3489 pub fn load_exported_symbol_names_for_path(
3495 &self,
3496 file: &str,
3497 limit: usize,
3498 ) -> DbResult<Vec<String>> {
3499 let max_rows = usize_to_i64(limit.max(1));
3500 let mut statement = self.connection.prepare(
3501 "
3502 SELECT DISTINCT name
3503 FROM symbols
3504 WHERE path = ?1 AND exported = 1
3505 ORDER BY name
3506 LIMIT ?2
3507 ",
3508 )?;
3509 let rows = statement.query_map(params![file, max_rows], |row| row.get::<_, String>(0))?;
3510 let mut names = Vec::new();
3511 for row in rows {
3512 names.push(row?);
3513 }
3514 Ok(names)
3515 }
3516
3517 pub fn exported_symbol_count_for_path(&self, file: &str) -> DbResult<usize> {
3523 let count = self.connection.query_row(
3524 "SELECT COUNT(DISTINCT name) FROM symbols WHERE path = ?1 AND exported = 1",
3525 [file],
3526 |row| row.get::<_, i64>(0),
3527 )?;
3528 Ok(i64_to_usize(count))
3529 }
3530
3531 pub fn load_symbol_by_name(&self, file: &str, name: &str) -> DbResult<Option<CodeSymbol>> {
3537 let mut symbols = self.load_symbols(Some(file), Some(name), 100)?;
3538 symbols.retain(|symbol| symbol.name == name);
3539 Ok(symbols.into_iter().next())
3540 }
3541
3542 pub fn load_symbols_by_exact_file_and_name(
3548 &self,
3549 file: &str,
3550 name: &str,
3551 ) -> DbResult<Vec<CodeSymbol>> {
3552 self.query_symbols(
3553 "
3554 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3555 source_byte_start, source_byte_end, source_column_start, source_column_end
3556 FROM symbols
3557 WHERE path = ?1 AND name = ?2
3558 ORDER BY line_start, line_end, kind, parent
3559 ",
3560 params![file, name],
3561 )
3562 }
3563
3564 pub fn load_node_by_path(&self, path: &str) -> DbResult<Option<IndexedNode>> {
3570 let mut statement = self.connection.prepare(
3571 "
3572 SELECT
3573 n.path,
3574 n.kind,
3575 n.parent_path,
3576 n.extension,
3577 n.language,
3578 n.size_bytes,
3579 n.mtime_ns,
3580 n.content_hash,
3581 p.purpose,
3582 p.source,
3583 p.status,
3584 s.summary
3585 FROM nodes n
3586 JOIN purposes p ON p.node_id = n.id
3587 LEFT JOIN summaries s ON s.node_id = n.id
3588 AND s.summary_level = 'node'
3589 AND s.subject = ''
3590 WHERE n.exists_now = 1 AND n.path = ?1
3591 ",
3592 )?;
3593 let row = statement
3594 .query_row([path], |row| {
3595 let kind_value: String = row.get(1)?;
3596 let source_value: String = row.get(9)?;
3597 let status_value: String = row.get(10)?;
3598 Ok((
3599 row.get::<_, String>(0)?,
3600 kind_value,
3601 row.get::<_, Option<String>>(2)?,
3602 row.get::<_, Option<String>>(3)?,
3603 row.get::<_, Option<String>>(4)?,
3604 option_u64_from_sql(row, 5)?,
3605 row.get::<_, Option<i64>>(6)?,
3606 row.get::<_, Option<String>>(7)?,
3607 row.get::<_, Option<String>>(8)?,
3608 source_value,
3609 status_value,
3610 row.get::<_, Option<String>>(11)?,
3611 ))
3612 })
3613 .optional()?;
3614 row.map(indexed_node_from_parts).transpose()
3615 }
3616
3617 pub fn load_nodes_by_paths(&self, paths: &[String]) -> DbResult<Vec<IndexedNode>> {
3623 self.load_nodes_by_paths_controlled(paths, None)
3624 }
3625
3626 pub fn load_nodes_by_paths_controlled(
3632 &self,
3633 paths: &[String],
3634 control: Option<&IndexWorkControl>,
3635 ) -> DbResult<Vec<IndexedNode>> {
3636 let mut unique_paths = paths.to_vec();
3637 unique_paths.sort();
3638 unique_paths.dedup();
3639 if unique_paths.is_empty() {
3640 return Ok(Vec::new());
3641 }
3642 let mut nodes = Vec::new();
3643 for chunk in unique_paths.chunks(MAX_PURPOSE_CURATION_BATCH_ROWS) {
3644 let hydrated = with_sqlite_read_progress(
3645 &self.connection,
3646 control,
3647 IndexWorkStage::RepositoryTraversal,
3648 || {
3649 let sql = load_nodes_by_paths_sql(chunk.len());
3650 let mut statement = self.connection.prepare(&sql)?;
3651 let rows = statement.query_map(params_from_iter(chunk), |row| {
3652 let kind_value: String = row.get(1)?;
3653 let source_value: String = row.get(9)?;
3654 let status_value: String = row.get(10)?;
3655 Ok((
3656 row.get::<_, String>(0)?,
3657 kind_value,
3658 row.get::<_, Option<String>>(2)?,
3659 row.get::<_, Option<String>>(3)?,
3660 row.get::<_, Option<String>>(4)?,
3661 option_u64_from_sql(row, 5)?,
3662 row.get::<_, Option<i64>>(6)?,
3663 row.get::<_, Option<String>>(7)?,
3664 row.get::<_, Option<String>>(8)?,
3665 source_value,
3666 status_value,
3667 row.get::<_, Option<String>>(11)?,
3668 ))
3669 })?;
3670 let mut selected = Vec::new();
3671 for row in rows {
3672 selected.push(indexed_node_from_parts(row?)?);
3673 }
3674 Ok(selected)
3675 },
3676 )?;
3677 nodes.extend(hydrated);
3678 }
3679 Ok(nodes)
3680 }
3681
3682 pub fn load_purpose_owner_nodes_by_paths_controlled(
3697 &self,
3698 project: ProjectInstanceId,
3699 generation: IndexGeneration,
3700 paths: &[String],
3701 budget: RepositoryGraphReadBudget,
3702 control: Option<&IndexWorkControl>,
3703 ) -> DbResult<RepositoryGraphReadBatch<IndexedNode>> {
3704 if paths
3705 .iter()
3706 .map(String::as_str)
3707 .collect::<HashSet<_>>()
3708 .len()
3709 != paths.len()
3710 {
3711 return Err(GraphContractError::InvalidLimits {
3712 reason: "purpose-owner hydration paths must be unique",
3713 }
3714 .into());
3715 }
3716 self.require_repository_graph_snapshot(project, generation)?;
3717 let mut meter = repository_graph::RepositoryGraphReadMeter::new(budget, paths.len())?;
3718 let path_count =
3719 u32::try_from(paths.len()).map_err(|_source| GraphContractError::InvalidLimits {
3720 reason: "purpose-owner hydration path count overflowed",
3721 })?;
3722 if path_count > budget.returned_rows() {
3723 return Err(GraphContractError::InvalidLimits {
3724 reason: "purpose-owner paths exceed the return budget",
3725 }
3726 .into());
3727 }
3728 if path_count > budget.hydrated_paths() {
3729 return Err(GraphContractError::InvalidLimits {
3730 reason: "purpose-owner paths exceed the hydration budget",
3731 }
3732 .into());
3733 }
3734 if paths.is_empty() {
3735 return Ok(RepositoryGraphReadBatch {
3736 rows: Vec::new(),
3737 work: meter.finish(0)?,
3738 });
3739 }
3740
3741 let mut selected = HashMap::with_capacity(paths.len());
3742 for chunk in paths.chunks(MAX_PURPOSE_CURATION_BATCH_ROWS) {
3743 let hydrated = with_sqlite_read_progress(
3744 &self.connection,
3745 control,
3746 IndexWorkStage::RepositoryTraversal,
3747 || {
3748 let sql = load_nodes_by_paths_sql(chunk.len());
3749 let mut statement = self.connection.prepare(&sql)?;
3750 let mut rows = statement.query(params_from_iter(chunk))?;
3751 let mut nodes = Vec::new();
3752 while let Some(row) = rows.next()? {
3753 let parts = indexed_node_parts_from_sql_row(row)?;
3754 meter.record_decoded_bytes(indexed_node_parts_decoded_bytes(&parts)?)?;
3755 let node = indexed_node_from_parts(parts)?;
3756 meter.record_hydrated_path(&node.node.path)?;
3757 nodes.push(node);
3758 }
3759 Ok(nodes)
3760 },
3761 )?;
3762 for node in hydrated {
3763 let path = node.node.path.clone();
3764 if selected.insert(path, node).is_some() {
3765 return Err(DbError::GraphRowShape {
3766 table: "nodes",
3767 reason: "purpose-owner hydration returned a duplicate path",
3768 });
3769 }
3770 }
3771 }
3772
3773 let mut ordered = Vec::with_capacity(paths.len());
3774 for path in paths {
3775 if let Some(node) = selected.remove(path) {
3776 ordered.push(node);
3777 }
3778 }
3779 let work = meter.finish(ordered.len())?;
3780 Ok(RepositoryGraphReadBatch {
3781 rows: ordered,
3782 work,
3783 })
3784 }
3785
3786 pub fn load_purpose_curation_batch(
3796 &self,
3797 task: &str,
3798 paths: &[String],
3799 ) -> DbResult<PurposeCurationBatch> {
3800 let task = normalize_purpose_curation_task(task)?;
3801 let mut unique_paths = paths.to_vec();
3802 unique_paths.sort();
3803 unique_paths.dedup();
3804 if unique_paths.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
3805 return Err(DbError::PurposeCurationBatchTooLarge {
3806 requested: unique_paths.len(),
3807 maximum: MAX_PURPOSE_CURATION_BATCH_ROWS,
3808 });
3809 }
3810 let (project_instance_id, active_generation) =
3811 self.purpose_curation_context(&self.connection)?;
3812 let items = self
3813 .load_nodes_by_paths(&unique_paths)?
3814 .into_iter()
3815 .filter(|node| {
3816 matches!(
3817 node.purpose.status,
3818 PurposeStatus::Missing | PurposeStatus::Suggested
3819 )
3820 })
3821 .map(|node| {
3822 purpose_curation_candidate(project_instance_id, active_generation, &task, node)
3823 })
3824 .collect::<Vec<_>>();
3825 let work_key =
3826 purpose_curation_batch_work_key(project_instance_id, active_generation, &task, &items);
3827 Ok(PurposeCurationBatch {
3828 project_instance_id,
3829 active_generation,
3830 task,
3831 work_key,
3832 items,
3833 })
3834 }
3835
3836 pub fn load_symbol_relations(
3842 &self,
3843 file: Option<&str>,
3844 query: Option<&str>,
3845 limit: usize,
3846 ) -> DbResult<Vec<SymbolRelation>> {
3847 let max_rows = usize_to_i64(limit.max(1));
3848 match (file, query) {
3849 (Some(file), Some(query)) => self.query_relations(
3850 "
3851 SELECT path, source_name, target_name, kind, line, context, parser
3852 FROM symbol_relations
3853 WHERE path = ?1 AND (source_name LIKE ?2 OR target_name LIKE ?2 OR context LIKE ?2)
3854 ORDER BY path, line, source_name, target_name
3855 LIMIT ?3
3856 ",
3857 params![file, like_query(query), max_rows],
3858 ),
3859 (Some(file), None) => self.query_relations(
3860 "
3861 SELECT path, source_name, target_name, kind, line, context, parser
3862 FROM symbol_relations
3863 WHERE path = ?1
3864 ORDER BY path, line, source_name, target_name
3865 LIMIT ?2
3866 ",
3867 params![file, max_rows],
3868 ),
3869 (None, Some(query)) => self.query_relations(
3870 "
3871 SELECT path, source_name, target_name, kind, line, context, parser
3872 FROM symbol_relations
3873 WHERE source_name LIKE ?1 OR target_name LIKE ?1 OR context LIKE ?1 OR path LIKE ?1
3874 ORDER BY path, line, source_name, target_name
3875 LIMIT ?2
3876 ",
3877 params![like_query(query), max_rows],
3878 ),
3879 (None, None) => self.query_relations(
3880 "
3881 SELECT path, source_name, target_name, kind, line, context, parser
3882 FROM symbol_relations
3883 ORDER BY path, line, source_name, target_name
3884 LIMIT ?1
3885 ",
3886 params![max_rows],
3887 ),
3888 }
3889 }
3890
3891 pub fn load_symbol_relations_by_kind(
3897 &self,
3898 file: &str,
3899 kind: RelationKind,
3900 limit: usize,
3901 ) -> DbResult<Vec<SymbolRelation>> {
3902 let max_rows = usize_to_i64(limit.max(1));
3903 self.query_relations(
3904 "
3905 SELECT path, source_name, target_name, kind, line, context, parser
3906 FROM symbol_relations
3907 WHERE path = ?1 AND kind = ?2
3908 ORDER BY path, line, source_name, target_name
3909 LIMIT ?3
3910 ",
3911 params![file, kind.to_string(), max_rows],
3912 )
3913 }
3914
3915 pub fn count_symbol_relations_by_kind(
3921 &self,
3922 file: &str,
3923 kind: RelationKind,
3924 ) -> DbResult<usize> {
3925 let count = self.connection.query_row(
3926 "SELECT COUNT(*) FROM symbol_relations WHERE path = ?1 AND kind = ?2",
3927 params![file, kind.to_string()],
3928 |row| row.get::<_, i64>(0),
3929 )?;
3930 Ok(i64_to_usize(count))
3931 }
3932
3933 pub fn load_distinct_relation_targets_by_kind(
3939 &self,
3940 file: &str,
3941 kind: RelationKind,
3942 limit: usize,
3943 ) -> DbResult<Vec<String>> {
3944 let max_rows = usize_to_i64(limit.max(1));
3945 let mut statement = self.connection.prepare(
3946 "
3947 SELECT DISTINCT target_name
3948 FROM symbol_relations
3949 WHERE path = ?1 AND kind = ?2
3950 ORDER BY target_name
3951 LIMIT ?3
3952 ",
3953 )?;
3954 let rows = statement.query_map(params![file, kind.to_string(), max_rows], |row| {
3955 row.get::<_, String>(0)
3956 })?;
3957 let mut targets = Vec::new();
3958 for row in rows {
3959 targets.push(row?);
3960 }
3961 Ok(targets)
3962 }
3963
3964 pub fn count_distinct_relation_targets_by_kind(
3970 &self,
3971 file: &str,
3972 kind: RelationKind,
3973 ) -> DbResult<usize> {
3974 let count = self.connection.query_row(
3975 "SELECT COUNT(DISTINCT target_name) FROM symbol_relations WHERE path = ?1 AND kind = ?2",
3976 params![file, kind.to_string()],
3977 |row| row.get::<_, i64>(0),
3978 )?;
3979 Ok(i64_to_usize(count))
3980 }
3981
3982 pub fn load_call_relations_to_targets(
3988 &self,
3989 target_names: &[String],
3990 limit_per_target: usize,
3991 ) -> DbResult<Vec<SymbolRelation>> {
3992 if target_names.is_empty() {
3993 return Ok(Vec::new());
3994 }
3995 let placeholders = numbered_placeholders(1, target_names.len());
3996 let limit_placeholder = target_names.len() + 1;
3997 let sql = format!(
3998 "
3999 SELECT path, source_name, target_name, kind, line, context, parser
4000 FROM (
4001 SELECT
4002 path,
4003 source_name,
4004 target_name,
4005 kind,
4006 line,
4007 context,
4008 parser,
4009 ROW_NUMBER() OVER (
4010 PARTITION BY target_name
4011 ORDER BY path, line, source_name, target_name
4012 ) AS target_row
4013 FROM symbol_relations INDEXED BY idx_symbol_relations_target
4014 WHERE kind = 'calls' AND target_name IN ({placeholders})
4015 )
4016 WHERE target_row <= ?{limit_placeholder}
4017 ORDER BY path, line, source_name, target_name
4018 "
4019 );
4020 let mut values = target_names
4021 .iter()
4022 .map(|target| Value::Text(target.clone()))
4023 .collect::<Vec<_>>();
4024 values.push(Value::Integer(usize_to_i64(limit_per_target.max(1))));
4025 let mut relations = self.query_relations(&sql, params_from_iter(values.iter()))?;
4026 relations.sort_by(|left, right| {
4027 left.path
4028 .cmp(&right.path)
4029 .then_with(|| left.line.cmp(&right.line))
4030 .then_with(|| left.source_name.cmp(&right.source_name))
4031 .then_with(|| left.target_name.cmp(&right.target_name))
4032 });
4033 relations.dedup_by(|left, right| {
4034 left.path == right.path
4035 && left.source_name == right.source_name
4036 && left.target_name == right.target_name
4037 && left.kind == right.kind
4038 && left.line == right.line
4039 });
4040 Ok(relations)
4041 }
4042
4043 pub fn load_import_relations_matching_targets(
4049 &self,
4050 terms: &[String],
4051 limit_per_term: usize,
4052 ) -> DbResult<Vec<StoredImportRelation>> {
4053 let mut unique_terms = terms.to_vec();
4054 unique_terms.sort();
4055 unique_terms.dedup();
4056 let mut relations = Vec::new();
4057 for term in unique_terms.iter().filter(|term| !term.trim().is_empty()) {
4058 let mut statement = self.connection.prepare_cached(
4059 "
4060 SELECT path, source_name, target_name, line
4061 FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
4062 WHERE kind = 'imports' AND target_name LIKE ?1 ESCAPE '\\'
4063 ORDER BY path, line, source_name, target_name
4064 LIMIT ?2
4065 ",
4066 )?;
4067 let rows = statement.query_map(
4068 params![
4069 sqlite_like_pattern(term),
4070 usize_to_i64(limit_per_term.max(1))
4071 ],
4072 stored_import_relation_from_row,
4073 )?;
4074 for row in rows {
4075 relations.push(row?);
4076 }
4077 }
4078 relations.sort_by(|left, right| {
4079 left.path
4080 .cmp(&right.path)
4081 .then_with(|| left.line.cmp(&right.line))
4082 .then_with(|| left.source_name.cmp(&right.source_name))
4083 .then_with(|| left.target_name.cmp(&right.target_name))
4084 });
4085 relations.dedup_by(|left, right| {
4086 left.path == right.path
4087 && left.source_name == right.source_name
4088 && left.target_name == right.target_name
4089 && left.line == right.line
4090 });
4091 Ok(relations)
4092 }
4093
4094 pub fn load_import_relations_for_path(
4100 &self,
4101 path: &str,
4102 limit: usize,
4103 ) -> DbResult<Vec<StoredImportRelation>> {
4104 let mut statement = self.connection.prepare_cached(
4105 "
4106 SELECT path, source_name, target_name, line
4107 FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
4108 WHERE kind = 'imports' AND path = ?1
4109 ORDER BY path, line, source_name, target_name
4110 LIMIT ?2
4111 ",
4112 )?;
4113 let rows = statement.query_map(
4114 params![path, usize_to_i64(limit.max(1))],
4115 stored_import_relation_from_row,
4116 )?;
4117 let mut relations = Vec::new();
4118 for row in rows {
4119 relations.push(row?);
4120 }
4121 Ok(relations)
4122 }
4123
4124 pub fn symbol_count(&self) -> DbResult<usize> {
4130 let count = self
4131 .connection
4132 .query_row("SELECT COUNT(*) FROM symbols", [], |row| {
4133 row.get::<_, i64>(0)
4134 })?;
4135 Ok(i64_to_usize(count))
4136 }
4137
4138 pub fn symbol_relation_count(&self) -> DbResult<usize> {
4144 let count =
4145 self.connection
4146 .query_row("SELECT COUNT(*) FROM symbol_relations", [], |row| {
4147 row.get::<_, i64>(0)
4148 })?;
4149 count_to_usize("symbol_relations", count)
4150 }
4151
4152 pub fn symbol_count_for_path(&self, path: &str) -> DbResult<usize> {
4158 let count = self.connection.query_row(
4159 "SELECT COUNT(*) FROM symbols WHERE path = ?1",
4160 [path],
4161 |row| row.get::<_, i64>(0),
4162 )?;
4163 Ok(i64_to_usize(count))
4164 }
4165
4166 pub fn symbol_counts_for_paths(&self, paths: &[String]) -> DbResult<HashMap<String, usize>> {
4174 let mut counts = HashMap::new();
4175 for chunk in paths.chunks(900) {
4176 if chunk.is_empty() {
4177 continue;
4178 }
4179 let placeholders = vec!["?"; chunk.len()].join(",");
4180 let sql = format!(
4181 "SELECT path, COUNT(*) FROM symbols WHERE path IN ({placeholders}) GROUP BY path"
4182 );
4183 let mut statement = self.connection.prepare(&sql)?;
4184 let rows = statement.query_map(params_from_iter(chunk.iter()), |row| {
4185 let path = row.get::<_, String>(0)?;
4186 let count = row.get::<_, i64>(1)?;
4187 Ok((path, i64_to_usize(count)))
4188 })?;
4189 for row in rows {
4190 let (path, count) = row?;
4191 counts.insert(path, count);
4192 }
4193 }
4194 Ok(counts)
4195 }
4196
4197 pub fn source_parse_metadata_paths_for_paths(
4203 &self,
4204 paths: &[String],
4205 ) -> DbResult<HashSet<String>> {
4206 let mut indexed = HashSet::new();
4207 for chunk in paths.chunks(900) {
4208 if chunk.is_empty() {
4209 continue;
4210 }
4211 let placeholders = numbered_placeholders(1, chunk.len());
4212 let sql = format!(
4213 "SELECT path FROM source_parse_metadata
4214 WHERE path IN ({placeholders})
4215 ORDER BY path"
4216 );
4217 let mut statement = self.connection.prepare(&sql)?;
4218 let rows = statement.query_map(params_from_iter(chunk.iter()), |row| {
4219 row.get::<_, String>(0)
4220 })?;
4221 for row in rows {
4222 indexed.insert(row?);
4223 }
4224 }
4225 Ok(indexed)
4226 }
4227
4228 pub fn symbol_parser_kinds_for_path(&self, path: &str) -> DbResult<Vec<ParserKind>> {
4234 let mut statement = self.connection.prepare(
4235 "
4236 SELECT DISTINCT parser
4237 FROM symbols
4238 WHERE path = ?1
4239 ORDER BY parser
4240 ",
4241 )?;
4242 let rows = statement.query_map([path], |row| {
4243 Ok(ParserKind::from_db(&row.get::<_, String>(0)?))
4244 })?;
4245 let mut parsers = Vec::new();
4246 for row in rows {
4247 parsers.push(row?);
4248 }
4249 Ok(parsers)
4250 }
4251
4252 pub fn load_symbol_graphs_for_paths(&self, paths: &[String]) -> DbResult<Vec<SymbolGraph>> {
4263 const PATHS_PER_QUERY: usize = 900;
4264
4265 let mut paths = paths.to_vec();
4266 paths.sort();
4267 paths.dedup();
4268 let mut graphs = Vec::with_capacity(paths.len());
4269 for chunk in paths.chunks(PATHS_PER_QUERY) {
4270 let placeholders = numbered_placeholders(1, chunk.len());
4271 let metadata_sql = format!(
4272 "SELECT path, language, source_parser, fact_parser, symbol_count, relation_count
4273 FROM source_parse_metadata
4274 WHERE path IN ({placeholders})
4275 ORDER BY path"
4276 );
4277 let mut metadata_statement = self.connection.prepare(&metadata_sql)?;
4278 let metadata_rows =
4279 metadata_statement.query_map(params_from_iter(chunk.iter()), |row| {
4280 Ok((
4281 row.get::<_, String>(0)?,
4282 row.get::<_, Option<String>>(1)?,
4283 row.get::<_, String>(2)?,
4284 row.get::<_, String>(3)?,
4285 row.get::<_, i64>(4)?,
4286 row.get::<_, i64>(5)?,
4287 ))
4288 })?;
4289 let mut staged = BTreeMap::new();
4290 for row in metadata_rows {
4291 let (path, language, source_parser, fact_parser, symbol_count, relation_count) =
4292 row?;
4293 staged.insert(
4294 path.clone(),
4295 (
4296 SourceParseMetadata {
4297 path,
4298 language,
4299 parser: ParserKind::from_db(&source_parser),
4300 symbol_count: count_to_usize(
4301 "source_parse_metadata.symbol_count",
4302 symbol_count,
4303 )?,
4304 relation_count: count_to_usize(
4305 "source_parse_metadata.relation_count",
4306 relation_count,
4307 )?,
4308 },
4309 ParserKind::from_db(&fact_parser),
4310 Vec::new(),
4311 Vec::new(),
4312 ),
4313 );
4314 }
4315
4316 let symbol_sql = format!(
4317 "SELECT path, language, name, kind, signature, line_start, line_end,
4318 parent, parser, detail, exported, documentation,
4319 source_byte_start, source_byte_end,
4320 source_column_start, source_column_end
4321 FROM symbols
4322 WHERE path IN ({placeholders})
4323 ORDER BY path, line_start, line_end, name, kind"
4324 );
4325 for symbol in self.query_symbols(&symbol_sql, params_from_iter(chunk.iter()))? {
4326 let path = symbol.path.clone();
4327 let Some((_, _, symbols, _)) = staged.get_mut(&path) else {
4328 return Err(DbError::SymbolGraphRowShape {
4329 path,
4330 reason: "symbol rows require matching parser metadata",
4331 });
4332 };
4333 symbols.push(symbol);
4334 }
4335
4336 let relation_sql = format!(
4337 "SELECT path, source_name, target_name, kind, line, context, parser
4338 FROM symbol_relations
4339 WHERE path IN ({placeholders})
4340 ORDER BY path, line, source_name, target_name, kind"
4341 );
4342 for relation in self.query_relations(&relation_sql, params_from_iter(chunk.iter()))? {
4343 let path = relation.path.clone();
4344 let Some((_, _, _, relations)) = staged.get_mut(&path) else {
4345 return Err(DbError::SymbolGraphRowShape {
4346 path,
4347 reason: "relation rows require matching parser metadata",
4348 });
4349 };
4350 relations.push(relation);
4351 }
4352
4353 for (path, (metadata, fact_parser, symbols, relations)) in staged {
4354 if metadata.symbol_count != symbols.len()
4355 || metadata.relation_count != relations.len()
4356 {
4357 return Err(DbError::SymbolGraphRowShape {
4358 path,
4359 reason: "parser metadata counts do not match persisted rows",
4360 });
4361 }
4362 graphs.push(SymbolGraph {
4363 path: metadata.path,
4364 language: metadata.language,
4365 parser: fact_parser,
4366 symbols,
4367 relations,
4368 });
4369 }
4370 }
4371 Ok(graphs)
4372 }
4373
4374 pub fn load_source_parse_metadata(&self, path: &str) -> DbResult<Option<SourceParseMetadata>> {
4380 self.connection
4381 .query_row(
4382 "
4383 SELECT path, language, source_parser, symbol_count, relation_count
4384 FROM source_parse_metadata
4385 WHERE path = ?1
4386 ",
4387 [path],
4388 |row| {
4389 let symbol_count = row.get::<_, i64>(3)?;
4390 let relation_count = row.get::<_, i64>(4)?;
4391 Ok(SourceParseMetadata {
4392 path: row.get(0)?,
4393 language: row.get(1)?,
4394 parser: ParserKind::from_db(&row.get::<_, String>(2)?),
4395 symbol_count: i64_to_usize(symbol_count),
4396 relation_count: i64_to_usize(relation_count),
4397 })
4398 },
4399 )
4400 .optional()
4401 .map_err(Into::into)
4402 }
4403
4404 pub fn max_symbol_end_line_for_path(&self, path: &str) -> DbResult<usize> {
4410 let line = self.connection.query_row(
4411 "SELECT COALESCE(MAX(line_end), 0) FROM symbols WHERE path = ?1",
4412 [path],
4413 |row| row.get::<_, i64>(0),
4414 )?;
4415 Ok(i64_to_usize(line))
4416 }
4417
4418 fn query_symbols<P>(&self, sql: &str, params: P) -> DbResult<Vec<CodeSymbol>>
4420 where
4421 P: rusqlite::Params,
4422 {
4423 let mut statement = self.connection.prepare(sql)?;
4424 let rows = statement.query_map(params, code_symbol_from_row)?;
4425 let mut symbols = Vec::new();
4426 for row in rows {
4427 symbols.push(row?);
4428 }
4429 Ok(symbols)
4430 }
4431
4432 fn query_relations<P>(&self, sql: &str, params: P) -> DbResult<Vec<SymbolRelation>>
4434 where
4435 P: rusqlite::Params,
4436 {
4437 let mut statement = self.connection.prepare(sql)?;
4438 let rows = statement.query_map(params, |row| {
4439 let kind_value: String = row.get(3)?;
4440 let relation_kind = RelationKind::from_db(&kind_value).ok_or_else(|| {
4441 rusqlite::Error::FromSqlConversionFailure(
4442 3,
4443 rusqlite::types::Type::Text,
4444 Box::new(std::io::Error::other(format!(
4445 "invalid relation kind {kind_value}"
4446 ))),
4447 )
4448 })?;
4449 Ok(SymbolRelation {
4450 path: row.get(0)?,
4451 source_name: row.get(1)?,
4452 target_name: row.get(2)?,
4453 kind: relation_kind,
4454 line: i64_to_usize(row.get::<_, i64>(4)?),
4455 context: row.get(5)?,
4456 parser: ParserKind::from_db(&row.get::<_, String>(6)?),
4457 })
4458 })?;
4459 let mut relations = Vec::new();
4460 for row in rows {
4461 relations.push(row?);
4462 }
4463 Ok(relations)
4464 }
4465
4466 pub fn has_agent_approved_purpose(&self) -> DbResult<bool> {
4472 self.connection
4473 .query_row(
4474 "SELECT EXISTS(
4475 SELECT 1
4476 FROM purposes INDEXED BY idx_purposes_status
4477 WHERE status = 'approved' AND source = 'agent'
4478 LIMIT 1
4479 )",
4480 [],
4481 |row| row.get(0),
4482 )
4483 .map_err(Into::into)
4484 }
4485
4486 pub fn set_purpose(&self, path: &str, purpose: &str, source: PurposeSource) -> DbResult<()> {
4492 self.with_validated_write(|connection| {
4493 let node_id = self.node_id_for_path(path)?;
4494 let changed = connection
4495 .prepare_cached(
4496 "
4497 INSERT INTO purposes(node_id, purpose, source, status, updated_at)
4498 VALUES(?1, ?2, ?3, 'approved', CURRENT_TIMESTAMP)
4499 ON CONFLICT(node_id) DO UPDATE SET
4500 purpose = excluded.purpose,
4501 source = excluded.source,
4502 status = 'approved',
4503 updated_at = CURRENT_TIMESTAMP
4504 WHERE purposes.purpose <> excluded.purpose
4505 OR purposes.source <> excluded.source
4506 OR purposes.status <> 'approved'
4507 ",
4508 )?
4509 .execute(params![node_id, purpose, source.to_string()])?;
4510 if changed > 0 {
4511 advance_authored_purpose_revision(connection)?;
4512 }
4513 Ok(())
4514 })
4515 }
4516
4517 pub fn authored_purpose_revision(&self) -> DbResult<u64> {
4526 load_authored_purpose_revision(&self.connection)
4527 }
4528
4529 pub fn conditionally_set_purpose(
4540 &self,
4541 task: &str,
4542 path: &str,
4543 work_key: &str,
4544 state_token: &str,
4545 purpose: &str,
4546 ) -> DbResult<PurposeConditionalApplyState> {
4547 let request = PurposeConditionalApplyRequest {
4548 task: task.to_string(),
4549 path: path.to_string(),
4550 work_key: work_key.to_string(),
4551 state_token: state_token.to_string(),
4552 purpose: purpose.to_string(),
4553 };
4554 let mut results = self.conditionally_set_purposes(&[request])?;
4555 Ok(results
4556 .pop()
4557 .map_or(PurposeConditionalApplyState::PathUnavailable, |result| {
4558 result.state
4559 }))
4560 }
4561
4562 pub fn conditionally_set_purposes(
4574 &self,
4575 requests: &[PurposeConditionalApplyRequest],
4576 ) -> DbResult<Vec<PurposeConditionalApplyResult>> {
4577 if requests.is_empty() {
4578 return Ok(Vec::new());
4579 }
4580 if requests.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
4581 return Err(DbError::PurposeCurationBatchTooLarge {
4582 requested: requests.len(),
4583 maximum: MAX_PURPOSE_CURATION_BATCH_ROWS,
4584 });
4585 }
4586 let normalized = requests
4587 .iter()
4588 .map(|request| {
4589 normalize_purpose_curation_task(&request.task).map(|task| {
4590 PurposeConditionalApplyRequest {
4591 task,
4592 path: request.path.clone(),
4593 work_key: request.work_key.clone(),
4594 state_token: request.state_token.clone(),
4595 purpose: request.purpose.clone(),
4596 }
4597 })
4598 })
4599 .collect::<DbResult<Vec<_>>>()?;
4600 self.with_validated_write(|connection| {
4601 let (project_instance_id, active_generation) =
4602 self.purpose_curation_context(connection)?;
4603 let results = normalized
4604 .iter()
4605 .map(|request| {
4606 apply_conditional_purpose(
4607 connection,
4608 project_instance_id,
4609 active_generation,
4610 request,
4611 )
4612 })
4613 .collect::<DbResult<Vec<_>>>()?;
4614 if results
4615 .iter()
4616 .any(|result| matches!(result.state, PurposeConditionalApplyState::Applied))
4617 {
4618 advance_authored_purpose_revision(connection)?;
4619 }
4620 Ok(results)
4621 })
4622 }
4623
4624 pub fn set_suggested_purpose(&self, path: &str, purpose: &str) -> DbResult<()> {
4630 self.with_validated_write(|connection| {
4631 let node_id = self.node_id_for_path(path)?;
4632 connection
4633 .prepare_cached(
4634 "
4635 INSERT INTO purposes(node_id, purpose, source, status, updated_at)
4636 VALUES(?1, ?2, ?3, ?4, CURRENT_TIMESTAMP)
4637 ON CONFLICT(node_id) DO UPDATE SET
4638 purpose = excluded.purpose,
4639 source = excluded.source,
4640 status = excluded.status,
4641 updated_at = CURRENT_TIMESTAMP
4642 WHERE purposes.status IN (?5, ?6)
4643 ",
4644 )?
4645 .execute(params![
4646 node_id,
4647 purpose,
4648 PurposeSource::Generated.to_string(),
4649 PurposeStatus::Suggested.as_str(),
4650 PurposeStatus::Missing.as_str(),
4651 PurposeStatus::Suggested.as_str(),
4652 ])?;
4653 Ok(())
4654 })
4655 }
4656
4657 fn node_id_for_path(&self, path: &str) -> DbResult<i64> {
4659 self.connection
4660 .prepare_cached("SELECT id FROM nodes WHERE path = ?1 AND exists_now = 1")?
4661 .query_row([path], |row| row.get::<_, i64>(0))
4662 .optional()?
4663 .ok_or_else(|| DbError::PathNotIndexed {
4664 path: path.to_string(),
4665 })
4666 }
4667
4668 pub fn load_nodes(&self) -> DbResult<Vec<IndexedNode>> {
4674 let mut statement = self.connection.prepare(
4675 "
4676 SELECT
4677 n.path,
4678 n.kind,
4679 n.parent_path,
4680 n.extension,
4681 n.language,
4682 n.size_bytes,
4683 n.mtime_ns,
4684 n.content_hash,
4685 p.purpose,
4686 p.source,
4687 p.status,
4688 s.summary
4689 FROM nodes n
4690 JOIN purposes p ON p.node_id = n.id
4691 LEFT JOIN summaries s ON s.node_id = n.id
4692 AND s.summary_level = 'node'
4693 AND s.subject = ''
4694 WHERE n.exists_now = 1
4695 ORDER BY n.path
4696 ",
4697 )?;
4698 let rows = statement.query_map([], |row| {
4699 let kind_value: String = row.get(1)?;
4700 let source_value: String = row.get(9)?;
4701 let status_value: String = row.get(10)?;
4702 Ok((
4703 row.get::<_, String>(0)?,
4704 kind_value,
4705 row.get::<_, Option<String>>(2)?,
4706 row.get::<_, Option<String>>(3)?,
4707 row.get::<_, Option<String>>(4)?,
4708 option_u64_from_sql(row, 5)?,
4709 row.get::<_, Option<i64>>(6)?,
4710 row.get::<_, Option<String>>(7)?,
4711 row.get::<_, Option<String>>(8)?,
4712 source_value,
4713 status_value,
4714 row.get::<_, Option<String>>(11)?,
4715 ))
4716 })?;
4717 let mut nodes = Vec::new();
4718 for row in rows {
4719 let (
4720 path,
4721 kind_value,
4722 parent_path,
4723 extension,
4724 language,
4725 size_bytes,
4726 mtime_ns,
4727 content_hash,
4728 purpose,
4729 source_value,
4730 status_value,
4731 summary,
4732 ) = row?;
4733 let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
4734 field: "kind",
4735 value: kind_value,
4736 })?;
4737 let source = parse_source(&source_value)?;
4738 let status =
4739 PurposeStatus::from_db(&status_value).ok_or_else(|| DbError::InvalidEnum {
4740 field: "status",
4741 value: status_value,
4742 })?;
4743 nodes.push(IndexedNode {
4744 node: Node {
4745 path: path.clone(),
4746 kind,
4747 parent_path,
4748 extension,
4749 language,
4750 size_bytes,
4751 mtime_ns,
4752 content_hash,
4753 },
4754 purpose: Purpose {
4755 path,
4756 purpose,
4757 source,
4758 status,
4759 },
4760 summary,
4761 });
4762 }
4763 Ok(nodes)
4764 }
4765
4766 pub fn load_ranked_nodes(
4776 &self,
4777 query: &str,
4778 kind: NodeKind,
4779 folder: Option<&str>,
4780 limit: usize,
4781 offset: usize,
4782 ) -> DbResult<Vec<IndexedNode>> {
4783 let terms = normalize_query_terms(query);
4784 let exact_query = normalize_exact_ranked_query(query);
4785 let exact_name_enabled = !exact_query.is_empty() && !exact_query.contains('/');
4786 let exact_name_pattern = format!("%/{}", sqlite_like_escape(&exact_query));
4787 let reviewed_match_expression = reviewed_purpose_match_expression(terms.len());
4788 let score_expression = ranked_score_expression(terms.len());
4789 let mut sql = format!(
4790 "
4791 SELECT path, kind, parent_path, extension, language, size_bytes, mtime_ns,
4792 content_hash, purpose, source, status, summary
4793 FROM (
4794 SELECT
4795 n.path,
4796 n.kind,
4797 n.parent_path,
4798 n.extension,
4799 n.language,
4800 n.size_bytes,
4801 n.mtime_ns,
4802 n.content_hash,
4803 p.purpose,
4804 p.source,
4805 p.status,
4806 s.summary,
4807 CASE WHEN lower(n.path) = ? THEN 1 ELSE 0 END AS exact_path,
4808 CASE WHEN ? = 1
4809 AND (lower(n.path) = ? OR lower(n.path) LIKE ? ESCAPE '\\')
4810 THEN 1 ELSE 0 END AS exact_name,
4811 {reviewed_match_expression} AS reviewed_purpose,
4812 {score_expression} AS score
4813 FROM nodes n
4814 JOIN purposes p ON p.node_id = n.id
4815 LEFT JOIN summaries s ON s.node_id = n.id
4816 AND s.summary_level = 'node'
4817 AND s.subject = ''
4818 LEFT JOIN summaries symbol_summaries ON symbol_summaries.node_id = n.id
4819 AND symbol_summaries.summary_level = 'search'
4820 AND symbol_summaries.subject = 'symbols'
4821 WHERE n.exists_now = 1
4822 AND n.kind = ?
4823 "
4824 );
4825 let mut values = vec![
4826 Value::from(exact_query.clone()),
4827 Value::from(i64::from(exact_name_enabled)),
4828 Value::from(exact_query),
4829 Value::from(exact_name_pattern),
4830 ];
4831 for term in &terms {
4832 values.push(Value::from(sqlite_like_pattern(term)));
4833 }
4834 for term in &terms {
4835 let pattern = sqlite_like_pattern(term);
4836 values.push(Value::from(pattern.clone()));
4837 values.push(Value::from(pattern.clone()));
4838 values.push(Value::from(pattern.clone()));
4839 values.push(Value::from(pattern.clone()));
4840 values.push(Value::from(pattern));
4841 }
4842 values.push(Value::from(kind.to_string()));
4843 if kind == NodeKind::File
4844 && let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".")
4845 {
4846 sql.push_str(" AND (n.parent_path = ? OR n.parent_path LIKE ? ESCAPE '\\')");
4847 values.push(Value::from(folder.to_string()));
4848 values.push(Value::from(sqlite_descendant_pattern(folder)));
4849 }
4850 sql.push_str(
4851 "
4852 )
4853 WHERE score > 0 OR exact_path > 0 OR exact_name > 0 OR reviewed_purpose > 0
4854 ORDER BY exact_path DESC, exact_name DESC, reviewed_purpose DESC, score DESC, path
4855 LIMIT ?
4856 OFFSET ?
4857 ",
4858 );
4859 values.push(Value::from(usize_to_i64(limit.max(1))));
4860 values.push(Value::from(usize_to_i64(offset)));
4861
4862 let mut statement = self.connection.prepare(&sql)?;
4863 let mut rows = statement.query(params_from_iter(values))?;
4864 let mut nodes = Vec::new();
4865 while let Some(row) = rows.next()? {
4866 nodes.push(indexed_node_from_sql_row(row)?);
4867 }
4868 Ok(nodes)
4869 }
4870
4871 pub fn source_file_byte_count(&self, folder: Option<&str>) -> DbResult<usize> {
4877 let mut sql = String::from(
4878 "
4879 SELECT COALESCE(SUM(COALESCE(size_bytes, 0)), 0)
4880 FROM nodes
4881 WHERE exists_now = 1
4882 AND kind = 'file'
4883 ",
4884 );
4885 let mut values = Vec::new();
4886 if let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".") {
4887 sql.push_str(" AND (parent_path = ? OR parent_path LIKE ? ESCAPE '\\')");
4888 values.push(Value::from(folder.to_string()));
4889 values.push(Value::from(sqlite_descendant_pattern(folder)));
4890 }
4891 let count = self
4892 .connection
4893 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
4894 count_to_usize("source_file_bytes", count)
4895 }
4896
4897 pub fn visit_file_token_estimates<F>(
4904 &self,
4905 folder: Option<&str>,
4906 mut visitor: F,
4907 ) -> DbResult<()>
4908 where
4909 F: FnMut(String, Option<u64>) -> DbResult<bool>,
4910 {
4911 let mut sql = String::from(
4912 "
4913 SELECT path, size_bytes
4914 FROM nodes
4915 WHERE exists_now = 1
4916 AND kind = 'file'
4917 ",
4918 );
4919 let mut values = Vec::new();
4920 if let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".") {
4921 sql.push_str(" AND (parent_path = ? OR parent_path LIKE ? ESCAPE '\\')");
4922 values.push(Value::from(folder.to_string()));
4923 values.push(Value::from(sqlite_descendant_pattern(folder)));
4924 }
4925 sql.push_str(" ORDER BY path");
4926 let mut statement = self.connection.prepare(&sql)?;
4927 let mut rows = statement.query(params_from_iter(values))?;
4928 while let Some(row) = rows.next()? {
4929 if !visitor(row.get::<_, String>(0)?, option_u64_from_sql(row, 1)?)? {
4930 return Ok(());
4931 }
4932 }
4933 Ok(())
4934 }
4935
4936 pub fn unresolved_health_findings(
4942 &self,
4943 resolved_ids: &[String],
4944 ) -> DbResult<Vec<HealthFinding>> {
4945 let mut findings = Vec::new();
4946 self.visit_unresolved_health_findings(resolved_ids, |finding| {
4947 findings.push(finding);
4948 Ok(true)
4949 })?;
4950 Ok(findings)
4951 }
4952
4953 pub fn unresolved_health_findings_page(
4959 &self,
4960 resolved_ids: &[String],
4961 query: &HealthQuery,
4962 ) -> DbResult<HealthFindingsPage> {
4963 self.unresolved_health_findings_page_with_filter(
4964 HealthResolutionFilter::Explicit(resolved_ids),
4965 query,
4966 )
4967 }
4968
4969 pub fn unresolved_health_findings_page_current(
4975 &self,
4976 query: &HealthQuery,
4977 ) -> DbResult<HealthFindingsPage> {
4978 self.unresolved_health_findings_page_with_filter(HealthResolutionFilter::Stored, query)
4979 }
4980
4981 pub fn purpose_curation_findings_page_current(
4990 &self,
4991 query: &HealthQuery,
4992 ) -> DbResult<HealthFindingsPage> {
4993 let specs = &PURPOSE_HEALTH_SPECS[..2];
4994 let unfiltered_total = specs.iter().try_fold(0_usize, |total, spec| {
4995 self.count_purpose_status_findings(
4996 *spec,
4997 None,
4998 HealthResolutionFilter::Stored,
4999 HealthScope::all(),
5000 )
5001 .map(|count| total + count)
5002 })?;
5003 if query
5004 .severity
5005 .is_some_and(|severity| severity != Severity::Warning)
5006 {
5007 return Ok(HealthFindingsPage {
5008 total: 0,
5009 unfiltered_total,
5010 returned: 0,
5011 start_index: query.start_index,
5012 limit: query.limit,
5013 findings: Vec::new(),
5014 });
5015 }
5016
5017 let matching_specs = query.category.as_deref().map_or(specs, |category| {
5018 specs
5019 .iter()
5020 .find(|spec| spec.category == category)
5021 .map_or(&[][..], std::slice::from_ref)
5022 });
5023 let total = self.count_purpose_lifecycle_findings(
5024 matching_specs,
5025 query.path_prefix.as_deref(),
5026 HealthResolutionFilter::Stored,
5027 query.scope,
5028 )?;
5029 let findings = if query.summary_only {
5030 Vec::new()
5031 } else {
5032 self.load_purpose_lifecycle_findings_page(
5033 matching_specs,
5034 query.path_prefix.as_deref(),
5035 HealthResolutionFilter::Stored,
5036 query.scope,
5037 query.start_index,
5038 query.limit,
5039 )?
5040 };
5041 Ok(HealthFindingsPage {
5042 total,
5043 unfiltered_total,
5044 returned: findings.len(),
5045 start_index: query.start_index,
5046 limit: query.limit,
5047 findings,
5048 })
5049 }
5050
5051 pub fn unresolved_health_finding_count_current(&self) -> DbResult<usize> {
5057 self.unresolved_health_findings_page_current(&HealthQuery {
5058 start_index: 0,
5059 limit: 0,
5060 category: None,
5061 severity: None,
5062 path_prefix: None,
5063 summary_only: true,
5064 scope: HealthScope::all(),
5065 })
5066 .map(|page| page.total)
5067 }
5068
5069 fn unresolved_health_findings_page_with_filter(
5071 &self,
5072 resolution_filter: HealthResolutionFilter<'_>,
5073 query: &HealthQuery,
5074 ) -> DbResult<HealthFindingsPage> {
5075 let mut unfiltered_total = 0_usize;
5076 let mut total = 0_usize;
5077 let mut findings = Vec::new();
5078
5079 for spec in PURPOSE_HEALTH_SPECS {
5080 unfiltered_total += self.count_purpose_status_findings(
5081 spec,
5082 None,
5083 resolution_filter,
5084 HealthScope::all(),
5085 )?;
5086 }
5087
5088 let scope = query.scope;
5089 if scope.high_impact_queue() && query.category.is_none() {
5090 let matching_count = if query
5091 .severity
5092 .is_none_or(|severity| severity == Severity::Warning)
5093 {
5094 self.count_purpose_lifecycle_findings(
5095 &PURPOSE_HEALTH_SPECS,
5096 query.path_prefix.as_deref(),
5097 resolution_filter,
5098 scope,
5099 )?
5100 } else {
5101 0
5102 };
5103 if !query.summary_only
5104 && findings.len() < query.limit
5105 && total + matching_count > query.start_index
5106 {
5107 let local_start = query.start_index.saturating_sub(total);
5108 let local_limit = query.limit - findings.len();
5109 findings.extend(self.load_purpose_lifecycle_findings_page(
5110 &PURPOSE_HEALTH_SPECS,
5111 query.path_prefix.as_deref(),
5112 resolution_filter,
5113 scope,
5114 local_start,
5115 local_limit,
5116 )?);
5117 }
5118 total += matching_count;
5119 } else {
5120 for spec in PURPOSE_HEALTH_SPECS {
5121 if !purpose_health_spec_matches_query(spec, query) {
5122 continue;
5123 }
5124
5125 let matching_count = self.count_purpose_status_findings(
5126 spec,
5127 query.path_prefix.as_deref(),
5128 resolution_filter,
5129 scope,
5130 )?;
5131 if !query.summary_only
5132 && findings.len() < query.limit
5133 && total + matching_count > query.start_index
5134 {
5135 let local_start = query.start_index.saturating_sub(total);
5136 let local_limit = query.limit - findings.len();
5137 findings.extend(self.load_purpose_status_findings_page(
5138 spec,
5139 query.path_prefix.as_deref(),
5140 resolution_filter,
5141 scope,
5142 local_start,
5143 local_limit,
5144 )?);
5145 }
5146 total += matching_count;
5147 }
5148 }
5149
5150 for category in STRUCTURAL_HEALTH_CATEGORIES {
5151 let unfiltered_scope = if category == CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED {
5152 HealthScope::purpose_strict()
5153 } else {
5154 HealthScope::all()
5155 };
5156 let unfiltered_count = self.count_structural_health_findings(
5157 category,
5158 None,
5159 resolution_filter,
5160 unfiltered_scope,
5161 )?;
5162 unfiltered_total += unfiltered_count;
5163 if !health_category_matches_query(category, Severity::Warning, query) {
5164 continue;
5165 }
5166 let matching_count = self.count_structural_health_findings(
5167 category,
5168 query.path_prefix.as_deref(),
5169 resolution_filter,
5170 scope,
5171 )?;
5172 if !query.summary_only
5173 && findings.len() < query.limit
5174 && total + matching_count > query.start_index
5175 {
5176 let local_start = query.start_index.saturating_sub(total);
5177 let local_limit = query.limit - findings.len();
5178 findings.extend(self.load_structural_health_findings_page(
5179 category,
5180 query.path_prefix.as_deref(),
5181 resolution_filter,
5182 scope,
5183 local_start,
5184 local_limit,
5185 )?);
5186 }
5187 total += matching_count;
5188 }
5189 Ok(HealthFindingsPage {
5190 total,
5191 unfiltered_total,
5192 returned: findings.len(),
5193 start_index: query.start_index,
5194 limit: query.limit,
5195 findings,
5196 })
5197 }
5198
5199 fn visit_unresolved_health_findings<F>(
5201 &self,
5202 resolved_ids: &[String],
5203 mut visitor: F,
5204 ) -> DbResult<()>
5205 where
5206 F: FnMut(HealthFinding) -> DbResult<bool>,
5207 {
5208 let resolved = resolved_ids.iter().cloned().collect::<HashSet<_>>();
5209 if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[0], &resolved, &mut visitor)? {
5210 return Ok(());
5211 }
5212 if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[1], &resolved, &mut visitor)? {
5213 return Ok(());
5214 }
5215 if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[2], &resolved, &mut visitor)? {
5216 return Ok(());
5217 }
5218 if !self.visit_agent_review_required_findings(&resolved, &mut visitor)? {
5219 return Ok(());
5220 }
5221 self.visit_structural_health_findings(&resolved, &mut visitor)
5222 }
5223
5224 fn visit_structural_health_findings<F>(
5226 &self,
5227 resolved_ids: &HashSet<String>,
5228 mut visitor: F,
5229 ) -> DbResult<()>
5230 where
5231 F: FnMut(HealthFinding) -> DbResult<bool>,
5232 {
5233 if !self.visit_duplicate_purpose_findings(resolved_ids, &mut visitor)? {
5234 return Ok(());
5235 }
5236 if !self.visit_repeated_temp_folder_findings(resolved_ids, &mut visitor)? {
5237 return Ok(());
5238 }
5239 Ok(())
5240 }
5241
5242 fn visit_purpose_status_findings<F>(
5244 &self,
5245 spec: PurposeHealthSpec,
5246 resolved_ids: &HashSet<String>,
5247 visitor: &mut F,
5248 ) -> DbResult<bool>
5249 where
5250 F: FnMut(HealthFinding) -> DbResult<bool>,
5251 {
5252 let mut statement = self.connection.prepare(
5253 "
5254 SELECT n.path
5255 FROM nodes n
5256 JOIN purposes p ON p.node_id = n.id
5257 WHERE n.exists_now = 1
5258 AND p.status = ?1
5259 ORDER BY n.path
5260 ",
5261 )?;
5262 let rows = statement.query_map([spec.status], |row| row.get::<_, String>(0))?;
5263 for row in rows {
5264 let path = row?;
5265 let finding = HealthFinding {
5266 id: finding_id(spec.category, &path, None),
5267 severity: Severity::Warning,
5268 category: spec.category.to_string(),
5269 path,
5270 related_path: None,
5271 message: spec.message.to_string(),
5272 recommendation: spec.recommendation.to_string(),
5273 };
5274 if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
5275 return Ok(false);
5276 }
5277 }
5278 Ok(true)
5279 }
5280
5281 fn count_purpose_lifecycle_findings(
5283 &self,
5284 specs: &[PurposeHealthSpec],
5285 path_prefix: Option<&str>,
5286 resolution_filter: HealthResolutionFilter<'_>,
5287 scope: HealthScope,
5288 ) -> DbResult<usize> {
5289 if specs.is_empty() {
5290 return Ok(0);
5291 }
5292 let (where_clause, values) =
5293 purpose_lifecycle_where_clause(specs, path_prefix, resolution_filter, scope);
5294 let sql = format!(
5295 "
5296 SELECT COUNT(*)
5297 FROM nodes n
5298 JOIN purposes p ON p.node_id = n.id
5299 WHERE {where_clause}
5300 "
5301 );
5302 let count = self
5303 .connection
5304 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5305 count_to_usize("health_purpose_lifecycle_count", count)
5306 }
5307
5308 fn load_purpose_lifecycle_findings_page(
5310 &self,
5311 specs: &[PurposeHealthSpec],
5312 path_prefix: Option<&str>,
5313 resolution_filter: HealthResolutionFilter<'_>,
5314 scope: HealthScope,
5315 start_index: usize,
5316 limit: usize,
5317 ) -> DbResult<Vec<HealthFinding>> {
5318 if limit == 0 || specs.is_empty() {
5319 return Ok(Vec::new());
5320 }
5321 let (where_clause, mut values) =
5322 purpose_lifecycle_where_clause(specs, path_prefix, resolution_filter, scope);
5323 let limit_placeholder = values.len() + 1;
5324 let offset_placeholder = values.len() + 2;
5325 values.push(Value::from(usize_to_i64(limit)));
5326 values.push(Value::from(usize_to_i64(start_index)));
5327 let order_by = purpose_default_queue_order_expression("n", "p");
5328 let sql = format!(
5329 "
5330 SELECT n.path, p.status
5331 FROM nodes n
5332 JOIN purposes p ON p.node_id = n.id
5333 WHERE {where_clause}
5334 ORDER BY {order_by}
5335 LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5336 "
5337 );
5338 let mut statement = self.connection.prepare(&sql)?;
5339 let rows = statement.query_map(params_from_iter(values), |row| {
5340 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5341 })?;
5342 let mut findings = Vec::new();
5343 for row in rows {
5344 let (path, status) = row?;
5345 let spec = purpose_health_spec_for_status(&status)?;
5346 findings.push(HealthFinding {
5347 id: finding_id(spec.category, &path, None),
5348 severity: Severity::Warning,
5349 category: spec.category.to_string(),
5350 path,
5351 related_path: None,
5352 message: spec.message.to_string(),
5353 recommendation: spec.recommendation.to_string(),
5354 });
5355 }
5356 Ok(findings)
5357 }
5358
5359 fn count_purpose_status_findings(
5361 &self,
5362 spec: PurposeHealthSpec,
5363 path_prefix: Option<&str>,
5364 resolution_filter: HealthResolutionFilter<'_>,
5365 scope: HealthScope,
5366 ) -> DbResult<usize> {
5367 let (where_clause, values) =
5368 purpose_status_where_clause(spec, path_prefix, resolution_filter, scope);
5369 let sql = format!(
5370 "
5371 SELECT COUNT(*)
5372 FROM nodes n
5373 JOIN purposes p ON p.node_id = n.id
5374 WHERE {where_clause}
5375 "
5376 );
5377 let count = self
5378 .connection
5379 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5380 count_to_usize("health_purpose_status_count", count)
5381 }
5382
5383 fn load_purpose_status_findings_page(
5385 &self,
5386 spec: PurposeHealthSpec,
5387 path_prefix: Option<&str>,
5388 resolution_filter: HealthResolutionFilter<'_>,
5389 scope: HealthScope,
5390 start_index: usize,
5391 limit: usize,
5392 ) -> DbResult<Vec<HealthFinding>> {
5393 if limit == 0 {
5394 return Ok(Vec::new());
5395 }
5396 let (where_clause, mut values) =
5397 purpose_status_where_clause(spec, path_prefix, resolution_filter, scope);
5398 let limit_placeholder = values.len() + 1;
5399 let offset_placeholder = values.len() + 2;
5400 values.push(Value::from(usize_to_i64(limit)));
5401 values.push(Value::from(usize_to_i64(start_index)));
5402 let order_by = if scope.high_impact_queue() {
5403 purpose_default_queue_order_expression("n", "p")
5404 } else {
5405 "n.path".to_string()
5406 };
5407 let sql = format!(
5408 "
5409 SELECT n.path
5410 FROM nodes n
5411 JOIN purposes p ON p.node_id = n.id
5412 WHERE {where_clause}
5413 ORDER BY {order_by}
5414 LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5415 "
5416 );
5417 let mut statement = self.connection.prepare(&sql)?;
5418 let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(0))?;
5419 let mut findings = Vec::new();
5420 for row in rows {
5421 let path = row?;
5422 findings.push(HealthFinding {
5423 id: finding_id(spec.category, &path, None),
5424 severity: Severity::Warning,
5425 category: spec.category.to_string(),
5426 path,
5427 related_path: None,
5428 message: spec.message.to_string(),
5429 recommendation: spec.recommendation.to_string(),
5430 });
5431 }
5432 Ok(findings)
5433 }
5434
5435 fn count_structural_health_findings(
5437 &self,
5438 category: &str,
5439 path_prefix: Option<&str>,
5440 resolution_filter: HealthResolutionFilter<'_>,
5441 scope: HealthScope,
5442 ) -> DbResult<usize> {
5443 match category {
5444 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => {
5445 self.count_agent_review_required_findings(path_prefix, resolution_filter, scope)
5446 }
5447 CATEGORY_DUPLICATE_PURPOSE => {
5448 self.count_duplicate_purpose_findings(path_prefix, resolution_filter, scope)
5449 }
5450 CATEGORY_REPEATED_TEMPORARY_FOLDER => {
5451 self.count_repeated_temp_folder_findings(path_prefix, resolution_filter, scope)
5452 }
5453 _ => Ok(0),
5454 }
5455 }
5456
5457 fn load_structural_health_findings_page(
5459 &self,
5460 category: &str,
5461 path_prefix: Option<&str>,
5462 resolution_filter: HealthResolutionFilter<'_>,
5463 scope: HealthScope,
5464 start_index: usize,
5465 limit: usize,
5466 ) -> DbResult<Vec<HealthFinding>> {
5467 match category {
5468 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => self
5469 .load_agent_review_required_findings_page(
5470 path_prefix,
5471 resolution_filter,
5472 scope,
5473 start_index,
5474 limit,
5475 ),
5476 CATEGORY_DUPLICATE_PURPOSE => self.load_duplicate_purpose_findings_page(
5477 path_prefix,
5478 resolution_filter,
5479 scope,
5480 start_index,
5481 limit,
5482 ),
5483 CATEGORY_REPEATED_TEMPORARY_FOLDER => self.load_repeated_temp_folder_findings_page(
5484 path_prefix,
5485 resolution_filter,
5486 scope,
5487 start_index,
5488 limit,
5489 ),
5490 _ => Ok(Vec::new()),
5491 }
5492 }
5493
5494 fn visit_agent_review_required_findings<F>(
5496 &self,
5497 resolved_ids: &HashSet<String>,
5498 visitor: &mut F,
5499 ) -> DbResult<bool>
5500 where
5501 F: FnMut(HealthFinding) -> DbResult<bool>,
5502 {
5503 let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
5504 let high_impact = high_impact_file_path_expression("lower(n.path)");
5505 let approved_status = PurposeStatus::Approved.as_str();
5506 let sql = format!(
5507 "
5508 SELECT n.path
5509 FROM nodes n
5510 JOIN purposes p ON p.node_id = n.id
5511 WHERE n.exists_now = 1
5512 AND p.status = '{approved_status}'
5513 AND p.source NOT IN ({reviewed_sources})
5514 AND (n.kind = 'folder' OR (n.kind = 'file' AND {high_impact}))
5515 ORDER BY CASE WHEN n.kind = 'folder' THEN 0 ELSE 1 END, n.path
5516 "
5517 );
5518 let mut statement = self.connection.prepare(&sql)?;
5519 let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
5520 for row in rows {
5521 let finding = agent_review_required_finding(row?);
5522 if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
5523 return Ok(false);
5524 }
5525 }
5526 Ok(true)
5527 }
5528
5529 fn count_agent_review_required_findings(
5531 &self,
5532 path_prefix: Option<&str>,
5533 resolution_filter: HealthResolutionFilter<'_>,
5534 scope: HealthScope,
5535 ) -> DbResult<usize> {
5536 let (where_clause, values) = structural_finding_where_clause(
5537 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
5538 path_prefix,
5539 resolution_filter,
5540 scope,
5541 1,
5542 );
5543 let source_relevant = source_relevant_node_expression("n");
5544 let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
5545 let review_candidate = purpose_review_candidate_expression("n", scope);
5546 let approved_status = PurposeStatus::Approved.as_str();
5547 let sql = format!(
5548 "
5549 WITH findings AS (
5550 SELECT n.path,
5551 n.kind,
5552 n.language,
5553 '' AS related_path,
5554 {source_relevant} AS source_relevant
5555 FROM nodes n
5556 JOIN purposes p ON p.node_id = n.id
5557 WHERE n.exists_now = 1
5558 AND p.status = '{approved_status}'
5559 AND p.source NOT IN ({reviewed_sources})
5560 AND {review_candidate}
5561 )
5562 SELECT COUNT(*)
5563 FROM findings
5564 {where_clause}
5565 "
5566 );
5567 let count = self
5568 .connection
5569 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5570 count_to_usize("health_agent_review_required_count", count)
5571 }
5572
5573 fn load_agent_review_required_findings_page(
5575 &self,
5576 path_prefix: Option<&str>,
5577 resolution_filter: HealthResolutionFilter<'_>,
5578 scope: HealthScope,
5579 start_index: usize,
5580 limit: usize,
5581 ) -> DbResult<Vec<HealthFinding>> {
5582 if limit == 0 {
5583 return Ok(Vec::new());
5584 }
5585 let (where_clause, mut values) = structural_finding_where_clause(
5586 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
5587 path_prefix,
5588 resolution_filter,
5589 scope,
5590 1,
5591 );
5592 let source_relevant = source_relevant_node_expression("n");
5593 let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
5594 let review_candidate = purpose_review_candidate_expression("n", scope);
5595 let approved_status = PurposeStatus::Approved.as_str();
5596 let limit_placeholder = values.len() + 1;
5597 let offset_placeholder = values.len() + 2;
5598 values.push(Value::from(usize_to_i64(limit)));
5599 values.push(Value::from(usize_to_i64(start_index)));
5600 let sql = format!(
5601 "
5602 WITH findings AS (
5603 SELECT n.path,
5604 n.kind,
5605 n.language,
5606 '' AS related_path,
5607 {source_relevant} AS source_relevant
5608 FROM nodes n
5609 JOIN purposes p ON p.node_id = n.id
5610 WHERE n.exists_now = 1
5611 AND p.status = '{approved_status}'
5612 AND p.source NOT IN ({reviewed_sources})
5613 AND {review_candidate}
5614 )
5615 SELECT path
5616 FROM findings
5617 {where_clause}
5618 ORDER BY CASE WHEN kind = 'folder' THEN 0 ELSE 1 END, path
5619 LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5620 "
5621 );
5622 let mut statement = self.connection.prepare(&sql)?;
5623 let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(0))?;
5624 let mut findings = Vec::new();
5625 for row in rows {
5626 findings.push(agent_review_required_finding(row?));
5627 }
5628 Ok(findings)
5629 }
5630
5631 fn count_duplicate_purpose_findings(
5633 &self,
5634 path_prefix: Option<&str>,
5635 resolution_filter: HealthResolutionFilter<'_>,
5636 scope: HealthScope,
5637 ) -> DbResult<usize> {
5638 let (where_clause, values) = structural_finding_where_clause(
5639 CATEGORY_DUPLICATE_PURPOSE,
5640 path_prefix,
5641 resolution_filter,
5642 scope,
5643 1,
5644 );
5645 let source_relevant = source_relevant_node_expression("n");
5646 let duplicate_scope =
5647 "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5648 let approved_status = PurposeStatus::Approved.as_str();
5649 let sql = format!(
5650 "
5651 WITH duplicate_rows AS (
5652 SELECT n.path,
5653 n.kind,
5654 n.language,
5655 p.purpose,
5656 {source_relevant} AS source_relevant,
5657 FIRST_VALUE(n.path) OVER (
5658 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5659 ORDER BY n.path
5660 ) AS related_path,
5661 ROW_NUMBER() OVER (
5662 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5663 ORDER BY n.path
5664 ) AS duplicate_rank,
5665 COUNT(*) OVER (
5666 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5667 ) AS duplicate_count
5668 FROM nodes n
5669 JOIN purposes p ON p.node_id = n.id
5670 WHERE n.exists_now = 1
5671 AND p.status = '{approved_status}'
5672 AND p.purpose IS NOT NULL
5673 ),
5674 findings AS (
5675 SELECT path, kind, language, purpose, related_path, source_relevant
5676 FROM duplicate_rows
5677 WHERE duplicate_count > 1
5678 AND duplicate_rank > 1
5679 )
5680 SELECT COUNT(*)
5681 FROM findings
5682 {where_clause}
5683 "
5684 );
5685 let count = self
5686 .connection
5687 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5688 count_to_usize("health_duplicate_purpose_count", count)
5689 }
5690
5691 fn load_duplicate_purpose_findings_page(
5693 &self,
5694 path_prefix: Option<&str>,
5695 resolution_filter: HealthResolutionFilter<'_>,
5696 scope: HealthScope,
5697 start_index: usize,
5698 limit: usize,
5699 ) -> DbResult<Vec<HealthFinding>> {
5700 if limit == 0 {
5701 return Ok(Vec::new());
5702 }
5703 let (where_clause, mut values) = structural_finding_where_clause(
5704 CATEGORY_DUPLICATE_PURPOSE,
5705 path_prefix,
5706 resolution_filter,
5707 scope,
5708 1,
5709 );
5710 let source_relevant = source_relevant_node_expression("n");
5711 let duplicate_scope =
5712 "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5713 let approved_status = PurposeStatus::Approved.as_str();
5714 let limit_placeholder = values.len() + 1;
5715 let offset_placeholder = values.len() + 2;
5716 values.push(Value::from(usize_to_i64(limit)));
5717 values.push(Value::from(usize_to_i64(start_index)));
5718 let sql = format!(
5719 "
5720 WITH duplicate_rows AS (
5721 SELECT n.path,
5722 n.kind,
5723 n.language,
5724 p.purpose,
5725 {source_relevant} AS source_relevant,
5726 FIRST_VALUE(n.path) OVER (
5727 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5728 ORDER BY n.path
5729 ) AS related_path,
5730 ROW_NUMBER() OVER (
5731 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5732 ORDER BY n.path
5733 ) AS duplicate_rank,
5734 COUNT(*) OVER (
5735 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5736 ) AS duplicate_count
5737 FROM nodes n
5738 JOIN purposes p ON p.node_id = n.id
5739 WHERE n.exists_now = 1
5740 AND p.status = '{approved_status}'
5741 AND p.purpose IS NOT NULL
5742 ),
5743 findings AS (
5744 SELECT path, kind, language, purpose, related_path, source_relevant
5745 FROM duplicate_rows
5746 WHERE duplicate_count > 1
5747 AND duplicate_rank > 1
5748 )
5749 SELECT path, kind, related_path
5750 FROM findings
5751 {where_clause}
5752 ORDER BY kind, lower(purpose), path
5753 LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5754 "
5755 );
5756 let mut statement = self.connection.prepare(&sql)?;
5757 let rows = statement.query_map(params_from_iter(values), |row| {
5758 Ok((
5759 row.get::<_, String>(0)?,
5760 row.get::<_, String>(1)?,
5761 row.get::<_, String>(2)?,
5762 ))
5763 })?;
5764 let mut findings = Vec::new();
5765 for row in rows {
5766 let (path, kind_value, related_path) = row?;
5767 let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
5768 field: "kind",
5769 value: kind_value,
5770 })?;
5771 findings.push(HealthFinding {
5772 id: finding_id(CATEGORY_DUPLICATE_PURPOSE, &path, Some(&related_path)),
5773 severity: Severity::Warning,
5774 category: CATEGORY_DUPLICATE_PURPOSE.to_string(),
5775 path,
5776 related_path: Some(related_path),
5777 message: format!("Multiple {kind} nodes share the same purpose."),
5778 recommendation: RECOMMENDATION_DUPLICATE_PURPOSE.to_string(),
5779 });
5780 }
5781 Ok(findings)
5782 }
5783
5784 fn count_repeated_temp_folder_findings(
5786 &self,
5787 path_prefix: Option<&str>,
5788 resolution_filter: HealthResolutionFilter<'_>,
5789 scope: HealthScope,
5790 ) -> DbResult<usize> {
5791 let mut total = 0_usize;
5792 for bucket in TEMP_FOLDER_BUCKETS {
5793 total += self.count_repeated_temp_folder_bucket_findings(
5794 bucket,
5795 path_prefix,
5796 resolution_filter,
5797 scope,
5798 )?;
5799 }
5800 Ok(total)
5801 }
5802
5803 fn count_repeated_temp_folder_bucket_findings(
5805 &self,
5806 bucket: &str,
5807 path_prefix: Option<&str>,
5808 resolution_filter: HealthResolutionFilter<'_>,
5809 scope: HealthScope,
5810 ) -> DbResult<usize> {
5811 let exact = bucket.to_string();
5812 let suffix = format!("%/{bucket}");
5813 let (where_clause, mut filter_values) = structural_finding_where_clause(
5814 CATEGORY_REPEATED_TEMPORARY_FOLDER,
5815 path_prefix,
5816 resolution_filter,
5817 scope,
5818 3,
5819 );
5820 let mut values = vec![Value::from(exact), Value::from(suffix)];
5821 values.append(&mut filter_values);
5822 let source_relevant = source_relevant_node_expression("n");
5823 let sql = format!(
5824 "
5825 WITH bucket_rows AS (
5826 SELECT n.path,
5827 n.kind,
5828 n.language,
5829 {source_relevant} AS source_relevant,
5830 FIRST_VALUE(n.path) OVER (ORDER BY n.path) AS related_path,
5831 ROW_NUMBER() OVER (ORDER BY path) AS duplicate_rank,
5832 COUNT(*) OVER () AS duplicate_count
5833 FROM nodes n
5834 WHERE n.exists_now = 1
5835 AND n.kind = 'folder'
5836 AND (lower(n.path) = ?1 OR lower(n.path) LIKE ?2)
5837 ),
5838 findings AS (
5839 SELECT path, kind, language, related_path, source_relevant
5840 FROM bucket_rows
5841 WHERE duplicate_count > 1
5842 AND duplicate_rank > 1
5843 )
5844 SELECT COUNT(*)
5845 FROM findings
5846 {where_clause}
5847 "
5848 );
5849 let count = self
5850 .connection
5851 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5852 count_to_usize("health_repeated_temp_count", count)
5853 }
5854
5855 fn load_repeated_temp_folder_findings_page(
5857 &self,
5858 path_prefix: Option<&str>,
5859 resolution_filter: HealthResolutionFilter<'_>,
5860 scope: HealthScope,
5861 start_index: usize,
5862 limit: usize,
5863 ) -> DbResult<Vec<HealthFinding>> {
5864 if limit == 0 {
5865 return Ok(Vec::new());
5866 }
5867 let mut total = 0_usize;
5868 let mut findings = Vec::new();
5869 for bucket in TEMP_FOLDER_BUCKETS {
5870 let matching_count = self.count_repeated_temp_folder_bucket_findings(
5871 bucket,
5872 path_prefix,
5873 resolution_filter,
5874 scope,
5875 )?;
5876 if findings.len() < limit && total + matching_count > start_index {
5877 let local_start = start_index.saturating_sub(total);
5878 let local_limit = limit - findings.len();
5879 findings.extend(self.load_repeated_temp_folder_bucket_findings_page(
5880 bucket,
5881 path_prefix,
5882 resolution_filter,
5883 scope,
5884 local_start,
5885 local_limit,
5886 )?);
5887 }
5888 total += matching_count;
5889 if findings.len() >= limit {
5890 break;
5891 }
5892 }
5893 Ok(findings)
5894 }
5895
5896 fn load_repeated_temp_folder_bucket_findings_page(
5898 &self,
5899 bucket: &str,
5900 path_prefix: Option<&str>,
5901 resolution_filter: HealthResolutionFilter<'_>,
5902 scope: HealthScope,
5903 start_index: usize,
5904 limit: usize,
5905 ) -> DbResult<Vec<HealthFinding>> {
5906 if limit == 0 {
5907 return Ok(Vec::new());
5908 }
5909 let exact = bucket.to_string();
5910 let suffix = format!("%/{bucket}");
5911 let (where_clause, mut filter_values) = structural_finding_where_clause(
5912 CATEGORY_REPEATED_TEMPORARY_FOLDER,
5913 path_prefix,
5914 resolution_filter,
5915 scope,
5916 3,
5917 );
5918 let mut values = vec![Value::from(exact), Value::from(suffix)];
5919 values.append(&mut filter_values);
5920 let source_relevant = source_relevant_node_expression("n");
5921 let limit_placeholder = values.len() + 1;
5922 let offset_placeholder = values.len() + 2;
5923 values.push(Value::from(usize_to_i64(limit)));
5924 values.push(Value::from(usize_to_i64(start_index)));
5925 let sql = format!(
5926 "
5927 WITH bucket_rows AS (
5928 SELECT n.path,
5929 n.kind,
5930 n.language,
5931 {source_relevant} AS source_relevant,
5932 FIRST_VALUE(n.path) OVER (ORDER BY n.path) AS related_path,
5933 ROW_NUMBER() OVER (ORDER BY n.path) AS duplicate_rank,
5934 COUNT(*) OVER () AS duplicate_count
5935 FROM nodes n
5936 WHERE n.exists_now = 1
5937 AND n.kind = 'folder'
5938 AND (lower(n.path) = ?1 OR lower(n.path) LIKE ?2)
5939 ),
5940 findings AS (
5941 SELECT path, kind, language, related_path, source_relevant
5942 FROM bucket_rows
5943 WHERE duplicate_count > 1
5944 AND duplicate_rank > 1
5945 )
5946 SELECT path, related_path
5947 FROM findings
5948 {where_clause}
5949 ORDER BY path
5950 LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5951 "
5952 );
5953 let mut statement = self.connection.prepare(&sql)?;
5954 let rows = statement.query_map(params_from_iter(values), |row| {
5955 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5956 })?;
5957 let mut findings = Vec::new();
5958 for row in rows {
5959 let (path, related_path) = row?;
5960 findings.push(HealthFinding {
5961 id: finding_id(
5962 CATEGORY_REPEATED_TEMPORARY_FOLDER,
5963 &path,
5964 Some(&related_path),
5965 ),
5966 severity: Severity::Warning,
5967 category: CATEGORY_REPEATED_TEMPORARY_FOLDER.to_string(),
5968 path,
5969 related_path: Some(related_path),
5970 message: format!("Repeated temporary/generated folder name `{bucket}` found."),
5971 recommendation: RECOMMENDATION_REPEATED_TEMPORARY_FOLDER.to_string(),
5972 });
5973 }
5974 Ok(findings)
5975 }
5976
5977 fn visit_duplicate_purpose_findings<F>(
5979 &self,
5980 resolved_ids: &HashSet<String>,
5981 visitor: &mut F,
5982 ) -> DbResult<bool>
5983 where
5984 F: FnMut(HealthFinding) -> DbResult<bool>,
5985 {
5986 let duplicate_scope =
5987 "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5988 let approved_status = PurposeStatus::Approved.as_str();
5989 let sql = format!(
5990 "
5991 WITH duplicate_rows AS (
5992 SELECT n.path,
5993 n.kind,
5994 p.purpose,
5995 FIRST_VALUE(n.path) OVER (
5996 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5997 ORDER BY n.path
5998 ) AS related_path,
5999 ROW_NUMBER() OVER (
6000 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
6001 ORDER BY n.path
6002 ) AS duplicate_rank,
6003 COUNT(*) OVER (
6004 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
6005 ) AS duplicate_count
6006 FROM nodes n
6007 JOIN purposes p ON p.node_id = n.id
6008 WHERE n.exists_now = 1
6009 AND p.status = '{approved_status}'
6010 AND p.purpose IS NOT NULL
6011 )
6012 SELECT path, kind, purpose, related_path
6013 FROM duplicate_rows
6014 WHERE duplicate_count > 1
6015 AND duplicate_rank > 1
6016 ORDER BY kind, lower(purpose), path
6017 "
6018 );
6019 let mut statement = self.connection.prepare(&sql)?;
6020 let rows = statement.query_map([], |row| {
6021 Ok((
6022 row.get::<_, String>(0)?,
6023 row.get::<_, String>(1)?,
6024 row.get::<_, String>(2)?,
6025 row.get::<_, String>(3)?,
6026 ))
6027 })?;
6028 for row in rows {
6029 let (path, kind_value, _purpose, related_path) = row?;
6030 let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
6031 field: "kind",
6032 value: kind_value.clone(),
6033 })?;
6034 let finding = HealthFinding {
6035 id: finding_id(CATEGORY_DUPLICATE_PURPOSE, &path, Some(&related_path)),
6036 severity: Severity::Warning,
6037 category: CATEGORY_DUPLICATE_PURPOSE.to_string(),
6038 path,
6039 related_path: Some(related_path),
6040 message: format!("Multiple {kind} nodes share the same purpose."),
6041 recommendation: RECOMMENDATION_DUPLICATE_PURPOSE.to_string(),
6042 };
6043 if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
6044 return Ok(false);
6045 }
6046 }
6047 Ok(true)
6048 }
6049
6050 fn visit_repeated_temp_folder_findings<F>(
6052 &self,
6053 resolved_ids: &HashSet<String>,
6054 visitor: &mut F,
6055 ) -> DbResult<bool>
6056 where
6057 F: FnMut(HealthFinding) -> DbResult<bool>,
6058 {
6059 for bucket in TEMP_FOLDER_BUCKETS {
6060 let exact = bucket.to_string();
6061 let suffix = format!("%/{bucket}");
6062 let mut statement = self.connection.prepare(
6063 "
6064 SELECT path
6065 FROM nodes
6066 WHERE exists_now = 1
6067 AND kind = 'folder'
6068 AND (lower(path) = ?1 OR lower(path) LIKE ?2)
6069 ORDER BY path
6070 ",
6071 )?;
6072 let rows =
6073 statement.query_map(params![exact, suffix], |row| row.get::<_, String>(0))?;
6074 let mut first_path = None;
6075 for row in rows {
6076 let path = row?;
6077 let Some(first_path) = first_path.as_ref() else {
6078 first_path = Some(path);
6079 continue;
6080 };
6081 let finding = HealthFinding {
6082 id: finding_id(
6083 CATEGORY_REPEATED_TEMPORARY_FOLDER,
6084 &path,
6085 Some(first_path.as_str()),
6086 ),
6087 severity: Severity::Warning,
6088 category: CATEGORY_REPEATED_TEMPORARY_FOLDER.to_string(),
6089 path,
6090 related_path: Some(first_path.clone()),
6091 message: format!("Repeated temporary/generated folder name `{bucket}` found."),
6092 recommendation: RECOMMENDATION_REPEATED_TEMPORARY_FOLDER.to_string(),
6093 };
6094 if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
6095 return Ok(false);
6096 }
6097 }
6098 }
6099 Ok(true)
6100 }
6101
6102 pub fn overview(&self) -> DbResult<Overview> {
6108 let missing_status = PurposeStatus::Missing.as_str();
6109 let stale_status = PurposeStatus::Stale.as_str();
6110 let approved_status = PurposeStatus::Approved.as_str();
6111 let suggested_status = PurposeStatus::Suggested.as_str();
6112 let sql = format!(
6113 "
6114 SELECT
6115 COALESCE(SUM(CASE WHEN n.kind = 'file' THEN 1 ELSE 0 END), 0),
6116 COALESCE(SUM(CASE WHEN n.kind = 'folder' THEN 1 ELSE 0 END), 0),
6117 COALESCE(SUM(CASE WHEN p.status = '{missing_status}' THEN 1 ELSE 0 END), 0),
6118 COALESCE(SUM(CASE WHEN p.status = '{stale_status}' THEN 1 ELSE 0 END), 0),
6119 COALESCE(SUM(CASE WHEN p.status = '{approved_status}' THEN 1 ELSE 0 END), 0),
6120 COALESCE(SUM(CASE WHEN p.status = '{suggested_status}' THEN 1 ELSE 0 END), 0)
6121 FROM nodes n
6122 JOIN purposes p ON p.node_id = n.id
6123 WHERE n.exists_now = 1
6124 "
6125 );
6126 let counts = self.connection.query_row(&sql, [], |row| {
6127 Ok((
6128 row.get::<_, i64>(0)?,
6129 row.get::<_, i64>(1)?,
6130 row.get::<_, i64>(2)?,
6131 row.get::<_, i64>(3)?,
6132 row.get::<_, i64>(4)?,
6133 row.get::<_, i64>(5)?,
6134 ))
6135 })?;
6136 Ok(Overview {
6137 files: count_to_usize("files", counts.0)?,
6138 folders: count_to_usize("folders", counts.1)?,
6139 missing_purposes: count_to_usize("missing_purposes", counts.2)?,
6140 stale_purposes: count_to_usize("stale_purposes", counts.3)?,
6141 approved_purposes: count_to_usize("approved_purposes", counts.4)?,
6142 suggested_purposes: count_to_usize("suggested_purposes", counts.5)?,
6143 })
6144 }
6145
6146 pub fn record_usage(&self, event: &UsageEvent) -> DbResult<()> {
6152 telemetry::validate_event(event, TelemetryRetentionPolicy::default())?;
6153 self.validated_project_root_identity
6154 .as_ref()
6155 .ok_or(DbError::ProjectRootIdentityMissing)?;
6156 let instance_id = self.library_usage_instance(&event.session_id, false)?;
6157 match self.record_usage_for_instance(
6158 instance_id,
6159 UsageInstanceOwner::LibraryHandle,
6160 event,
6161 false,
6162 ) {
6163 Err(DbError::TelemetryInstanceInactive) => {
6164 let replacement = self.library_usage_instance(&event.session_id, true)?;
6165 self.record_usage_for_instance(
6166 replacement,
6167 UsageInstanceOwner::LibraryHandle,
6168 event,
6169 false,
6170 )
6171 }
6172 Err(DbError::TelemetryBaselineCapacity) => {
6173 let replacement = telemetry::generate_usage_instance_id()?;
6174 self.seal_usage_instance(instance_id)?;
6175 self.library_usage_instances
6176 .borrow_mut()
6177 .insert(event.session_id.clone(), Some(replacement));
6178 self.record_usage_for_instance(
6179 replacement,
6180 UsageInstanceOwner::LibraryHandle,
6181 event,
6182 false,
6183 )
6184 }
6185 result => result,
6186 }
6187 }
6188
6189 fn library_usage_instance(
6191 &self,
6192 caller_label: &str,
6193 rotate: bool,
6194 ) -> DbResult<UsageInstanceId> {
6195 let mut instances = self.library_usage_instances.borrow_mut();
6196 if !rotate && let Some(instance) = instances.get(caller_label) {
6197 return instance.ok_or(DbError::TelemetryIdentityUnavailable);
6198 }
6199 if !instances.contains_key(caller_label)
6200 && instances.len() >= TelemetryRetentionPolicy::default().max_retained_labels
6201 {
6202 return Err(DbError::TelemetryInstanceCapacity);
6203 }
6204 let instance = telemetry::generate_usage_instance_id().ok();
6205 instances.insert(caller_label.to_string(), instance);
6206 instance.ok_or(DbError::TelemetryIdentityUnavailable)
6207 }
6208
6209 pub fn record_usage_for_instance(
6220 &self,
6221 instance_id: UsageInstanceId,
6222 owner: UsageInstanceOwner,
6223 event: &UsageEvent,
6224 seal_after_record: bool,
6225 ) -> DbResult<()> {
6226 self.record_usage_for_instance_origin(instance_id, owner, None, event, seal_after_record)
6227 }
6228
6229 pub fn record_usage_for_worktree_instance(
6237 &self,
6238 instance_id: UsageInstanceId,
6239 owner: UsageInstanceOwner,
6240 registration_id: i64,
6241 event: &UsageEvent,
6242 seal_after_record: bool,
6243 ) -> DbResult<()> {
6244 self.record_usage_for_instance_origin(
6245 instance_id,
6246 owner,
6247 Some(registration_id),
6248 event,
6249 seal_after_record,
6250 )
6251 }
6252
6253 fn record_usage_for_instance_origin(
6255 &self,
6256 instance_id: UsageInstanceId,
6257 owner: UsageInstanceOwner,
6258 registration_id: Option<i64>,
6259 event: &UsageEvent,
6260 seal_after_record: bool,
6261 ) -> DbResult<()> {
6262 let policy = TelemetryRetentionPolicy::default();
6263 let project_instance_id = self
6264 .validated_project_instance_id
6265 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
6266 let project_root_identity = self
6267 .validated_project_root_identity
6268 .as_ref()
6269 .ok_or(DbError::ProjectRootIdentityMissing)?;
6270 self.with_telemetry_connection(|connection| {
6271 with_validated_native_write_transaction(
6272 connection,
6273 Some(project_root_identity),
6274 Some(project_instance_id),
6275 |transaction| {
6276 telemetry::record_usage_for_project(
6277 transaction,
6278 project_instance_id,
6279 instance_id,
6280 owner,
6281 registration_id,
6282 event,
6283 policy,
6284 seal_after_record,
6285 )
6286 },
6287 )?;
6288 drop(telemetry::maintain_after_commit_for_native_project(
6292 connection,
6293 Some(project_root_identity),
6294 project_instance_id,
6295 policy,
6296 ));
6297 Ok(())
6298 })
6299 }
6300
6301 pub fn seal_usage_instance(&self, instance_id: UsageInstanceId) -> DbResult<()> {
6308 let project_root_identity = self
6309 .validated_project_root_identity
6310 .as_ref()
6311 .ok_or(DbError::ProjectRootIdentityMissing)?;
6312 self.with_telemetry_connection(|connection| {
6313 with_validated_native_write_transaction(
6314 connection,
6315 Some(project_root_identity),
6316 self.validated_project_instance_id,
6317 |transaction| telemetry::seal_usage_instance(transaction, instance_id),
6318 )
6319 })
6320 }
6321
6322 pub fn telemetry_retention_state(&self) -> DbResult<TelemetryRetentionState> {
6328 telemetry::retention_state(&self.connection)
6329 }
6330
6331 pub fn usage_events(&self, session_id: Option<&str>) -> DbResult<Vec<UsageEvent>> {
6337 telemetry::usage_events(&self.connection, session_id)
6338 }
6339
6340 pub fn token_overview(&self, session_id: Option<&str>) -> DbResult<TokenOverview> {
6346 telemetry::token_overview(&self.connection, session_id)
6347 }
6348
6349 pub fn repository_token_overview(&self) -> DbResult<TokenOverview> {
6356 telemetry::repository_token_overview(&self.connection)
6357 }
6358
6359 pub fn registered_worktree_token_overview(
6366 &self,
6367 alias: &WorktreeAlias,
6368 ) -> DbResult<TokenOverview> {
6369 let registration = self.worktree_registration(alias)?;
6370 telemetry::worktree_token_overview(&self.connection, registration.registration_id)
6371 }
6372
6373 pub fn token_trends(
6379 &self,
6380 session_id: Option<&str>,
6381 window: TokenTrendWindow,
6382 ) -> DbResult<TokenTrendReport> {
6383 telemetry::token_trends(&self.connection, session_id, window)
6384 }
6385
6386 pub fn repository_token_trends(&self, window: TokenTrendWindow) -> DbResult<TokenTrendReport> {
6393 telemetry::repository_token_trends(&self.connection, window)
6394 }
6395
6396 pub fn registered_worktree_token_trends(
6403 &self,
6404 alias: &WorktreeAlias,
6405 window: TokenTrendWindow,
6406 ) -> DbResult<TokenTrendReport> {
6407 let registration = self.worktree_registration(alias)?;
6408 telemetry::worktree_token_trends(&self.connection, registration.registration_id, window)
6409 }
6410
6411 pub fn resolve_health_finding(&self, resolution: &HealthResolution) -> DbResult<()> {
6417 self.with_validated_write(|connection| {
6418 if !self.active_health_finding_matches(resolution)? {
6419 return Err(DbError::HealthFindingNotActive {
6420 finding_id: resolution.finding_id.clone(),
6421 category: resolution.category.clone(),
6422 path: resolution.path.clone(),
6423 });
6424 }
6425 connection.execute(
6426 "
6427 INSERT INTO health_resolutions(
6428 finding_id,
6429 category,
6430 path,
6431 related_path,
6432 rationale,
6433 resolved_by,
6434 resolved_at
6435 )
6436 VALUES(?1, ?2, ?3, ?4, ?5, 'agent', CURRENT_TIMESTAMP)
6437 ON CONFLICT(finding_id) DO UPDATE SET
6438 category = excluded.category,
6439 path = excluded.path,
6440 related_path = excluded.related_path,
6441 rationale = excluded.rationale,
6442 resolved_by = 'agent',
6443 resolved_at = CURRENT_TIMESTAMP
6444 ",
6445 params![
6446 resolution.finding_id,
6447 resolution.category,
6448 resolution.path,
6449 resolution.related_path,
6450 resolution.rationale,
6451 ],
6452 )?;
6453 Ok(())
6454 })
6455 }
6456
6457 fn active_health_finding_matches(&self, resolution: &HealthResolution) -> DbResult<bool> {
6459 const PAGE_SIZE: usize = 256;
6460 let mut start_index = 0_usize;
6461 loop {
6462 let page = self.unresolved_health_findings_page_current(&HealthQuery {
6463 start_index,
6464 limit: PAGE_SIZE,
6465 category: Some(resolution.category.clone()),
6466 severity: Some(Severity::Warning),
6467 path_prefix: Some(resolution.path.clone()),
6468 summary_only: false,
6469 scope: HealthScope::all(),
6470 })?;
6471 if page.findings.iter().any(|finding| {
6472 finding.id == resolution.finding_id
6473 && finding.category == resolution.category
6474 && finding.path == resolution.path
6475 && finding.related_path == resolution.related_path
6476 }) {
6477 return Ok(true);
6478 }
6479 if page.returned == 0 || start_index + page.returned >= page.total {
6480 return Ok(false);
6481 }
6482 start_index += page.returned;
6483 }
6484 }
6485
6486 pub fn resolved_health_ids(&self) -> DbResult<Vec<String>> {
6492 let mut statement = self
6493 .connection
6494 .prepare("SELECT finding_id FROM health_resolutions ORDER BY finding_id")?;
6495 let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
6496 let mut ids = Vec::new();
6497 for row in rows {
6498 ids.push(row?);
6499 }
6500 Ok(ids)
6501 }
6502}
6503
6504fn begin_immediate_publication(connection: &Connection) -> DbResult<()> {
6506 connection.busy_timeout(SQLITE_PUBLICATION_ACQUIRE_TIMEOUT)?;
6507 let begin_result = connection.execute_batch("BEGIN IMMEDIATE");
6508 let restore_result = connection.busy_timeout(SQLITE_BUSY_TIMEOUT);
6509 match (begin_result, restore_result) {
6510 (Ok(()), Ok(())) => Ok(()),
6511 (Err(error), Ok(())) => Err(error.into()),
6512 (Ok(()), Err(error)) => Err(schema::rollback_after_error(connection, error.into())),
6513 (Err(operation), Err(restore)) => Err(DbError::PublicationAcquirePolicyRestore {
6514 operation: Box::new(operation),
6515 restore: Box::new(restore),
6516 }),
6517 }
6518}
6519
6520pub fn read_project_root_read_only(path: &Path) -> DbResult<Option<String>> {
6526 schema::read_project_root(path)
6527}
6528
6529pub fn read_legacy_project_root_candidate_read_only(path: &Path) -> DbResult<Option<String>> {
6545 let (preflight, _) = schema::inspect_compatibility(path, None)?;
6546 if preflight.state != SchemaState::UpgradeRequired {
6547 return Ok(None);
6548 }
6549 if schema::legacy_root_requires_native_authority(preflight.project_root.as_deref()) {
6550 return Err(DbError::ProjectRootIdentityMissing);
6551 }
6552 Ok(preflight.project_root)
6553}
6554
6555pub fn preflight_project_binding_read_only(path: &Path, root: &Path) -> DbResult<()> {
6567 let expected_identity = CanonicalProjectRoot::from_path(root)?;
6568 schema::preflight_for_project(path, &expected_identity).map(|_| ())
6569}
6570
6571pub fn read_project_root_identity_read_only(path: &Path) -> DbResult<Option<CanonicalProjectRoot>> {
6578 let location = sqlite_profile::inspect_database_location(path)?;
6579 if !location.database_exists {
6580 return Ok(None);
6581 }
6582 let (preflight, _) = schema::inspect_compatibility(path, None)?;
6583 if preflight.state != schema::SchemaState::Current {
6584 return Ok(None);
6585 }
6586 let connection = schema::open_current_read_only(path, None)?.0;
6587 let identity = project_identity::load_project_root_identity(&connection)?;
6588 connection.execute_batch("ROLLBACK")?;
6589 Ok(identity)
6590}
6591
6592pub fn verify_project_database(path: &Path, project_root: &Path) -> DbResult<()> {
6599 let identity = CanonicalProjectRoot::from_path(project_root)?;
6600 schema::verify_current_integrity_for_project(path, &identity)
6601}
6602
6603fn normalize_metadata_path(path: &Path) -> String {
6605 normalize_native_path_display(path)
6606}
6607
6608fn set_metadata(connection: &Connection, key: &str, value: &str) -> DbResult<()> {
6610 connection.execute(
6611 "
6612 INSERT INTO metadata(key, value)
6613 VALUES(?1, ?2)
6614 ON CONFLICT(key) DO UPDATE SET value = excluded.value
6615 ",
6616 [key, value],
6617 )?;
6618 Ok(())
6619}
6620
6621fn load_authored_purpose_revision(connection: &Connection) -> DbResult<u64> {
6623 let value = connection
6624 .prepare_cached("SELECT value FROM metadata WHERE key = ?1")?
6625 .query_row([AUTHORED_PURPOSE_REVISION_KEY], |row| {
6626 row.get::<_, String>(0)
6627 })
6628 .optional()?;
6629 value.map_or(Ok(0), |value| {
6630 value
6631 .parse::<u64>()
6632 .map_err(|source| DbError::InvalidInteger {
6633 field: AUTHORED_PURPOSE_REVISION_KEY,
6634 value,
6635 source,
6636 })
6637 })
6638}
6639
6640fn advance_authored_purpose_revision(connection: &Connection) -> DbResult<u64> {
6642 let current = load_authored_purpose_revision(connection)?;
6643 let revision = current
6644 .checked_add(1)
6645 .ok_or(DbError::IntegerMetadataOverflow {
6646 field: AUTHORED_PURPOSE_REVISION_KEY,
6647 value: current,
6648 })?;
6649 set_metadata(
6650 connection,
6651 AUTHORED_PURPOSE_REVISION_KEY,
6652 &revision.to_string(),
6653 )?;
6654 Ok(revision)
6655}
6656
6657fn load_file_text_fts_revisions(connection: &Connection) -> DbResult<Option<(u64, u64)>> {
6659 let (source, projection) = connection.query_row(
6660 "
6661 SELECT
6662 (SELECT value FROM metadata WHERE key = ?1),
6663 (SELECT value FROM metadata WHERE key = ?2)
6664 ",
6665 params![
6666 FILE_TEXT_FTS_SOURCE_REVISION_KEY,
6667 FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
6668 ],
6669 |row| {
6670 Ok((
6671 row.get::<_, Option<String>>(0)?,
6672 row.get::<_, Option<String>>(1)?,
6673 ))
6674 },
6675 )?;
6676 if source.is_none() && projection.is_none() {
6677 return Ok(None);
6678 }
6679 let (Some(source), Some(projection)) = (source, projection) else {
6680 return Err(DbError::FileTextFtsStateInvalid {
6681 reason: "only one FTS revision is present",
6682 });
6683 };
6684 let source_revision =
6685 source
6686 .parse::<u64>()
6687 .map_err(|source_error| DbError::InvalidInteger {
6688 field: FILE_TEXT_FTS_SOURCE_REVISION_KEY,
6689 value: source,
6690 source: source_error,
6691 })?;
6692 let projection_revision =
6693 projection
6694 .parse::<u64>()
6695 .map_err(|source_error| DbError::InvalidInteger {
6696 field: FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
6697 value: projection,
6698 source: source_error,
6699 })?;
6700 Ok(Some((source_revision, projection_revision)))
6701}
6702
6703fn begin_file_text_fts_update(connection: &Connection) -> DbResult<u64> {
6705 let (source, projection) =
6706 load_file_text_fts_revisions(connection)?.ok_or(DbError::FileTextFtsStateInvalid {
6707 reason: "FTS revisions are missing",
6708 })?;
6709 if source != projection {
6710 return Err(DbError::FileTextFtsStateInvalid {
6711 reason: "an earlier FTS revision is incomplete",
6712 });
6713 }
6714 let revision = source
6715 .checked_add(1)
6716 .ok_or(DbError::FileTextFtsStateInvalid {
6717 reason: "FTS revision overflowed",
6718 })?;
6719 set_metadata(
6720 connection,
6721 FILE_TEXT_FTS_SOURCE_REVISION_KEY,
6722 &revision.to_string(),
6723 )?;
6724 Ok(revision)
6725}
6726
6727fn complete_file_text_fts_update(connection: &Connection, revision: u64) -> DbResult<()> {
6729 let (source, projection) =
6730 load_file_text_fts_revisions(connection)?.ok_or(DbError::FileTextFtsStateInvalid {
6731 reason: "FTS revisions disappeared during an update",
6732 })?;
6733 if source != revision || projection.checked_add(1) != Some(revision) {
6734 return Err(DbError::FileTextFtsStateInvalid {
6735 reason: "FTS revisions changed during an update",
6736 });
6737 }
6738 set_metadata(
6739 connection,
6740 FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
6741 &revision.to_string(),
6742 )
6743}
6744
6745#[cfg(test)]
6747fn record_released_schema_eight_usage_event(
6748 connection: &Connection,
6749 event: &UsageEvent,
6750) -> DbResult<()> {
6751 connection.execute(
6752 "
6753 INSERT INTO usage_events(
6754 session_id,
6755 command,
6756 path,
6757 query,
6758 estimated_tokens_without_projectatlas,
6759 estimated_tokens_with_projectatlas,
6760 estimated_tokens_saved,
6761 token_savings_bucket,
6762 provider,
6763 model,
6764 tokenizer_backend,
6765 accuracy,
6766 baseline_kind,
6767 confidence,
6768 calculation_trace,
6769 accounting_layer,
6770 estimate_method,
6771 denominator_kind,
6772 baseline_identity,
6773 baseline_fingerprint,
6774 dedupe_scope,
6775 created_at
6776 )
6777 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, CURRENT_TIMESTAMP)
6778 ",
6779 params![
6780 event.session_id,
6781 event.command,
6782 event.path,
6783 event.query,
6784 option_usize_to_i64(
6785 "estimated_tokens_without_projectatlas",
6786 event.estimated_tokens_without_projectatlas,
6787 )?,
6788 option_usize_to_i64(
6789 "estimated_tokens_with_projectatlas",
6790 event.estimated_tokens_with_projectatlas,
6791 )?,
6792 event.estimated_tokens_saved,
6793 event.token_savings_bucket,
6794 event.provider,
6795 event.model,
6796 event.tokenizer_backend,
6797 event.accuracy,
6798 event.baseline_kind,
6799 event.confidence,
6800 event.calculation_trace,
6801 event.accounting_layer,
6802 event.estimate_method,
6803 event.denominator_kind,
6804 event.baseline_identity,
6805 event.baseline_fingerprint,
6806 event.dedupe_scope
6807 ],
6808 )?;
6809 Ok(())
6810}
6811
6812fn load_index_publication(connection: &Connection) -> DbResult<Option<IndexPublication>> {
6814 let row = connection
6815 .query_row(
6816 "
6817 SELECT state.value, fingerprint.value, generation.value
6818 FROM metadata AS state
6819 LEFT JOIN metadata AS fingerprint ON fingerprint.key = ?2
6820 LEFT JOIN metadata AS generation ON generation.key = ?3
6821 WHERE state.key = ?1
6822 ",
6823 params![
6824 INDEX_PUBLICATION_STATE_KEY,
6825 INDEX_PUBLICATION_FINGERPRINT_KEY,
6826 INDEX_PUBLICATION_GENERATION_KEY,
6827 ],
6828 |row| {
6829 Ok((
6830 row.get::<_, String>(0)?,
6831 row.get::<_, Option<String>>(1)?,
6832 row.get::<_, Option<String>>(2)?,
6833 ))
6834 },
6835 )
6836 .optional()?;
6837 let Some((state, contract_fingerprint, generation)) = row else {
6838 return Ok(None);
6839 };
6840 let generation = generation.map_or(Ok(IndexGeneration::ZERO), |value| {
6841 value
6842 .parse::<u64>()
6843 .map(IndexGeneration::new)
6844 .map_err(|source| DbError::InvalidInteger {
6845 field: INDEX_PUBLICATION_GENERATION_KEY,
6846 value,
6847 source,
6848 })
6849 })?;
6850 Ok(Some(IndexPublication {
6851 state: IndexPublicationState::from_db(state)?,
6852 contract_fingerprint,
6853 generation,
6854 }))
6855}
6856
6857fn mark_all_scan_nodes_absent(connection: &Connection) -> DbResult<()> {
6859 connection.execute("UPDATE nodes SET exists_now = 0", [])?;
6860 Ok(())
6861}
6862
6863fn delete_absent_scan_projections(connection: &Connection) -> DbResult<()> {
6865 content_classification::delete_absent_file_content_classifications(connection)?;
6866 let fts_revision = begin_file_text_fts_update(connection)?;
6867 connection.execute(
6868 "DELETE FROM symbol_relations WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6869 [],
6870 )?;
6871 connection.execute(
6872 "DELETE FROM symbols WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6873 [],
6874 )?;
6875 connection.execute(
6876 "DELETE FROM source_parse_metadata WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6877 [],
6878 )?;
6879 connection.execute(
6880 "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
6881 SELECT 'delete', rowid, content FROM file_texts \
6882 WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6883 [],
6884 )?;
6885 connection.execute(
6886 "DELETE FROM file_texts WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6887 [],
6888 )?;
6889 complete_file_text_fts_update(connection, fts_revision)?;
6890 Ok(())
6891}
6892
6893fn upsert_nodes(connection: &Connection, nodes: &[Node]) -> DbResult<()> {
6895 content_classification::delete_non_file_content_classifications(connection, nodes)?;
6896 let mut select_existing = connection.prepare_cached(
6897 "
6898 SELECT content_hash
6899 FROM nodes
6900 WHERE path = ?1
6901 ",
6902 )?;
6903 let mut upsert_node = connection.prepare_cached(
6904 "
6905 INSERT INTO nodes(path, kind, parent_path, extension, language, size_bytes, mtime_ns, content_hash, exists_now)
6906 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)
6907 ON CONFLICT(path) DO UPDATE SET
6908 kind = excluded.kind,
6909 parent_path = excluded.parent_path,
6910 extension = excluded.extension,
6911 language = excluded.language,
6912 size_bytes = excluded.size_bytes,
6913 mtime_ns = excluded.mtime_ns,
6914 content_hash = excluded.content_hash,
6915 exists_now = 1,
6916 last_seen_at = CURRENT_TIMESTAMP,
6917 last_indexed_at = CURRENT_TIMESTAMP
6918 ",
6919 )?;
6920 let mut ensure_file_classification = connection.prepare_cached(
6921 "INSERT INTO file_content_classifications(path, classification)
6922 SELECT ?1, 'opaque'
6923 WHERE ?2 = 'file'
6924 ON CONFLICT(path) DO NOTHING",
6925 )?;
6926 let mut select_node_id = connection.prepare_cached("SELECT id FROM nodes WHERE path = ?1")?;
6927 let mut ensure_purpose = connection.prepare_cached(
6928 "
6929 INSERT INTO purposes(node_id, purpose, source, status)
6930 VALUES(?1, NULL, 'missing', 'missing')
6931 ON CONFLICT(node_id) DO NOTHING
6932 ",
6933 )?;
6934 let mut upsert_summary = connection.prepare_cached(
6935 "
6936 INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
6937 VALUES(?1, 'node', '', ?2, CURRENT_TIMESTAMP)
6938 ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
6939 summary = CASE WHEN ?3 THEN excluded.summary ELSE summaries.summary END,
6940 updated_at = CURRENT_TIMESTAMP
6941 ",
6942 )?;
6943 for node in nodes {
6944 let size_bytes = node
6945 .size_bytes
6946 .map(|value| {
6947 i64::try_from(value).map_err(|_source| DbError::GraphCountOverflow {
6948 field: "nodes.size_bytes",
6949 value,
6950 })
6951 })
6952 .transpose()?;
6953 let existing = select_existing
6954 .query_row([&node.path], |row| row.get::<_, Option<String>>(0))
6955 .optional()?;
6956 let content_changed = existing.as_ref().is_some_and(|old_hash| {
6957 node.kind == NodeKind::File
6958 && old_hash.is_some()
6959 && node.content_hash.is_some()
6960 && old_hash != &node.content_hash
6961 });
6962 upsert_node.execute(params![
6963 node.path,
6964 node.kind.to_string(),
6965 node.parent_path,
6966 node.extension,
6967 node.language,
6968 size_bytes,
6969 node.mtime_ns,
6970 node.content_hash
6971 ])?;
6972 ensure_file_classification.execute(params![node.path, node.kind.to_string()])?;
6973 let node_id = select_node_id.query_row([&node.path], |row| row.get::<_, i64>(0))?;
6974 ensure_purpose.execute([node_id])?;
6975 upsert_summary.execute(params![
6976 node_id,
6977 generate_node_summary(node),
6978 content_changed
6979 ])?;
6980 }
6981 Ok(())
6982}
6983
6984fn file_text_from_row(row: &rusqlite::Row<'_>) -> DbResult<IndexedFileText> {
6986 let byte_count = count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?;
6987 let line_count = count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?;
6988 let text = IndexedFileText {
6989 path: row.get(0)?,
6990 content_hash: row.get(1)?,
6991 byte_count,
6992 line_count,
6993 content: row.get(4)?,
6994 };
6995 validate_indexed_file_text(&text)?;
6996 Ok(text)
6997}
6998
6999fn validate_indexed_file_text(text: &IndexedFileText) -> DbResult<()> {
7001 let actual_bytes = text.content.len();
7002 if text.byte_count != actual_bytes {
7003 return Err(DbError::FileTextMetadataMismatch {
7004 path: text.path.clone(),
7005 field: "byte_count",
7006 recorded: text.byte_count,
7007 actual: actual_bytes,
7008 });
7009 }
7010 let actual_lines = text.content.lines().count();
7011 if text.line_count != actual_lines {
7012 return Err(DbError::FileTextMetadataMismatch {
7013 path: text.path.clone(),
7014 field: "line_count",
7015 recorded: text.line_count,
7016 actual: actual_lines,
7017 });
7018 }
7019 Ok(())
7020}
7021
7022const SQLITE_READ_PROGRESS_OPS: i32 = 1_000;
7024
7025#[cfg(feature = "sqlite-progress-test-observer")]
7027pub mod sqlite_progress_test_observer {
7028 use projectatlas_core::IndexWorkStage;
7029 use std::cell::RefCell;
7030
7031 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
7033 pub enum SqliteReadProgressEvent {
7034 RepositoryRelationFamilyQueryEntered,
7036 RepositoryRelationFamilyQueryExited,
7038 CallbackEntered {
7040 stage: IndexWorkStage,
7042 },
7043 CallbackEvaluated {
7045 stage: IndexWorkStage,
7047 interrupted: bool,
7049 },
7050 }
7051
7052 type Observer = Box<dyn FnMut(SqliteReadProgressEvent)>;
7054
7055 thread_local! {
7056 static OBSERVER: RefCell<Option<Observer>> = RefCell::new(None);
7058 }
7059
7060 struct ObserverGuard {
7062 previous: Option<Observer>,
7064 }
7065
7066 impl Drop for ObserverGuard {
7067 fn drop(&mut self) {
7068 OBSERVER.with(|slot| {
7069 drop(slot.replace(self.previous.take()));
7070 });
7071 }
7072 }
7073
7074 pub fn observe_sqlite_read_progress<T>(
7076 observer: impl FnMut(SqliteReadProgressEvent) + 'static,
7077 operation: impl FnOnce() -> T,
7078 ) -> T {
7079 let previous = OBSERVER.with(|slot| slot.replace(Some(Box::new(observer))));
7080 let _guard = ObserverGuard { previous };
7081 operation()
7082 }
7083
7084 pub(crate) fn notify(event: SqliteReadProgressEvent) {
7086 OBSERVER.with(|slot| {
7087 if let Some(observer) = slot.borrow_mut().as_mut() {
7088 observer(event);
7089 }
7090 });
7091 }
7092}
7093
7094pub(crate) struct SqliteReadProgressGuard<'connection> {
7096 connection: &'connection Connection,
7098 armed: bool,
7100}
7101
7102impl<'connection> SqliteReadProgressGuard<'connection> {
7103 pub(crate) fn new(
7105 connection: &'connection Connection,
7106 control: Option<&IndexWorkControl>,
7107 stage: IndexWorkStage,
7108 ) -> DbResult<Self> {
7109 let armed = if let Some(control) = control {
7110 control.check(stage)?;
7111 let progress_control = control.clone();
7112 connection.progress_handler(
7113 SQLITE_READ_PROGRESS_OPS,
7114 Some(move || {
7115 #[cfg(feature = "sqlite-progress-test-observer")]
7116 sqlite_progress_test_observer::notify(
7117 sqlite_progress_test_observer::SqliteReadProgressEvent::CallbackEntered {
7118 stage,
7119 },
7120 );
7121 let interrupted = progress_control.check(stage).is_err();
7122 #[cfg(feature = "sqlite-progress-test-observer")]
7123 sqlite_progress_test_observer::notify(
7124 sqlite_progress_test_observer::SqliteReadProgressEvent::CallbackEvaluated {
7125 stage,
7126 interrupted,
7127 },
7128 );
7129 interrupted
7130 }),
7131 )?;
7132 true
7133 } else {
7134 false
7135 };
7136 Ok(Self { connection, armed })
7137 }
7138}
7139
7140impl Drop for SqliteReadProgressGuard<'_> {
7141 fn drop(&mut self) {
7142 if self.armed {
7143 drop(self.connection.progress_handler(0, None::<fn() -> bool>));
7144 }
7145 }
7146}
7147
7148pub(crate) fn with_sqlite_read_progress<T>(
7150 connection: &Connection,
7151 control: Option<&IndexWorkControl>,
7152 stage: IndexWorkStage,
7153 operation: impl FnOnce() -> DbResult<T>,
7154) -> DbResult<T> {
7155 let guard = SqliteReadProgressGuard::new(connection, control, stage)?;
7156 let result = operation();
7157 drop(guard);
7158 if result.as_ref().is_err_and(|error| {
7159 matches!(
7160 error,
7161 DbError::Sqlite(sqlite)
7162 if sqlite.sqlite_error_code() == Some(ErrorCode::OperationInterrupted)
7163 )
7164 }) && let Some(control) = control
7165 {
7166 control.check(stage)?;
7167 }
7168 result
7169}
7170
7171fn with_file_text_progress<T>(
7173 connection: &Connection,
7174 control: Option<&IndexWorkControl>,
7175 operation: impl FnOnce() -> DbResult<T>,
7176) -> DbResult<T> {
7177 with_sqlite_read_progress(connection, control, IndexWorkStage::TextIndex, operation)
7178}
7179
7180fn validate_file_text_fts_token(token: &str) -> DbResult<()> {
7182 if token.len() < 3 {
7183 return Err(DbError::FileTextFtsTokenUnsafe {
7184 reason: "token is shorter than three ASCII bytes",
7185 });
7186 }
7187 if !token.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
7188 return Err(DbError::FileTextFtsTokenUnsafe {
7189 reason: "token is not exclusively ASCII alphanumeric",
7190 });
7191 }
7192 Ok(())
7193}
7194
7195fn normalized_file_text_path_prefix(path_prefix: Option<&str>) -> Option<&str> {
7197 path_prefix
7198 .map(|prefix| prefix.trim_end_matches('/'))
7199 .filter(|prefix| !prefix.is_empty() && *prefix != ".")
7200}
7201
7202fn file_text_descendant_range(path_prefix: &str) -> (String, String) {
7204 (format!("{path_prefix}/"), format!("{path_prefix}0"))
7205}
7206
7207fn collect_file_text_fts_candidates(
7209 rows: &mut rusqlite::Rows<'_>,
7210 control: Option<&IndexWorkControl>,
7211 candidates: &mut Vec<FileTextFtsCandidate>,
7212) -> DbResult<()> {
7213 while let Some(row) = rows.next()? {
7214 if let Some(control) = control {
7215 control.check(IndexWorkStage::TextIndex)?;
7216 }
7217 let path = row.get::<_, String>(0)?;
7218 let bm25 = row.get::<_, f64>(4)?;
7219 if !bm25.is_finite() {
7220 return Err(DbError::FileTextFtsScoreInvalid { path });
7221 }
7222 candidates.push(FileTextFtsCandidate {
7223 path,
7224 content_hash: row.get(1)?,
7225 byte_count: count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?,
7226 line_count: count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?,
7227 bm25,
7228 });
7229 }
7230 Ok(())
7231}
7232
7233fn file_text_metadata_from_row(row: &rusqlite::Row<'_>) -> DbResult<FileTextMetadata> {
7235 let path = row.get::<_, String>(0)?;
7236 let classification = row
7237 .get::<_, Option<String>>(4)?
7238 .ok_or_else(|| DbError::FileContentClassificationMissing { path: path.clone() })?;
7239 Ok(FileTextMetadata {
7240 path,
7241 content_hash: row.get(1)?,
7242 byte_count: count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?,
7243 line_count: count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?,
7244 classification: ContentClassification::from_db(&classification).ok_or(
7245 DbError::InvalidEnum {
7246 field: "file_content_classifications.classification",
7247 value: classification,
7248 },
7249 )?,
7250 })
7251}
7252
7253fn visit_file_text_fallback_rows<A, V>(
7255 rows: &mut rusqlite::Rows<'_>,
7256 content_statement: &mut rusqlite::CachedStatement<'_>,
7257 control: Option<&IndexWorkControl>,
7258 admit: &mut A,
7259 visitor: &mut V,
7260) -> DbResult<()>
7261where
7262 A: FnMut(&FileTextMetadata) -> DbResult<FileTextAdmission>,
7263 V: FnMut(IndexedFileText) -> DbResult<bool>,
7264{
7265 while let Some(row) = rows.next()? {
7266 if let Some(control) = control {
7267 control.check(IndexWorkStage::TextIndex)?;
7268 }
7269 let metadata = file_text_metadata_from_row(row)?;
7270 match admit(&metadata)? {
7271 FileTextAdmission::Skip => continue,
7272 FileTextAdmission::Stop => return Ok(()),
7273 FileTextAdmission::Read => {}
7274 }
7275 let content =
7276 content_statement.query_row([&metadata.path], |content_row| content_row.get(0))?;
7277 let text = IndexedFileText {
7278 path: metadata.path,
7279 content_hash: metadata.content_hash,
7280 byte_count: metadata.byte_count,
7281 line_count: metadata.line_count,
7282 content,
7283 };
7284 validate_indexed_file_text(&text)?;
7285 if !visitor(text)? {
7286 return Ok(());
7287 }
7288 }
7289 Ok(())
7290}
7291
7292type IndexedNodeParts = (
7294 String,
7295 String,
7296 Option<String>,
7297 Option<String>,
7298 Option<String>,
7299 Option<u64>,
7300 Option<i64>,
7301 Option<String>,
7302 Option<String>,
7303 String,
7304 String,
7305 Option<String>,
7306);
7307
7308fn indexed_node_parts_from_sql_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<IndexedNodeParts> {
7310 let kind_value: String = row.get(1)?;
7311 let source_value: String = row.get(9)?;
7312 let status_value: String = row.get(10)?;
7313 Ok((
7314 row.get::<_, String>(0)?,
7315 kind_value,
7316 row.get::<_, Option<String>>(2)?,
7317 row.get::<_, Option<String>>(3)?,
7318 row.get::<_, Option<String>>(4)?,
7319 option_u64_from_sql(row, 5)?,
7320 row.get::<_, Option<i64>>(6)?,
7321 row.get::<_, Option<String>>(7)?,
7322 row.get::<_, Option<String>>(8)?,
7323 source_value,
7324 status_value,
7325 row.get::<_, Option<String>>(11)?,
7326 ))
7327}
7328
7329fn indexed_node_from_sql_row(row: &rusqlite::Row<'_>) -> DbResult<IndexedNode> {
7331 indexed_node_from_parts(indexed_node_parts_from_sql_row(row)?)
7332}
7333
7334fn indexed_node_parts_decoded_bytes(row: &IndexedNodeParts) -> DbResult<u64> {
7336 let lengths = [
7337 row.0.len(),
7338 row.1.len(),
7339 row.2.as_ref().map_or(0, String::len),
7340 row.3.as_ref().map_or(0, String::len),
7341 row.4.as_ref().map_or(0, String::len),
7342 row.7.as_ref().map_or(0, String::len),
7343 row.8.as_ref().map_or(0, String::len),
7344 row.9.len(),
7345 row.10.len(),
7346 row.11.as_ref().map_or(0, String::len),
7347 ];
7348 let mut bytes = 16_u64;
7349 for length in lengths {
7350 let length =
7351 u64::try_from(length).map_err(|_source| GraphContractError::InvalidLimits {
7352 reason: "purpose-owner decoded field length overflowed",
7353 })?;
7354 bytes = bytes
7355 .checked_add(length)
7356 .ok_or(GraphContractError::InvalidLimits {
7357 reason: "purpose-owner decoded row size overflowed",
7358 })?;
7359 }
7360 Ok(bytes)
7361}
7362
7363fn indexed_node_from_parts(row: IndexedNodeParts) -> DbResult<IndexedNode> {
7365 let (
7366 path,
7367 kind_value,
7368 parent_path,
7369 extension,
7370 language,
7371 size_bytes,
7372 mtime_ns,
7373 content_hash,
7374 purpose,
7375 source_value,
7376 status_value,
7377 summary,
7378 ) = row;
7379 let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
7380 field: "kind",
7381 value: kind_value,
7382 })?;
7383 let source = parse_source(&source_value)?;
7384 let status = PurposeStatus::from_db(&status_value).ok_or_else(|| DbError::InvalidEnum {
7385 field: "status",
7386 value: status_value,
7387 })?;
7388 Ok(IndexedNode {
7389 node: Node {
7390 path: path.clone(),
7391 kind,
7392 parent_path,
7393 extension,
7394 language,
7395 size_bytes,
7396 mtime_ns,
7397 content_hash,
7398 },
7399 purpose: Purpose {
7400 path,
7401 purpose,
7402 source,
7403 status,
7404 },
7405 summary,
7406 })
7407}
7408
7409fn normalize_query_terms(query: &str) -> Vec<String> {
7411 query
7412 .split(|character: char| !character.is_alphanumeric())
7413 .filter(|term| !term.is_empty())
7414 .map(str::to_lowercase)
7415 .collect()
7416}
7417
7418fn normalize_exact_ranked_query(query: &str) -> String {
7420 query.trim().replace('\\', "/").to_lowercase()
7421}
7422
7423fn reviewed_purpose_match_expression(term_count: usize) -> String {
7425 if term_count == 0 {
7426 return "0".to_string();
7427 }
7428 let matches = std::iter::repeat_n(
7429 "lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\'",
7430 term_count,
7431 )
7432 .collect::<Vec<_>>()
7433 .join(" OR ");
7434 format!(
7435 "CASE WHEN p.status = 'approved' AND p.source IN ('agent', 'human') \
7436 AND ({matches}) THEN 1 ELSE 0 END"
7437 )
7438}
7439
7440fn ranked_score_expression(term_count: usize) -> String {
7442 if term_count == 0 {
7443 return "1".to_string();
7444 }
7445 (0..term_count)
7446 .map(|_| {
7447 "(CASE WHEN lower(n.path) LIKE ? ESCAPE '\\' THEN 20 ELSE 0 END \
7448 + CASE WHEN p.status = 'approved' AND p.source IN ('agent', 'human') \
7449 AND lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\' THEN 30 ELSE 0 END \
7450 + CASE WHEN NOT (p.status = 'approved' AND p.source IN ('agent', 'human')) \
7451 AND lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\' THEN 2 ELSE 0 END \
7452 + CASE WHEN lower(COALESCE(s.summary, '')) LIKE ? ESCAPE '\\' THEN 10 ELSE 0 END \
7453 + CASE WHEN lower(COALESCE(symbol_summaries.summary, '')) LIKE ? ESCAPE '\\' THEN 25 ELSE 0 END)"
7454 .to_string()
7455 })
7456 .collect::<Vec<_>>()
7457 .join(" + ")
7458}
7459
7460fn sqlite_like_pattern(term: &str) -> String {
7462 format!("%{}%", sqlite_like_escape(term))
7463}
7464
7465fn sqlite_descendant_pattern(path: &str) -> String {
7467 format!("{}/%", sqlite_like_escape(path))
7468}
7469
7470fn sqlite_like_escape(value: &str) -> String {
7472 value
7473 .replace('\\', "\\\\")
7474 .replace('%', "\\%")
7475 .replace('_', "\\_")
7476}
7477
7478fn replace_symbol_search_summary(
7480 connection: &Connection,
7481 node_id: i64,
7482 summary: Option<&str>,
7483) -> DbResult<()> {
7484 if let Some(summary) = summary {
7485 connection
7486 .prepare_cached(
7487 "
7488 INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
7489 VALUES(?1, 'search', 'symbols', ?2, CURRENT_TIMESTAMP)
7490 ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
7491 summary = excluded.summary,
7492 updated_at = CURRENT_TIMESTAMP
7493 ",
7494 )?
7495 .execute(params![node_id, summary])?;
7496 } else {
7497 connection
7498 .prepare_cached(
7499 "
7500 DELETE FROM summaries
7501 WHERE node_id = ?1
7502 AND summary_level = 'search'
7503 AND subject = 'symbols'
7504 ",
7505 )?
7506 .execute([node_id])?;
7507 }
7508 Ok(())
7509}
7510
7511fn symbol_search_summary(graph: &SymbolGraph) -> Option<String> {
7513 let mut names = graph
7514 .symbols
7515 .iter()
7516 .filter(|symbol| !matches!(symbol.kind, SymbolKind::Import | SymbolKind::Unknown))
7517 .map(|symbol| symbol.name.trim())
7518 .filter(|name| !name.is_empty())
7519 .map(ToString::to_string)
7520 .collect::<Vec<_>>();
7521 names.sort();
7522 names.dedup();
7523 if names.is_empty() {
7524 return None;
7525 }
7526 let summary = format!("symbols {}", names.join(" "));
7527 Some(truncate_summary_chars(
7528 &summary,
7529 MAX_SYMBOL_SEARCH_SUMMARY_CHARS,
7530 ))
7531}
7532
7533fn truncate_summary_chars(value: &str, max_chars: usize) -> String {
7535 if value.chars().count() <= max_chars {
7536 return value.to_string();
7537 }
7538 value.chars().take(max_chars).collect()
7539}
7540
7541fn parse_source(value: &str) -> DbResult<PurposeSource> {
7543 let source = match value {
7544 value if value == PurposeSource::Missing.as_str() => PurposeSource::Missing,
7545 value if value == PurposeSource::Imported.as_str() => PurposeSource::Imported,
7546 value if value == PurposeSource::Generated.as_str() => PurposeSource::Generated,
7547 value if value == PurposeSource::Agent.as_str() || value == LEGACY_HUMAN_PURPOSE_SOURCE => {
7550 PurposeSource::Agent
7551 }
7552 _ => {
7553 return Err(DbError::InvalidEnum {
7554 field: "source",
7555 value: value.to_string(),
7556 });
7557 }
7558 };
7559 Ok(source)
7560}
7561
7562fn normalize_purpose_curation_task(task: &str) -> DbResult<String> {
7564 let normalized = task.split_whitespace().collect::<Vec<_>>().join(" ");
7565 if normalized.is_empty() {
7566 return Err(DbError::PurposeCurationTaskInvalid {
7567 reason: "task must not be blank",
7568 });
7569 }
7570 if normalized.len() > MAX_PURPOSE_CURATION_TASK_BYTES {
7571 return Err(DbError::PurposeCurationTaskInvalid {
7572 reason: "task exceeds the UTF-8 byte limit",
7573 });
7574 }
7575 if normalized.chars().any(char::is_control) {
7576 return Err(DbError::PurposeCurationTaskInvalid {
7577 reason: "task contains control characters",
7578 });
7579 }
7580 Ok(normalized)
7581}
7582
7583fn load_current_purpose_state(
7585 connection: &Connection,
7586 path: &str,
7587) -> DbResult<Option<(i64, Purpose)>> {
7588 let row = connection
7589 .prepare_cached(
7590 "
7591 SELECT n.id, p.purpose, p.source, p.status
7592 FROM nodes n
7593 JOIN purposes p ON p.node_id = n.id
7594 WHERE n.exists_now = 1 AND n.path = ?1
7595 ",
7596 )?
7597 .query_row([path], |row| {
7598 Ok((
7599 row.get::<_, i64>(0)?,
7600 row.get::<_, Option<String>>(1)?,
7601 row.get::<_, String>(2)?,
7602 row.get::<_, String>(3)?,
7603 ))
7604 })
7605 .optional()?;
7606 row.map(|(node_id, purpose, source, status)| {
7607 let source = parse_source(&source)?;
7608 let status = PurposeStatus::from_db(&status).ok_or_else(|| DbError::InvalidEnum {
7609 field: "purpose_status",
7610 value: status,
7611 })?;
7612 Ok((
7613 node_id,
7614 Purpose {
7615 path: path.to_string(),
7616 purpose,
7617 source,
7618 status,
7619 },
7620 ))
7621 })
7622 .transpose()
7623}
7624
7625fn apply_conditional_purpose(
7627 connection: &Connection,
7628 project: ProjectInstanceId,
7629 generation: IndexGeneration,
7630 request: &PurposeConditionalApplyRequest,
7631) -> DbResult<PurposeConditionalApplyResult> {
7632 let current = load_current_purpose_state(connection, &request.path)?;
7633 let Some((node_id, current_purpose)) = current else {
7634 return Ok(PurposeConditionalApplyResult {
7635 path: request.path.clone(),
7636 state: PurposeConditionalApplyState::PathUnavailable,
7637 current_purpose: None,
7638 });
7639 };
7640 if !matches!(
7641 current_purpose.status,
7642 PurposeStatus::Missing | PurposeStatus::Suggested
7643 ) {
7644 return Ok(PurposeConditionalApplyResult {
7645 path: request.path.clone(),
7646 state: PurposeConditionalApplyState::Accepted,
7647 current_purpose: Some(current_purpose),
7648 });
7649 }
7650 let current_work_key =
7651 purpose_curation_item_work_key(project, generation, &request.task, &request.path);
7652 let current_state_token = purpose_curation_state_token(¤t_work_key, ¤t_purpose);
7653 if current_work_key != request.work_key || current_state_token != request.state_token {
7654 return Ok(PurposeConditionalApplyResult {
7655 path: request.path.clone(),
7656 state: PurposeConditionalApplyState::Stale,
7657 current_purpose: Some(current_purpose),
7658 });
7659 }
7660 let generation_sql =
7661 i64::try_from(generation.get()).map_err(|_source| DbError::GraphCountOverflow {
7662 field: "project_identity.active_generation",
7663 value: generation.get(),
7664 })?;
7665 let changed = connection
7666 .prepare_cached(
7667 "
7668 UPDATE purposes
7669 SET purpose = ?2,
7670 source = ?3,
7671 status = ?4,
7672 updated_at = CURRENT_TIMESTAMP
7673 WHERE node_id = ?1
7674 AND status = ?5
7675 AND source = ?6
7676 AND purpose IS ?7
7677 AND EXISTS (
7678 SELECT 1
7679 FROM nodes n
7680 JOIN project_identity pi ON pi.singleton = 1
7681 WHERE n.id = ?1
7682 AND n.exists_now = 1
7683 AND n.path = ?8
7684 AND pi.project_instance_id = ?9
7685 AND pi.active_generation = ?10
7686 )
7687 ",
7688 )?
7689 .execute(params![
7690 node_id,
7691 request.purpose,
7692 PurposeSource::Agent.as_str(),
7693 PurposeStatus::Approved.as_str(),
7694 current_purpose.status.as_str(),
7695 current_purpose.source.as_str(),
7696 current_purpose.purpose,
7697 request.path,
7698 &project.as_bytes()[..],
7699 generation_sql,
7700 ])?;
7701 if changed == 1 {
7702 Ok(PurposeConditionalApplyResult {
7703 path: request.path.clone(),
7704 state: PurposeConditionalApplyState::Applied,
7705 current_purpose: Some(Purpose {
7706 path: request.path.clone(),
7707 purpose: Some(request.purpose.clone()),
7708 source: PurposeSource::Agent,
7709 status: PurposeStatus::Approved,
7710 }),
7711 })
7712 } else {
7713 Ok(PurposeConditionalApplyResult {
7714 path: request.path.clone(),
7715 state: PurposeConditionalApplyState::Stale,
7716 current_purpose: Some(current_purpose),
7717 })
7718 }
7719}
7720
7721fn purpose_curation_candidate(
7723 project: ProjectInstanceId,
7724 generation: IndexGeneration,
7725 task: &str,
7726 node: IndexedNode,
7727) -> PurposeCurationCandidate {
7728 let work_key = purpose_curation_item_work_key(project, generation, task, &node.node.path);
7729 let state_token = purpose_curation_state_token(&work_key, &node.purpose);
7730 PurposeCurationCandidate {
7731 node,
7732 work_key,
7733 state_token,
7734 }
7735}
7736
7737fn purpose_curation_item_work_key(
7739 project: ProjectInstanceId,
7740 generation: IndexGeneration,
7741 task: &str,
7742 path: &str,
7743) -> String {
7744 digest_fields(
7745 PURPOSE_CURATION_ITEM_KEY_DOMAIN,
7746 &[
7747 &project.as_bytes(),
7748 &generation.get().to_le_bytes(),
7749 task.as_bytes(),
7750 path.as_bytes(),
7751 ],
7752 )
7753}
7754
7755fn purpose_curation_state_token(work_key: &str, purpose: &Purpose) -> String {
7757 let purpose_presence = [u8::from(purpose.purpose.is_some())];
7758 digest_fields(
7759 PURPOSE_CURATION_STATE_TOKEN_DOMAIN,
7760 &[
7761 work_key.as_bytes(),
7762 &purpose_presence,
7763 purpose.purpose.as_deref().unwrap_or_default().as_bytes(),
7764 purpose.source.as_str().as_bytes(),
7765 purpose.status.as_str().as_bytes(),
7766 ],
7767 )
7768}
7769
7770fn purpose_curation_batch_work_key(
7772 project: ProjectInstanceId,
7773 generation: IndexGeneration,
7774 task: &str,
7775 items: &[PurposeCurationCandidate],
7776) -> String {
7777 let mut hasher = Hasher::new();
7778 digest_field(&mut hasher, PURPOSE_CURATION_BATCH_KEY_DOMAIN.as_bytes());
7779 digest_field(&mut hasher, &project.as_bytes());
7780 digest_field(&mut hasher, &generation.get().to_le_bytes());
7781 digest_field(&mut hasher, task.as_bytes());
7782 for item in items {
7783 digest_field(&mut hasher, item.work_key.as_bytes());
7784 digest_field(&mut hasher, item.state_token.as_bytes());
7785 }
7786 hasher.finalize().to_hex().to_string()
7787}
7788
7789fn digest_fields(domain: &str, fields: &[&[u8]]) -> String {
7791 let mut hasher = Hasher::new();
7792 digest_field(&mut hasher, domain.as_bytes());
7793 for field in fields {
7794 digest_field(&mut hasher, field);
7795 }
7796 hasher.finalize().to_hex().to_string()
7797}
7798
7799fn digest_field(hasher: &mut Hasher, value: &[u8]) {
7801 let length = u64::try_from(value.len()).unwrap_or(u64::MAX);
7802 hasher.update(&length.to_le_bytes());
7803 hasher.update(value);
7804}
7805
7806fn count_to_usize(field: &'static str, value: i64) -> DbResult<usize> {
7808 usize::try_from(value).map_err(|source| DbError::InvalidCount {
7809 field,
7810 value,
7811 source,
7812 })
7813}
7814
7815fn usize_to_i64(value: usize) -> i64 {
7817 i64::try_from(value).unwrap_or(i64::MAX)
7818}
7819
7820fn i64_to_usize(value: i64) -> usize {
7822 usize::try_from(value.max(0)).unwrap_or(usize::MAX)
7823}
7824
7825fn option_u64_from_sql(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<Option<u64>> {
7827 row.get::<_, Option<i64>>(index)?
7828 .map(|value| {
7829 u64::try_from(value).map_err(|source| {
7830 rusqlite::Error::FromSqlConversionFailure(
7831 index,
7832 rusqlite::types::Type::Integer,
7833 Box::new(source),
7834 )
7835 })
7836 })
7837 .transpose()
7838}
7839
7840#[cfg(test)]
7842fn option_usize_to_i64(field: &'static str, value: Option<usize>) -> DbResult<Option<i64>> {
7843 value
7844 .map(|value| {
7845 i64::try_from(value).map_err(|_source| DbError::TelemetryIntegerOverflow { field })
7846 })
7847 .transpose()
7848}
7849
7850fn classified_symbols_sql(
7852 file: Option<&str>,
7853 query: Option<&str>,
7854 selection: ContentSelection,
7855 limit: usize,
7856) -> (String, Vec<Value>) {
7857 let mut predicates = Vec::new();
7858 let mut bindings = Vec::new();
7859 if let Some(file) = file {
7860 bindings.push(Value::Text(file.to_string()));
7861 predicates.push(format!("symbol.path = ?{}", bindings.len()));
7862 }
7863 if let Some(query) = query {
7864 bindings.push(Value::Text(like_query(query)));
7865 let placeholder = bindings.len();
7866 let path_predicate = if file.is_some() {
7867 String::new()
7868 } else {
7869 format!(" OR symbol.path LIKE ?{placeholder}")
7870 };
7871 predicates.push(format!(
7872 "(symbol.name LIKE ?{placeholder} OR symbol.signature LIKE ?{placeholder} \
7873 OR symbol.documentation LIKE ?{placeholder}{path_predicate})"
7874 ));
7875 }
7876 match selection {
7877 ContentSelection::UnspecifiedLegacy => {}
7878 ContentSelection::Source => {
7879 bindings.push(Value::Text(
7880 ContentClassification::Source.as_str().to_string(),
7881 ));
7882 predicates.push(format!(
7883 "(classification.classification = ?{} OR classification.path IS NULL)",
7884 bindings.len()
7885 ));
7886 }
7887 ContentSelection::Documentation => {
7888 bindings.push(Value::Text(
7889 ContentClassification::Documentation.as_str().to_string(),
7890 ));
7891 predicates.push(format!(
7892 "(classification.classification = ?{} OR classification.path IS NULL)",
7893 bindings.len()
7894 ));
7895 }
7896 ContentSelection::Both => {
7897 bindings.push(Value::Text(
7898 ContentClassification::Source.as_str().to_string(),
7899 ));
7900 let source = bindings.len();
7901 bindings.push(Value::Text(
7902 ContentClassification::Documentation.as_str().to_string(),
7903 ));
7904 let documentation = bindings.len();
7905 predicates.push(format!(
7906 "(classification.classification IN (?{source}, ?{documentation}) \
7907 OR classification.path IS NULL)"
7908 ));
7909 }
7910 }
7911 bindings.push(Value::Integer(usize_to_i64(limit.max(1))));
7912 let limit = bindings.len();
7913 let where_clause = if predicates.is_empty() {
7914 String::new()
7915 } else {
7916 format!("WHERE {}", predicates.join(" AND "))
7917 };
7918 let sql = format!(
7919 "SELECT symbol.path, symbol.language, symbol.name, symbol.kind,
7920 symbol.signature, symbol.line_start, symbol.line_end, symbol.parent,
7921 symbol.parser, symbol.detail, symbol.exported, symbol.documentation,
7922 symbol.source_byte_start, symbol.source_byte_end,
7923 symbol.source_column_start, symbol.source_column_end,
7924 classification.classification
7925 FROM symbols AS symbol INDEXED BY idx_symbols_path
7926 LEFT JOIN file_content_classifications AS classification
7927 ON classification.path = symbol.path
7928 {where_clause}
7929 ORDER BY symbol.path, symbol.line_start, symbol.name
7930 LIMIT ?{limit}"
7931 );
7932 (sql, bindings)
7933}
7934
7935fn symbol_source_selector_values(symbol: &CodeSymbol) -> DbResult<[Option<i64>; 4]> {
7937 let Some(selector) = symbol.source_selector else {
7938 return Ok([None; 4]);
7939 };
7940 if symbol.line_start == 0
7941 || symbol.line_end < symbol.line_start
7942 || selector.byte_end < selector.byte_start
7943 || (symbol.line_start == symbol.line_end && selector.column_end < selector.column_start)
7944 {
7945 return Err(DbError::SymbolGraphRowShape {
7946 path: symbol.path.clone(),
7947 reason: "symbol source selector range is invalid",
7948 });
7949 }
7950 let narrow = |value| match i64::try_from(value) {
7951 Ok(value) => Ok(Some(value)),
7952 Err(_) => Err(DbError::SymbolGraphRowShape {
7953 path: symbol.path.clone(),
7954 reason: "symbol source selector exceeds the SQLite integer range",
7955 }),
7956 };
7957 Ok([
7958 narrow(selector.byte_start)?,
7959 narrow(selector.byte_end)?,
7960 narrow(selector.column_start)?,
7961 narrow(selector.column_end)?,
7962 ])
7963}
7964
7965fn symbol_source_selector_from_row(
7967 row: &rusqlite::Row<'_>,
7968 line_start: usize,
7969 line_end: usize,
7970) -> rusqlite::Result<Option<SymbolSourceSelector>> {
7971 let raw = [
7972 row.get::<_, Option<i64>>(12)?,
7973 row.get::<_, Option<i64>>(13)?,
7974 row.get::<_, Option<i64>>(14)?,
7975 row.get::<_, Option<i64>>(15)?,
7976 ];
7977 let [
7978 Some(byte_start),
7979 Some(byte_end),
7980 Some(column_start),
7981 Some(column_end),
7982 ] = raw
7983 else {
7984 if raw.iter().all(Option::is_none) {
7985 return Ok(None);
7986 }
7987 return Err(invalid_symbol_source_selector(
7988 "symbol source selector columns are only partially populated",
7989 ));
7990 };
7991 if byte_start < 0
7992 || byte_end < byte_start
7993 || column_start < 0
7994 || column_end < 0
7995 || line_start == 0
7996 || line_end < line_start
7997 || (line_start == line_end && column_end < column_start)
7998 {
7999 return Err(invalid_symbol_source_selector(
8000 "symbol source selector range is invalid",
8001 ));
8002 }
8003 Ok(Some(SymbolSourceSelector {
8004 byte_start: symbol_source_selector_usize(byte_start)?,
8005 byte_end: symbol_source_selector_usize(byte_end)?,
8006 column_start: symbol_source_selector_usize(column_start)?,
8007 column_end: symbol_source_selector_usize(column_end)?,
8008 }))
8009}
8010
8011fn symbol_source_selector_usize(value: i64) -> rusqlite::Result<usize> {
8013 usize::try_from(value).map_err(|source| {
8014 rusqlite::Error::FromSqlConversionFailure(
8015 12,
8016 rusqlite::types::Type::Integer,
8017 Box::new(source),
8018 )
8019 })
8020}
8021
8022fn invalid_symbol_source_selector(message: &'static str) -> rusqlite::Error {
8024 rusqlite::Error::FromSqlConversionFailure(
8025 12,
8026 rusqlite::types::Type::Integer,
8027 Box::new(std::io::Error::other(message)),
8028 )
8029}
8030
8031fn code_symbol_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CodeSymbol> {
8033 let line_start = i64_to_usize(row.get::<_, i64>(5)?);
8034 let line_end = i64_to_usize(row.get::<_, i64>(6)?);
8035 Ok(CodeSymbol {
8036 path: row.get(0)?,
8037 language: row.get(1)?,
8038 name: row.get(2)?,
8039 kind: SymbolKind::from_db(row.get_ref(3)?.as_str()?),
8040 signature: row.get(4)?,
8041 line_start,
8042 line_end,
8043 source_selector: symbol_source_selector_from_row(row, line_start, line_end)?,
8044 parent: row.get(7)?,
8045 parser: ParserKind::from_db(row.get_ref(8)?.as_str()?),
8046 detail: row.get(9)?,
8047 exported: row.get::<_, i64>(10)? != 0,
8048 documentation: row.get(11)?,
8049 })
8050}
8051
8052fn classified_symbol_from_row(row: &rusqlite::Row<'_>) -> DbResult<ClassifiedSymbol> {
8054 let symbol = code_symbol_from_row(row)?;
8055 let classification = row.get::<_, Option<String>>(16)?.ok_or_else(|| {
8056 DbError::FileContentClassificationMissing {
8057 path: symbol.path.clone(),
8058 }
8059 })?;
8060 Ok(ClassifiedSymbol {
8061 symbol,
8062 classification: content_classification::parse_classification(classification)?,
8063 })
8064}
8065
8066fn code_symbol_preflight_bytes(row: &rusqlite::Row<'_>) -> DbResult<u64> {
8068 let string_bytes = row.get::<_, i64>(16)?;
8069 let string_bytes = u64::try_from(string_bytes).map_err(|source| DbError::InvalidCount {
8070 field: "symbol persisted field bytes",
8071 value: string_bytes,
8072 source,
8073 })?;
8074 let row_bytes = u64::try_from(std::mem::size_of::<CodeSymbol>()).map_err(|source| {
8075 DbError::InvalidCount {
8076 field: "symbol persisted field bytes",
8077 value: i64::MAX,
8078 source,
8079 }
8080 })?;
8081 row_bytes.checked_add(string_bytes).ok_or_else(|| {
8082 GraphContractError::InvalidLimits {
8083 reason: "symbol persisted row bytes overflowed",
8084 }
8085 .into()
8086 })
8087}
8088
8089fn stored_import_relation_from_row(
8091 row: &rusqlite::Row<'_>,
8092) -> rusqlite::Result<StoredImportRelation> {
8093 Ok(StoredImportRelation {
8094 path: row.get(0)?,
8095 source_name: row.get(1)?,
8096 target_name: row.get(2)?,
8097 line: i64_to_usize(row.get::<_, i64>(3)?),
8098 })
8099}
8100
8101fn code_symbol_decoded_bytes(symbol: &CodeSymbol) -> DbResult<u64> {
8103 let capacities = [
8104 symbol.path.capacity(),
8105 symbol.language.as_ref().map_or(0, String::capacity),
8106 symbol.name.capacity(),
8107 symbol.signature.capacity(),
8108 symbol.documentation.as_ref().map_or(0, String::capacity),
8109 symbol.parent.as_ref().map_or(0, String::capacity),
8110 symbol.detail.as_ref().map_or(0, String::capacity),
8111 ];
8112 let mut bytes = u64::try_from(std::mem::size_of::<CodeSymbol>()).map_err(|source| {
8113 DbError::InvalidCount {
8114 field: "symbol decoded field bytes",
8115 value: i64::MAX,
8116 source,
8117 }
8118 })?;
8119 for capacity in capacities {
8120 let capacity = u64::try_from(capacity).map_err(|source| DbError::InvalidCount {
8121 field: "symbol decoded field bytes",
8122 value: i64::MAX,
8123 source,
8124 })?;
8125 bytes = bytes
8126 .checked_add(capacity)
8127 .ok_or(GraphContractError::InvalidLimits {
8128 reason: "symbol decoded row bytes overflowed",
8129 })?;
8130 }
8131 Ok(bytes)
8132}
8133
8134fn like_query(query: &str) -> String {
8136 format!("%{query}%")
8137}
8138
8139fn load_nodes_by_paths_sql(path_count: usize) -> String {
8141 let placeholders = numbered_placeholders(1, path_count);
8142 format!(
8143 "
8144 SELECT
8145 n.path,
8146 n.kind,
8147 n.parent_path,
8148 n.extension,
8149 n.language,
8150 n.size_bytes,
8151 n.mtime_ns,
8152 n.content_hash,
8153 p.purpose,
8154 p.source,
8155 p.status,
8156 s.summary
8157 FROM nodes n
8158 JOIN purposes p ON p.node_id = n.id
8159 LEFT JOIN summaries s ON s.node_id = n.id
8160 AND s.summary_level = 'node'
8161 AND s.subject = ''
8162 WHERE n.exists_now = 1 AND n.path IN ({placeholders})
8163 ORDER BY n.path
8164 "
8165 )
8166}
8167
8168fn numbered_placeholders(start: usize, count: usize) -> String {
8170 (start..start + count)
8171 .map(|index| format!("?{index}"))
8172 .collect::<Vec<_>>()
8173 .join(", ")
8174}
8175
8176fn generate_node_summary(node: &Node) -> String {
8178 match node.kind {
8179 NodeKind::Folder => format!("Folder for {}", path_label(&node.path)),
8180 NodeKind::File => file_summary(node),
8181 }
8182}
8183
8184fn file_summary(node: &Node) -> String {
8186 let language = node
8187 .language
8188 .as_deref()
8189 .or(node.extension.as_deref())
8190 .unwrap_or("unknown");
8191 let size = node.size_bytes.map_or_else(
8192 || "unknown size".to_string(),
8193 |bytes| format!("{bytes} bytes"),
8194 );
8195 format!("{language} file, {size}")
8196}
8197
8198fn path_label(path: &str) -> String {
8200 if path == "." {
8201 return "repository root".to_string();
8202 }
8203 path.rsplit('/')
8204 .next()
8205 .filter(|value| !value.is_empty())
8206 .unwrap_or(path)
8207 .replace(['-', '_'], " ")
8208}
8209
8210fn emit_unresolved_finding<F>(
8212 finding: HealthFinding,
8213 resolved_ids: &HashSet<String>,
8214 visitor: &mut F,
8215) -> DbResult<bool>
8216where
8217 F: FnMut(HealthFinding) -> DbResult<bool>,
8218{
8219 if resolved_ids.contains(&finding.id) {
8220 return Ok(true);
8221 }
8222 visitor(finding)
8223}
8224
8225fn purpose_health_spec_matches_query(spec: PurposeHealthSpec, query: &HealthQuery) -> bool {
8227 health_category_matches_query(spec.category, Severity::Warning, query)
8228}
8229
8230fn health_category_matches_query(category: &str, severity: Severity, query: &HealthQuery) -> bool {
8232 query
8233 .category
8234 .as_deref()
8235 .is_none_or(|requested| category.eq_ignore_ascii_case(requested))
8236 && query.severity.is_none_or(|requested| severity == requested)
8237}
8238
8239fn purpose_health_spec_for_status(status: &str) -> DbResult<PurposeHealthSpec> {
8241 PURPOSE_HEALTH_SPECS
8242 .iter()
8243 .copied()
8244 .find(|spec| spec.status == status)
8245 .ok_or_else(|| DbError::InvalidEnum {
8246 field: "status",
8247 value: status.to_string(),
8248 })
8249}
8250
8251fn agent_review_required_finding(path: String) -> HealthFinding {
8253 HealthFinding {
8254 id: finding_id(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED, &path, None),
8255 severity: Severity::Warning,
8256 category: CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
8257 path,
8258 related_path: None,
8259 message: MESSAGE_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
8260 recommendation: RECOMMENDATION_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
8261 }
8262}
8263
8264fn purpose_lifecycle_where_clause(
8266 specs: &[PurposeHealthSpec],
8267 path_prefix: Option<&str>,
8268 resolution_filter: HealthResolutionFilter<'_>,
8269 scope: HealthScope,
8270) -> (String, Vec<Value>) {
8271 let statuses = specs
8272 .iter()
8273 .map(|spec| format!("'{}'", spec.status))
8274 .collect::<Vec<_>>()
8275 .join(", ");
8276 let mut clauses = vec![
8277 "n.exists_now = 1".to_string(),
8278 format!("p.status IN ({statuses})"),
8279 ];
8280 let mut values = Vec::new();
8281
8282 if source_filter_applies_before_queue(scope) {
8283 clauses.push(source_relevant_node_expression("n"));
8284 }
8285 if scope.high_impact_queue() {
8286 clauses.push(purpose_default_queue_node_expression("n", "p", scope));
8287 }
8288
8289 let normalized_prefix = path_prefix
8290 .map(normalize_repo_path_prefix)
8291 .filter(|prefix| prefix != ".");
8292 if let Some(prefix) = normalized_prefix {
8293 clauses.push(format!(
8294 "(n.path = ?{} OR n.path LIKE ?{} ESCAPE '\\')",
8295 values.len() + 1,
8296 values.len() + 2
8297 ));
8298 values.push(Value::from(prefix.clone()));
8299 values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8300 }
8301
8302 match resolution_filter {
8303 HealthResolutionFilter::Explicit(resolved_ids) => {
8304 for spec in specs {
8305 let resolved_paths = resolved_purpose_paths(resolved_ids, spec.category);
8306 if !resolved_paths.is_empty() {
8307 clauses.push(format!(
8308 "NOT (p.status = '{}' AND n.path IN ({}))",
8309 spec.status,
8310 numbered_placeholders(values.len() + 1, resolved_paths.len())
8311 ));
8312 values.extend(resolved_paths.into_iter().map(Value::from));
8313 }
8314 }
8315 }
8316 HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(
8317 &purpose_lifecycle_finding_id_expression(specs, "n", "p"),
8318 )),
8319 }
8320
8321 (clauses.join(" AND "), values)
8322}
8323
8324fn purpose_status_where_clause(
8326 spec: PurposeHealthSpec,
8327 path_prefix: Option<&str>,
8328 resolution_filter: HealthResolutionFilter<'_>,
8329 scope: HealthScope,
8330) -> (String, Vec<Value>) {
8331 let mut clauses = vec!["n.exists_now = 1".to_string(), "p.status = ?1".to_string()];
8332 let mut values = vec![Value::from(spec.status.to_string())];
8333
8334 if source_filter_applies_before_queue(scope) {
8335 clauses.push(source_relevant_node_expression("n"));
8336 }
8337 if scope.high_impact_queue() {
8338 clauses.push(purpose_default_queue_node_expression("n", "p", scope));
8339 }
8340
8341 let normalized_prefix = path_prefix
8342 .map(normalize_repo_path_prefix)
8343 .filter(|prefix| prefix != ".");
8344 if let Some(prefix) = normalized_prefix {
8345 clauses.push(format!(
8346 "(n.path = ?{} OR n.path LIKE ?{} ESCAPE '\\')",
8347 values.len() + 1,
8348 values.len() + 2
8349 ));
8350 values.push(Value::from(prefix.clone()));
8351 values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8352 }
8353
8354 match resolution_filter {
8355 HealthResolutionFilter::Explicit(resolved_ids) => {
8356 let resolved_paths = resolved_purpose_paths(resolved_ids, spec.category);
8357 if !resolved_paths.is_empty() {
8358 clauses.push(format!(
8359 "n.path NOT IN ({})",
8360 numbered_placeholders(values.len() + 1, resolved_paths.len())
8361 ));
8362 values.extend(resolved_paths.into_iter().map(Value::from));
8363 }
8364 }
8365 HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(&format!(
8366 "'{}:' || n.path || ':'",
8367 spec.category
8368 ))),
8369 }
8370
8371 (clauses.join(" AND "), values)
8372}
8373
8374fn structural_finding_where_clause(
8376 category: &str,
8377 path_prefix: Option<&str>,
8378 resolution_filter: HealthResolutionFilter<'_>,
8379 scope: HealthScope,
8380 first_placeholder: usize,
8381) -> (String, Vec<Value>) {
8382 let mut placeholder = first_placeholder;
8383 let mut clauses = Vec::new();
8384 let mut values = Vec::new();
8385
8386 if source_filter_applies_before_queue(scope) {
8387 clauses.push("source_relevant = 1".to_string());
8388 }
8389 if scope.high_impact_queue() {
8390 clauses.push(purpose_default_queue_finding_expression(scope));
8391 }
8392
8393 let normalized_prefix = path_prefix
8394 .map(normalize_repo_path_prefix)
8395 .filter(|prefix| prefix != ".");
8396 if let Some(prefix) = normalized_prefix {
8397 clauses.push(format!(
8398 "((path = ?{path_exact} OR path LIKE ?{path_descendant} ESCAPE '\\') \
8399 OR (related_path = ?{related_exact} OR related_path LIKE ?{related_descendant} ESCAPE '\\'))",
8400 path_exact = placeholder,
8401 path_descendant = placeholder + 1,
8402 related_exact = placeholder + 2,
8403 related_descendant = placeholder + 3
8404 ));
8405 values.push(Value::from(prefix.clone()));
8406 values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8407 values.push(Value::from(prefix.clone()));
8408 values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8409 placeholder += 4;
8410 }
8411
8412 match resolution_filter {
8413 HealthResolutionFilter::Explicit(resolved_ids) => {
8414 let resolved_ids = resolved_ids_for_category(resolved_ids, category);
8415 if !resolved_ids.is_empty() {
8416 clauses.push(format!(
8417 "('{category}:' || path || ':' || related_path) NOT IN ({})",
8418 numbered_placeholders(placeholder, resolved_ids.len())
8419 ));
8420 values.extend(resolved_ids.into_iter().map(Value::from));
8421 }
8422 }
8423 HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(&format!(
8424 "'{category}:' || path || ':' || related_path"
8425 ))),
8426 }
8427
8428 if clauses.is_empty() {
8429 (String::new(), values)
8430 } else {
8431 (format!("WHERE {}", clauses.join(" AND ")), values)
8432 }
8433}
8434
8435fn purpose_lifecycle_finding_id_expression(
8437 specs: &[PurposeHealthSpec],
8438 node_alias: &str,
8439 purpose_alias: &str,
8440) -> String {
8441 let category_cases = specs
8442 .iter()
8443 .map(|spec| format!("WHEN '{}' THEN '{}:'", spec.status, spec.category))
8444 .collect::<Vec<_>>()
8445 .join(" ");
8446 format!(
8447 "(CASE {purpose_alias}.status {category_cases} ELSE '' END || {node_alias}.path || ':')"
8448 )
8449}
8450
8451fn stored_resolution_filter_clause(finding_id_expression: &str) -> String {
8453 format!(
8454 "NOT EXISTS (SELECT 1 FROM health_resolutions hr WHERE hr.finding_id = {finding_id_expression})"
8455 )
8456}
8457
8458fn purpose_review_candidate_expression(node_alias: &str, scope: HealthScope) -> String {
8460 let scope = match scope {
8461 HealthScope::All => HealthScope::PurposeStrict,
8462 other => other,
8463 };
8464 purpose_default_queue_node_expression(node_alias, "p", scope)
8465}
8466
8467fn purpose_default_queue_node_expression(
8469 node_alias: &str,
8470 purpose_alias: &str,
8471 scope: HealthScope,
8472) -> String {
8473 let asset_clause = if scope.include_assets() {
8474 format!(
8475 " OR ({node_alias}.kind = 'file' AND NOT ({}))",
8476 source_relevant_node_expression(node_alias)
8477 )
8478 } else {
8479 String::new()
8480 };
8481 let source_file_clause = if scope.include_source_files() {
8482 format!(" OR ({node_alias}.kind = 'file' AND COALESCE({node_alias}.language, '') <> '')")
8483 } else {
8484 String::new()
8485 };
8486 let all_file_clause = if scope.include_all_files() {
8487 format!(" OR {node_alias}.kind = 'file'")
8488 } else {
8489 String::new()
8490 };
8491 let stale_queue_sources = sql_string_literals(STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES);
8492 format!(
8493 "({node_alias}.kind = 'folder' \
8494 OR ({node_alias}.kind = 'file' \
8495 AND {purpose_alias}.status = 'stale' \
8496 AND {purpose_alias}.source IN ({stale_queue_sources})) \
8497 OR ({node_alias}.kind = 'file' AND {}){source_file_clause}{all_file_clause}{asset_clause})",
8498 high_impact_file_path_expression(&format!("lower({node_alias}.path)")),
8499 )
8500}
8501
8502fn purpose_default_queue_finding_expression(scope: HealthScope) -> String {
8504 let asset_clause = if scope.include_assets() {
8505 " OR (kind = 'file' AND COALESCE(language, '') = '')"
8506 } else {
8507 ""
8508 };
8509 let source_file_clause = if scope.include_source_files() {
8510 " OR (kind = 'file' AND COALESCE(language, '') <> '')"
8511 } else {
8512 ""
8513 };
8514 let all_file_clause = if scope.include_all_files() {
8515 " OR kind = 'file'"
8516 } else {
8517 ""
8518 };
8519 format!(
8520 "(kind = 'folder' OR (kind = 'file' AND {}){source_file_clause}{all_file_clause}{asset_clause})",
8521 high_impact_file_path_expression("lower(path)")
8522 )
8523}
8524
8525fn purpose_default_queue_order_expression(node_alias: &str, purpose_alias: &str) -> String {
8527 let stale_queue_sources = sql_string_literals(STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES);
8528 format!(
8529 "CASE \
8530 WHEN {node_alias}.kind = 'folder' THEN 0 \
8531 WHEN {node_alias}.kind = 'file' \
8532 AND {purpose_alias}.status = 'stale' \
8533 AND ({purpose_alias}.source IN ({stale_queue_sources}) OR {}) THEN 1 \
8534 WHEN {node_alias}.kind = 'file' AND {} THEN 2 \
8535 ELSE 3 \
8536 END, {node_alias}.path",
8537 high_impact_file_path_expression(&format!("lower({node_alias}.path)")),
8538 high_impact_file_path_expression(&format!("lower({node_alias}.path)"))
8539 )
8540}
8541
8542const STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES: &[&str] = &["human", "imported"];
8544
8545fn source_filter_applies_before_queue(scope: HealthScope) -> bool {
8547 scope.source_only_filter() && !scope.high_impact_queue()
8548}
8549
8550fn sql_string_literals(values: &[&str]) -> String {
8552 values
8553 .iter()
8554 .map(|value| format!("'{}'", value.replace('\'', "''")))
8555 .collect::<Vec<_>>()
8556 .join(", ")
8557}
8558
8559fn high_impact_file_path_expression(lower_path: &str) -> String {
8561 let name_matches = HIGH_IMPACT_FILE_NAMES
8562 .iter()
8563 .map(|name| format!("{lower_path} = '{name}' OR {lower_path} LIKE '%/{name}'"))
8564 .collect::<Vec<_>>()
8565 .join(" OR ");
8566 let prefix_matches = HIGH_IMPACT_PATH_PREFIXES
8567 .iter()
8568 .map(|prefix| format!("{lower_path} LIKE '{prefix}%'"))
8569 .collect::<Vec<_>>()
8570 .join(" OR ");
8571 let segment_matches = HIGH_IMPACT_PATH_SEGMENTS
8572 .iter()
8573 .map(|segment| format!("{lower_path} LIKE '%{segment}%'"))
8574 .collect::<Vec<_>>()
8575 .join(" OR ");
8576 format!("({name_matches} OR {prefix_matches} OR {segment_matches})")
8577}
8578
8579fn source_relevant_node_expression(alias: &str) -> String {
8581 format!(
8582 "(({alias}.kind = 'file' AND COALESCE({alias}.language, '') <> '') \
8583 OR ({alias}.kind = 'folder' AND EXISTS (\
8584 SELECT 1 FROM nodes source_child \
8585 WHERE source_child.exists_now = 1 \
8586 AND source_child.kind = 'file' \
8587 AND COALESCE(source_child.language, '') <> '' \
8588 AND (\
8589 {alias}.path = '.' \
8590 OR source_child.parent_path = {alias}.path \
8591 OR substr(source_child.parent_path, 1, length({alias}.path) + 1) = {alias}.path || '/'\
8592 )\
8593 )))"
8594 )
8595}
8596
8597fn resolved_purpose_paths(resolved_ids: &[String], category: &str) -> Vec<String> {
8599 let prefix = format!("{category}:");
8600 resolved_ids
8601 .iter()
8602 .filter_map(|id| {
8603 id.strip_prefix(&prefix)
8604 .and_then(|rest| rest.strip_suffix(':'))
8605 .filter(|path| !path.is_empty())
8606 .map(ToOwned::to_owned)
8607 })
8608 .collect()
8609}
8610
8611fn resolved_ids_for_category(resolved_ids: &[String], category: &str) -> Vec<String> {
8613 let prefix = format!("{category}:");
8614 resolved_ids
8615 .iter()
8616 .filter(|id| id.starts_with(&prefix))
8617 .cloned()
8618 .collect()
8619}
8620
8621#[cfg(test)]
8622mod tests {
8623 use super::*;
8624 use projectatlas_core::telemetry::{
8625 READ_AVOIDANCE_CONFIDENCE_MODELED, READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED,
8626 READ_AVOIDANCE_CONFIDENCE_OBSERVED, TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
8627 TOKEN_ACCURACY_HEURISTIC, TOKEN_BASELINE_SELECTED_CANDIDATES,
8628 TOKEN_BUCKET_FULL_FILE_COMPRESSION, TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
8629 TOKEN_COMMAND_SEARCH, TOKEN_CONFIDENCE_INFERRED, TOKEN_DEDUPE_SCOPE_EVENT,
8630 usage_from_estimates, usage_from_estimates_with_accounting,
8631 usage_from_estimates_with_context, usage_from_text,
8632 };
8633 use projectatlas_core::{NodeKind, normalized_parent};
8634 use std::error::Error;
8635 use std::fmt::Debug;
8636 use std::fs;
8637 use std::io;
8638 use std::time::Instant;
8639
8640 #[test]
8641 fn unsupported_schema_versions_follow_the_migration_inventory() -> Result<(), Box<dyn Error>> {
8642 for found in schema::PREVIOUS_SCHEMA_VERSION..=schema::SCHEMA_VERSION {
8643 let error = DbError::SchemaVersion {
8644 found,
8645 expected: schema::SCHEMA_VERSION,
8646 };
8647 let expected_migration = if found < schema::SCHEMA_VERSION {
8648 Some((
8649 found,
8650 schema::SCHEMA_VERSION,
8651 u32::try_from(schema::SCHEMA_VERSION - found)?,
8652 ))
8653 } else {
8654 None
8655 };
8656 require_eq(
8657 &error.supported_schema_migration(),
8658 &expected_migration,
8659 "admitted schema migration",
8660 )?;
8661 require_eq(
8662 &error.unsupported_schema_version(),
8663 &None,
8664 "admitted schema version",
8665 )?;
8666 }
8667 for found in [
8668 schema::PREVIOUS_SCHEMA_VERSION - 1,
8669 schema::SCHEMA_VERSION + 1,
8670 ] {
8671 let error = DbError::SchemaVersion {
8672 found,
8673 expected: schema::SCHEMA_VERSION,
8674 };
8675 require_eq(
8676 &error.unsupported_schema_version(),
8677 &Some((found, schema::SCHEMA_VERSION)),
8678 "unsupported schema version",
8679 )?;
8680 require_eq(
8681 &error.supported_schema_migration(),
8682 &None,
8683 "unsupported schema migration",
8684 )?;
8685 }
8686 require_eq(
8687 &DbError::SchemaVersionMissing.unsupported_schema_version(),
8688 &None,
8689 "unrelated database error",
8690 )?;
8691 require_eq(
8692 &DbError::SchemaVersionMissing.supported_schema_migration(),
8693 &None,
8694 "unrelated migration error",
8695 )?;
8696 Ok(())
8697 }
8698
8699 #[test]
8700 fn stores_nodes_and_overview() -> Result<(), Box<dyn Error>> {
8701 let mut store = AtlasStore::in_memory()?;
8702 let node = Node {
8703 path: "src/main.rs".to_string(),
8704 kind: NodeKind::File,
8705 parent_path: normalized_parent("src/main.rs"),
8706 extension: Some(".rs".to_string()),
8707 language: Some("rust".to_string()),
8708 size_bytes: Some(12),
8709 mtime_ns: Some(10),
8710 content_hash: Some("abc".to_string()),
8711 };
8712 store.replace_scan(&[node])?;
8713 let overview = store.overview()?;
8714 require_eq(&overview.files, &1, "file count")?;
8715 require_eq(&overview.missing_purposes, &1, "missing purpose count")?;
8716 let nodes = store.load_nodes()?;
8717 require_eq(
8718 &nodes[0].purpose.purpose,
8719 &None,
8720 "purpose remains separate from summary",
8721 )?;
8722 require_eq(
8723 &nodes[0].summary,
8724 &Some("rust file, 12 bytes".to_string()),
8725 "node-level summary",
8726 )?;
8727 let loaded = store
8728 .load_node_by_path("src/main.rs")?
8729 .ok_or_else(|| io::Error::other("indexed node was not found by path"))?;
8730 require_eq(
8731 &loaded.node.path,
8732 &"src/main.rs".to_string(),
8733 "targeted path lookup",
8734 )?;
8735 require_eq(
8736 &store.load_node_by_path("src/missing.rs")?.is_none(),
8737 &true,
8738 "missing targeted path lookup",
8739 )?;
8740 Ok(())
8741 }
8742
8743 #[test]
8744 fn load_nodes_rejects_negative_size_bytes_without_partial_page() -> Result<(), Box<dyn Error>> {
8745 let mut store = AtlasStore::in_memory()?;
8746 store.replace_scan(&[
8747 test_file_node("src/a.rs", "hash-a"),
8748 test_file_node("src/b.rs", "hash-b"),
8749 ])?;
8750 store.connection.execute(
8751 "UPDATE nodes SET size_bytes = -1 WHERE path = 'src/b.rs'",
8752 [],
8753 )?;
8754
8755 let Err(error) = store.load_nodes() else {
8756 return Err(io::Error::other("negative node size returned a partial page").into());
8757 };
8758 require(
8759 matches!(
8760 error,
8761 DbError::Sqlite(rusqlite::Error::FromSqlConversionFailure(
8762 5,
8763 rusqlite::types::Type::Integer,
8764 _
8765 ))
8766 ),
8767 "negative node size returned the wrong conversion error",
8768 )?;
8769 Ok(())
8770 }
8771
8772 #[test]
8773 fn validated_existing_database_paths_are_never_recreated() -> Result<(), Box<dyn Error>> {
8774 let temp = tempfile::tempdir()?;
8775
8776 let current_path = temp.path().join("current.db");
8777 drop(AtlasStore::open(¤t_path)?);
8778 let (current, current_location) = schema::preflight(¤t_path, None)?;
8779 require_eq(
8780 ¤t.state,
8781 &SchemaState::Current,
8782 "current preflight state",
8783 )?;
8784 fs::remove_file(¤t_path)?;
8785 if Connection::open_with_flags(
8786 ¤t_path,
8787 writable_open_flags(current.state, current_location.database_exists),
8788 )
8789 .is_ok()
8790 {
8791 return Err(io::Error::other("current database path was recreated").into());
8792 }
8793 require_eq(¤t_path.exists(), &false, "current path stays absent")?;
8794
8795 let released_path = temp.path().join("released.db");
8796 let released_root = temp.path().join("released-root");
8797 write_released_schema_eight_compatibility_fixture(&released_path, &released_root)?;
8798 let (released, released_location) = schema::preflight(&released_path, None)?;
8799 require_eq(
8800 &released.state,
8801 &SchemaState::UpgradeRequired,
8802 "released preflight state",
8803 )?;
8804 fs::remove_file(&released_path)?;
8805 if Connection::open_with_flags(
8806 &released_path,
8807 writable_open_flags(released.state, released_location.database_exists),
8808 )
8809 .is_ok()
8810 {
8811 return Err(io::Error::other("released database path was recreated").into());
8812 }
8813 require_eq(
8814 &released_path.exists(),
8815 &false,
8816 "released path stays absent",
8817 )?;
8818
8819 let fresh_path = temp.path().join("fresh.db");
8820 let (fresh, _) = schema::preflight(&fresh_path, None)?;
8821 require_eq(&fresh.state, &SchemaState::Fresh, "fresh preflight state")?;
8822 drop(AtlasStore::open(&fresh_path)?);
8823 require_eq(&fresh_path.is_file(), &true, "fresh path is created")?;
8824 drop(AtlasStore::open_read_only(&fresh_path)?);
8825 Ok(())
8826 }
8827
8828 #[cfg(windows)]
8829 #[test]
8830 fn released_schema_layouts_upgrade_without_losing_local_state() -> Result<(), Box<dyn Error>> {
8831 let layouts: [(&str, fn(&Path, &Path) -> Result<(), Box<dyn Error>>); 2] = [
8832 (
8833 "fresh-v0.3.26",
8834 write_released_schema_eight_compatibility_fixture,
8835 ),
8836 (
8837 "evolved-v0.3.11-to-v0.3.26",
8838 write_evolved_released_schema_eight_compatibility_fixture,
8839 ),
8840 ];
8841 let mut previous_binding = None;
8842 for (label, write_fixture) in layouts {
8843 let binding =
8844 assert_released_schema_upgrade_preserves_local_state(label, write_fixture)?;
8845 if previous_binding
8846 .as_ref()
8847 .is_some_and(|previous| previous == &binding)
8848 {
8849 return Err(io::Error::other(
8850 "independent released-schema databases shared one project binding",
8851 )
8852 .into());
8853 }
8854 previous_binding = Some(binding);
8855 }
8856 Ok(())
8857 }
8858
8859 #[cfg(windows)]
8861 fn assert_released_schema_upgrade_preserves_local_state(
8862 label: &str,
8863 write_fixture: fn(&Path, &Path) -> Result<(), Box<dyn Error>>,
8864 ) -> Result<CapturedProjectBinding, Box<dyn Error>> {
8865 let temp = tempfile::tempdir()?;
8866 let db_path = temp.path().join("projectatlas.db");
8867 let root = temp.path().join("repository");
8868 write_fixture(&db_path, &root)?;
8869 fs::create_dir_all(&root)?;
8870 let database_before_read = fs::read(&db_path)?;
8871
8872 let Err(read_error) = AtlasStore::open_read_only(&db_path) else {
8873 return Err(io::Error::other("schema-8 read-only open unexpectedly succeeded").into());
8874 };
8875 require_eq(
8876 &matches!(
8877 read_error,
8878 DbError::SchemaVersion {
8879 found: PREVIOUS_SCHEMA_VERSION,
8880 expected: SCHEMA_VERSION,
8881 }
8882 ),
8883 &true,
8884 "schema-8 read-only rejection",
8885 )?;
8886 require_eq(
8887 &fs::read(&db_path)?,
8888 &database_before_read,
8889 "read-only rejection leaves database unchanged",
8890 )?;
8891
8892 let store = AtlasStore::open(&db_path)?;
8893 let stored_schema = store.connection.query_row(
8894 "SELECT value FROM metadata WHERE key = ?1",
8895 [SCHEMA_VERSION_KEY],
8896 |row| row.get::<_, String>(0),
8897 )?;
8898 require_eq(
8899 &stored_schema,
8900 &SCHEMA_VERSION.to_string(),
8901 "upgraded schema version",
8902 )?;
8903 let migrated_binding = store.captured_project_binding()?;
8904 drop(store);
8905 schema::verify_current_integrity(&db_path, Some(&normalize_native_path_display(&root)))?;
8906 let mut store = AtlasStore::open_for_project(&db_path, &root)?;
8907 require_eq(
8908 &store.captured_project_binding()?,
8909 &migrated_binding,
8910 &format!("{label} identity survives reopen"),
8911 )?;
8912 require_eq(
8913 &store.project_root()?,
8914 &Some(normalize_native_path_display(&root)),
8915 "upgraded project root",
8916 )?;
8917 let node = store
8918 .load_node_by_path("src/lib.rs")?
8919 .ok_or_else(|| io::Error::other("upgraded source node missing"))?;
8920 require_eq(
8921 &node.node.content_hash,
8922 &Some("hash-legacy".to_string()),
8923 "upgraded source row",
8924 )?;
8925 require_eq(
8926 &node.purpose.purpose,
8927 &Some("Schema compatibility source".to_string()),
8928 "upgraded purpose text",
8929 )?;
8930 require_eq(
8931 &node.purpose.source,
8932 &PurposeSource::Agent,
8933 "upgraded purpose source",
8934 )?;
8935 require_eq(
8936 &node.purpose.status,
8937 &PurposeStatus::Approved,
8938 "upgraded purpose review state",
8939 )?;
8940 require_eq(
8941 &store.resolved_health_ids()?,
8942 &vec!["schema-review".to_string()],
8943 "upgraded authored review",
8944 )?;
8945 let custom_setting = store.connection.query_row(
8946 "SELECT value FROM metadata WHERE key = 'custom_setting'",
8947 [],
8948 |row| row.get::<_, String>(0),
8949 )?;
8950 require_eq(
8951 &custom_setting,
8952 &"preserved".to_string(),
8953 "upgraded compatible metadata",
8954 )?;
8955 let telemetry = store.token_overview(Some("schema-session"))?;
8956 require_eq(&telemetry.calls, &1, "upgraded telemetry call count")?;
8957 require_eq(
8958 &telemetry.estimated_saved,
8959 &80,
8960 "upgraded telemetry savings",
8961 )?;
8962 require_eq(
8963 &store.index_publication()?,
8964 &None,
8965 "untrusted publication metadata invalidated",
8966 )?;
8967 let remaining_publication_keys = store.connection.query_row(
8968 "SELECT COUNT(*) FROM metadata WHERE key IN (?1, ?2, ?3)",
8969 params![
8970 INDEX_PUBLICATION_STATE_KEY,
8971 INDEX_PUBLICATION_FINGERPRINT_KEY,
8972 INDEX_PUBLICATION_GENERATION_KEY,
8973 ],
8974 |row| row.get::<_, i64>(0),
8975 )?;
8976 require_eq(
8977 &remaining_publication_keys,
8978 &0,
8979 "all untrusted publication keys removed",
8980 )?;
8981
8982 {
8983 let mut publication = store.begin_index_publication("schema-9-contract")?;
8984 write_test_projection(&mut publication, "fresh")?;
8985 publication.complete()?;
8986 }
8987 require_test_projection(&store, 1, "fresh")?;
8988 require_eq(
8989 &store
8990 .index_publication()?
8991 .and_then(|publication| publication.contract_fingerprint),
8992 &Some("schema-9-contract".to_string()),
8993 &format!("{label} fresh publication contract"),
8994 )?;
8995 Ok(migrated_binding)
8996 }
8997
8998 #[test]
8999 fn future_schema_rejection_preserves_source_and_authored_rows() -> Result<(), Box<dyn Error>> {
9000 let temp = tempfile::tempdir()?;
9001 let db_path = temp.path().join("projectatlas.db");
9002 let root = temp.path().join("repository");
9003 fs::create_dir(&root)?;
9004 let future_schema = SCHEMA_VERSION + 1;
9005 write_schema_compatibility_fixture(&db_path, &root, future_schema, "future")?;
9006 let database_before = fs::read(&db_path)?;
9007 let Err(open_error) = AtlasStore::open(&db_path) else {
9008 return Err(
9009 io::Error::other("future schema writable open unexpectedly succeeded").into(),
9010 );
9011 };
9012 require_eq(
9013 &matches!(
9014 open_error,
9015 DbError::SchemaVersion {
9016 found,
9017 expected: SCHEMA_VERSION,
9018 } if found == future_schema
9019 ),
9020 &true,
9021 "future schema rejection",
9022 )?;
9023 require_eq(
9024 &fs::read(&db_path)?,
9025 &database_before,
9026 "future schema bytes remain unchanged",
9027 )?;
9028 let connection = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
9029 let stored_schema = connection.query_row(
9030 "SELECT value FROM metadata WHERE key = ?1",
9031 [SCHEMA_VERSION_KEY],
9032 |row| row.get::<_, String>(0),
9033 )?;
9034 require_eq(
9035 &stored_schema,
9036 &future_schema.to_string(),
9037 "future schema remains unchanged",
9038 )?;
9039 let stored_root = connection.query_row(
9040 "SELECT value FROM metadata WHERE key = 'project_root'",
9041 [],
9042 |row| row.get::<_, String>(0),
9043 )?;
9044 require_eq(
9045 &stored_root,
9046 &normalize_native_path_display(&root),
9047 "future project root remains unchanged",
9048 )?;
9049 let source_state = connection.query_row(
9050 "
9051 SELECT n.content_hash, p.purpose, p.source, p.status
9052 FROM nodes AS n
9053 JOIN purposes AS p ON p.node_id = n.id
9054 WHERE n.path = 'src/lib.rs'
9055 ",
9056 [],
9057 |row| {
9058 Ok((
9059 row.get::<_, Option<String>>(0)?,
9060 row.get::<_, Option<String>>(1)?,
9061 row.get::<_, String>(2)?,
9062 row.get::<_, String>(3)?,
9063 ))
9064 },
9065 )?;
9066 require_eq(
9067 &source_state,
9068 &(
9069 Some("hash-future".to_string()),
9070 Some("Schema compatibility source".to_string()),
9071 PurposeSource::Agent.to_string(),
9072 PurposeStatus::Approved.as_str().to_string(),
9073 ),
9074 "future source and purpose rows remain unchanged",
9075 )?;
9076 let review_rationale = connection.query_row(
9077 "SELECT rationale FROM health_resolutions WHERE finding_id = 'schema-review'",
9078 [],
9079 |row| row.get::<_, String>(0),
9080 )?;
9081 require_eq(
9082 &review_rationale,
9083 &"Reviewed schema fixture".to_string(),
9084 "future authored review remains unchanged",
9085 )?;
9086 let telemetry_state = connection.query_row(
9087 "
9088 SELECT COUNT(*), SUM(e.estimated_tokens_saved)
9089 FROM usage_events AS e
9090 JOIN usage_instances AS i USING(instance_row_id)
9091 WHERE i.caller_label = 'schema-session'
9092 ",
9093 [],
9094 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<i64>>(1)?)),
9095 )?;
9096 require_eq(
9097 &telemetry_state,
9098 &(1, Some(80)),
9099 "future telemetry remains unchanged",
9100 )?;
9101 Ok(())
9102 }
9103
9104 #[test]
9105 fn projection_refresh_cannot_replace_publication_contract() -> Result<(), Box<dyn Error>> {
9106 let mut store = AtlasStore::in_memory()?;
9107 store.begin_index_publication("contract-a")?.complete()?;
9108
9109 store
9110 .begin_index_projection_refresh("contract-a")?
9111 .complete()?;
9112 require_eq(
9113 &store.index_publication()?,
9114 &Some(IndexPublication {
9115 state: IndexPublicationState::Complete,
9116 contract_fingerprint: Some("contract-a".to_string()),
9117 generation: IndexGeneration::new(2),
9118 }),
9119 "matching projection refresh",
9120 )?;
9121
9122 let publication = store.begin_index_projection_refresh("contract-a")?;
9123 set_metadata(
9124 &publication.connection,
9125 INDEX_PUBLICATION_FINGERPRINT_KEY,
9126 "contract-b",
9127 )?;
9128 let Err(mismatch) = publication.complete() else {
9129 return Err(io::Error::other(
9130 "projection refresh replaced the global publication contract",
9131 )
9132 .into());
9133 };
9134 if !matches!(mismatch, DbError::PublicationContractChanged) {
9135 return Err(io::Error::other(format!(
9136 "unexpected projection refresh mismatch: {mismatch}"
9137 ))
9138 .into());
9139 }
9140 require_eq(
9141 &store.index_publication()?,
9142 &Some(IndexPublication {
9143 state: IndexPublicationState::Complete,
9144 contract_fingerprint: Some("contract-a".to_string()),
9145 generation: IndexGeneration::new(2),
9146 }),
9147 "mismatched projection refresh rolls back",
9148 )?;
9149 Ok(())
9150 }
9151
9152 #[test]
9153 fn read_snapshots_expose_only_complete_publications() -> Result<(), Box<dyn Error>> {
9154 let temp = tempfile::tempdir()?;
9155 let db_path = temp.path().join("projectatlas.db");
9156 let independent_db_path = temp.path().join("independent.db");
9157 let mut writer_a = AtlasStore::open(&db_path)?;
9158 require_writable_connection_profile(&writer_a.connection)?;
9159 {
9160 let mut publication = writer_a.begin_index_publication("contract")?;
9161 write_test_projection(&mut publication, "old")?;
9162 publication.complete()?;
9163 }
9164 let mut writer_b = AtlasStore::open(&db_path)?;
9165 let mut independent_writer = AtlasStore::open(&independent_db_path)?;
9166 let old_reader = AtlasStore::open_read_only(&db_path)?;
9167 require_read_connection_profile(&old_reader.connection)?;
9168 if old_reader
9169 .connection
9170 .execute("DELETE FROM metadata", [])
9171 .is_ok()
9172 {
9173 return Err(io::Error::other("read-only connection accepted a mutation").into());
9174 }
9175 require_test_projection(&old_reader, 1, "old")?;
9176
9177 {
9178 let mut publication = writer_a.begin_index_publication("contract")?;
9179 write_test_projection(&mut publication, "new")?;
9180 require_test_projection(&old_reader, 1, "old")?;
9181
9182 let probe_started = std::time::Instant::now();
9183 let Err(probe_contention) = writer_b.probe_index_publication_writer() else {
9184 return Err(io::Error::other(
9185 "writer availability probe entered an active publication transaction",
9186 )
9187 .into());
9188 };
9189 require_eq(
9190 &matches!(
9191 probe_contention,
9192 DbError::Sqlite(ref error)
9193 if matches!(
9194 error.sqlite_error_code(),
9195 Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
9196 )
9197 ),
9198 &true,
9199 "same-database writer availability probe",
9200 )?;
9201 require_eq(
9202 &(probe_started.elapsed() < Duration::from_secs(2)),
9203 &true,
9204 "fail-fast same-database writer availability probe",
9205 )?;
9206
9207 let started = std::time::Instant::now();
9208 let Err(contention) = writer_b.begin_index_publication("contract") else {
9209 return Err(io::Error::other(
9210 "second writer entered an active publication transaction",
9211 )
9212 .into());
9213 };
9214 require_eq(
9215 &matches!(
9216 contention,
9217 DbError::Sqlite(ref error)
9218 if matches!(
9219 error.sqlite_error_code(),
9220 Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
9221 )
9222 ),
9223 &true,
9224 "same-database writer contention",
9225 )?;
9226 require_eq(
9227 &(started.elapsed() < Duration::from_secs(2)),
9228 &true,
9229 "fail-fast same-database writer acquisition",
9230 )?;
9231 let restored_busy_timeout =
9232 writer_b
9233 .connection
9234 .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, i64>(0))?;
9235 require_eq(
9236 &u128::try_from(restored_busy_timeout)?,
9237 &SQLITE_BUSY_TIMEOUT.as_millis(),
9238 "ordinary busy timeout after failed publication acquisition",
9239 )?;
9240
9241 independent_writer
9242 .begin_index_publication("independent-contract")?
9243 .complete()?;
9244 require_eq(
9245 &independent_writer
9246 .index_publication()?
9247 .ok_or_else(|| io::Error::other("independent publication missing"))?
9248 .generation,
9249 &IndexGeneration::new(1),
9250 "independent database generation",
9251 )?;
9252
9253 drop(publication);
9254 }
9255 let rolled_back_reader = AtlasStore::open_read_only(&db_path)?;
9256 require_test_projection(&rolled_back_reader, 1, "old")?;
9257 rolled_back_reader.finish_index_read_snapshot()?;
9258
9259 {
9260 let mut publication = writer_a.begin_index_publication("contract")?;
9261 write_test_projection(&mut publication, "new")?;
9262 publication.complete()?;
9263 }
9264 let restored_busy_timeout =
9265 writer_a
9266 .connection
9267 .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, i64>(0))?;
9268 require_eq(
9269 &u128::try_from(restored_busy_timeout)?,
9270 &SQLITE_BUSY_TIMEOUT.as_millis(),
9271 "ordinary busy timeout after successful publication acquisition",
9272 )?;
9273 require_test_projection(&old_reader, 1, "old")?;
9274 let new_reader = AtlasStore::open_read_only(&db_path)?;
9275 require_test_projection(&new_reader, 2, "new")?;
9276 new_reader.finish_index_read_snapshot()?;
9277 old_reader.finish_index_read_snapshot()?;
9278 Ok(())
9279 }
9280
9281 #[test]
9282 fn authored_write_waits_for_the_existing_writer_before_validation() -> Result<(), Box<dyn Error>>
9283 {
9284 let temp = tempfile::tempdir()?;
9285 let root = temp.path().join("repository");
9286 fs::create_dir_all(&root)?;
9287 let db_path = temp.path().join("projectatlas.db");
9288 let mut store = AtlasStore::open_for_project(&db_path, &root)?;
9289 store.replace_scan(&[test_file_node("src/lib.rs", "initial")])?;
9290
9291 let blocking_writer = Connection::open(&db_path)?;
9292 blocking_writer.busy_timeout(SQLITE_BUSY_TIMEOUT)?;
9293 blocking_writer.execute_batch("BEGIN IMMEDIATE")?;
9294 let release = std::thread::spawn(move || {
9295 std::thread::sleep(Duration::from_millis(1_250));
9296 blocking_writer.execute_batch("ROLLBACK")
9297 });
9298
9299 let started = Instant::now();
9300 store.set_purpose(
9301 "src/lib.rs",
9302 "Own the library source.",
9303 PurposeSource::Agent,
9304 )?;
9305 let elapsed = started.elapsed();
9306 release
9307 .join()
9308 .map_err(|_panic| io::Error::other("blocking writer thread panicked"))??;
9309
9310 require_eq(
9311 &(elapsed >= Duration::from_secs(1)),
9312 &true,
9313 "authored write waited for the existing writer",
9314 )?;
9315 require_eq(
9316 &(elapsed < SQLITE_BUSY_TIMEOUT),
9317 &true,
9318 "authored write stayed within the ordinary busy timeout",
9319 )?;
9320 require_eq(
9321 &store
9322 .load_node_by_path("src/lib.rs")?
9323 .ok_or_else(|| io::Error::other("purpose node missing"))?
9324 .purpose
9325 .purpose,
9326 &Some("Own the library source.".to_string()),
9327 "purpose after bounded writer contention",
9328 )
9329 }
9330
9331 #[test]
9332 fn writable_mutators_reject_an_active_read_snapshot() -> Result<(), Box<dyn Error>> {
9333 let temp = tempfile::tempdir()?;
9334 let db_path = temp.path().join("projectatlas.db");
9335 let mut store = AtlasStore::open(&db_path)?;
9336 store.replace_scan(&[test_file_node("src/lib.rs", "initial")])?;
9337 store.begin_index_read_snapshot()?;
9338
9339 let Err(purpose_error) = store.set_purpose(
9340 "src/lib.rs",
9341 "Must not be written through a read snapshot.",
9342 PurposeSource::Agent,
9343 ) else {
9344 return Err(io::Error::other("purpose wrote through a read snapshot").into());
9345 };
9346 require_eq(
9347 &matches!(purpose_error, DbError::IndexReadSnapshotActive),
9348 &true,
9349 "read-snapshot purpose rejection",
9350 )?;
9351
9352 let Err(scan_error) = store.replace_scan(&[]) else {
9353 return Err(io::Error::other("scan wrote through a read snapshot").into());
9354 };
9355 require_eq(
9356 &matches!(scan_error, DbError::IndexReadSnapshotActive),
9357 &true,
9358 "read-snapshot scan rejection",
9359 )?;
9360
9361 store.finish_index_read_snapshot()?;
9362 require_eq(
9363 &store.load_node_by_path("src/lib.rs")?.is_some(),
9364 &true,
9365 "read-snapshot rejected writes preserved indexed state",
9366 )?;
9367 Ok(())
9368 }
9369
9370 #[test]
9371 fn stale_publication_base_is_rejected_before_batch_mutation() -> Result<(), Box<dyn Error>> {
9372 let temp = tempfile::tempdir()?;
9373 let db_path = temp.path().join("projectatlas.db");
9374 let mut writer_a = AtlasStore::open(&db_path)?;
9375 {
9376 let mut publication =
9377 writer_a.begin_index_publication_from("contract", IndexGeneration::ZERO)?;
9378 write_test_projection(&mut publication, "base")?;
9379 publication.complete()?;
9380 }
9381 let prepared_base = writer_a
9382 .index_publication()?
9383 .ok_or_else(|| io::Error::other("prepared base publication missing"))?
9384 .generation;
9385
9386 let incomplete_result = {
9387 let mut publication =
9388 writer_a.begin_index_publication_from("contract", prepared_base)?;
9389 publication.begin_scan_replacement()?;
9390 publication.complete()
9391 };
9392 if !matches!(incomplete_result, Err(DbError::ScanReplacementIncomplete)) {
9393 return Err(io::Error::other(
9394 "unfinished scan replacement was allowed to complete publication",
9395 )
9396 .into());
9397 }
9398 require_test_projection(&writer_a, 1, "base")?;
9399
9400 let mut writer_b = AtlasStore::open(&db_path)?;
9401 {
9402 let mut publication = writer_b.begin_index_publication("contract")?;
9403 write_test_projection(&mut publication, "winner")?;
9404 publication.complete()?;
9405 }
9406
9407 let Err(conflict) = writer_a.begin_index_publication_from("contract", prepared_base) else {
9408 return Err(io::Error::other("stale prepared publication was accepted").into());
9409 };
9410 require_eq(
9411 &matches!(
9412 conflict,
9413 DbError::PublicationBaseGenerationChanged { expected, found }
9414 if expected == IndexGeneration::new(1)
9415 && found == IndexGeneration::new(2)
9416 ),
9417 &true,
9418 "stale publication base conflict",
9419 )?;
9420 require_test_projection(&writer_a, 2, "winner")?;
9421 require_eq(
9422 &writer_a.load_symbols(Some("src/lib.rs"), None, 10)?.len(),
9423 &1,
9424 "rejected batch symbol row count",
9425 )?;
9426 require_eq(
9427 &writer_a
9428 .load_symbol_relations(Some("src/lib.rs"), None, 10)?
9429 .len(),
9430 &1,
9431 "rejected batch relation row count",
9432 )?;
9433
9434 let Err(zero_conflict) =
9435 writer_a.begin_index_publication_from("contract", IndexGeneration::ZERO)
9436 else {
9437 return Err(io::Error::other("zero base matched an initialized store").into());
9438 };
9439 require_eq(
9440 &matches!(
9441 zero_conflict,
9442 DbError::PublicationBaseGenerationChanged { expected, found }
9443 if expected == IndexGeneration::ZERO
9444 && found == IndexGeneration::new(2)
9445 ),
9446 &true,
9447 "zero publication base conflict",
9448 )?;
9449 require_test_projection(&writer_a, 2, "winner")?;
9450 Ok(())
9451 }
9452
9453 #[test]
9454 fn records_token_overview() -> Result<(), Box<dyn Error>> {
9455 let project = tempfile::tempdir()?;
9456 let mut store = AtlasStore::in_memory()?;
9457 store.set_project_root(project.path())?;
9458 let mut session_event = usage_from_estimates(
9459 "session",
9460 "outline",
9461 Some("src/main.rs".to_string()),
9462 None,
9463 100,
9464 20,
9465 );
9466 session_event.estimated_tokens_saved = Some(1);
9467 store.record_usage(&session_event)?;
9468 let mut unknown_event = usage_from_estimates("session", "unknown", None, None, 0, 0);
9469 unknown_event.estimated_tokens_without_projectatlas = None;
9470 unknown_event.estimated_tokens_with_projectatlas = None;
9471 unknown_event.estimated_tokens_saved = None;
9472 store.record_usage(&unknown_event)?;
9473 store.record_usage(&usage_from_estimates(
9474 "other-session",
9475 "outline",
9476 Some("src/lib.rs".to_string()),
9477 None,
9478 200,
9479 50,
9480 ))?;
9481 let overview = store.token_overview(Some("session"))?;
9482 require_eq(&overview.calls, &1, "usage call count")?;
9483 require_eq(&overview.estimated_saved, &80, "saved token count")?;
9484 require_eq(&overview.buckets.len(), &1, "usage bucket count")?;
9485 require_eq(
9486 &overview.buckets[0].accuracy,
9487 &TOKEN_ACCURACY_HEURISTIC.to_string(),
9488 "usage bucket accuracy",
9489 )?;
9490 let all_sessions = store.token_overview(None)?;
9491 require_eq(&all_sessions.calls, &2, "all-session usage call count")?;
9492 require_eq(
9493 &all_sessions.estimated_without_projectatlas,
9494 &300,
9495 "all-session baseline tokens",
9496 )?;
9497 require_eq(
9498 &all_sessions.estimated_with_projectatlas,
9499 &70,
9500 "all-session atlas tokens",
9501 )?;
9502 require_eq(
9503 &all_sessions.estimated_saved,
9504 &230,
9505 "all-session saved tokens",
9506 )?;
9507 require_eq(
9508 &all_sessions.likely_file_reads_avoided,
9509 &0,
9510 "non-search estimate events do not count as avoided file reads",
9511 )?;
9512 require_eq(
9513 &all_sessions.read_avoidance_confidence,
9514 &READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED.to_string(),
9515 "non-search estimate read avoidance confidence",
9516 )?;
9517
9518 store.record_usage(&usage_from_text(
9519 "bucketed",
9520 "summary",
9521 Some("src/main.rs".to_string()),
9522 None,
9523 "abcdefghijkl",
9524 "abcd",
9525 ))?;
9526 store.record_usage(&usage_from_estimates(
9527 "bucketed", "folders", None, None, 100, 20,
9528 ))?;
9529 let bucketed = store.token_overview(Some("bucketed"))?;
9530 require_eq(&bucketed.buckets.len(), &2, "bucketed overview count")?;
9531 require_eq(
9532 &bucketed.buckets[0].token_savings_bucket,
9533 &TOKEN_BUCKET_FULL_FILE_COMPRESSION.to_string(),
9534 "source compression bucket",
9535 )?;
9536 require_eq(
9537 &bucketed.buckets[1].token_savings_bucket,
9538 &TOKEN_BUCKET_NAVIGATION_AVOIDANCE.to_string(),
9539 "navigation bucket",
9540 )?;
9541 require_eq(
9542 &bucketed.observed_file_read_replacements,
9543 &1,
9544 "observed read replacement count",
9545 )?;
9546 require_eq(
9547 &bucketed.modeled_file_reads_avoided,
9548 &0,
9549 "folder navigation does not count as modeled file-read avoidance",
9550 )?;
9551 require_eq(
9552 &bucketed.likely_file_reads_avoided,
9553 &1,
9554 "observed-only likely file reads avoided",
9555 )?;
9556 require_eq(
9557 &bucketed.read_avoidance_confidence,
9558 &READ_AVOIDANCE_CONFIDENCE_OBSERVED.to_string(),
9559 "observed-only read avoidance confidence",
9560 )?;
9561
9562 store.record_usage(&usage_from_text(
9563 "deduped",
9564 "summary",
9565 Some("src/lib.rs".to_string()),
9566 None,
9567 "abcdabcd",
9568 "ab",
9569 ))?;
9570 store.record_usage(&usage_from_estimates(
9571 "deduped",
9572 TOKEN_COMMAND_SEARCH,
9573 None,
9574 Some("token".to_string()),
9575 400,
9576 40,
9577 ))?;
9578 store.record_usage(&usage_from_estimates(
9579 "deduped",
9580 TOKEN_COMMAND_SEARCH,
9581 None,
9582 Some("token".to_string()),
9583 400,
9584 30,
9585 ))?;
9586 let deduped = store.token_overview(Some("deduped"))?;
9587 require_eq(
9588 &deduped.legacy_gross_estimated_saved,
9589 &731,
9590 "legacy gross saved tokens remains available",
9591 )?;
9592 require_eq(
9593 &deduped.measured_tokens_saved,
9594 &1,
9595 "measured saved tokens remain separate",
9596 )?;
9597 require_eq(
9598 &deduped.gross_modeled_tokens_avoided,
9599 &730,
9600 "gross modeled avoided tokens remains available",
9601 )?;
9602 require_eq(
9603 &deduped.deduped_modeled_tokens_avoided,
9604 &330,
9605 "modeled avoided tokens are deduped by baseline",
9606 )?;
9607 require_eq(
9608 &deduped.tokens_avoided,
9609 &331,
9610 "headline avoided tokens use measured plus deduped modeled",
9611 )?;
9612 require_eq(
9613 &deduped.observed_file_read_replacements,
9614 &1,
9615 "deduped observed read replacements",
9616 )?;
9617 require_eq(
9618 &deduped.modeled_file_reads_avoided,
9619 &2,
9620 "deduped raw search events remain likely file reads avoided",
9621 )?;
9622 require_eq(
9623 &deduped.likely_file_reads_avoided,
9624 &3,
9625 "deduped likely file reads avoided",
9626 )?;
9627 require_eq(
9628 &deduped.read_avoidance_confidence,
9629 &READ_AVOIDANCE_CONFIDENCE_MODELED.to_string(),
9630 "deduped read avoidance confidence",
9631 )?;
9632
9633 store.record_usage(&usage_from_estimates_with_accounting(
9634 "event-scoped",
9635 "folders",
9636 None,
9637 Some("token".to_string()),
9638 400,
9639 40,
9640 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
9641 TOKEN_BASELINE_SELECTED_CANDIDATES,
9642 TOKEN_CONFIDENCE_INFERRED,
9643 TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
9644 TOKEN_BASELINE_SELECTED_CANDIDATES,
9645 TOKEN_DEDUPE_SCOPE_EVENT,
9646 ))?;
9647 store.record_usage(&usage_from_estimates_with_accounting(
9648 "event-scoped",
9649 "folders",
9650 None,
9651 Some("token".to_string()),
9652 400,
9653 30,
9654 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
9655 TOKEN_BASELINE_SELECTED_CANDIDATES,
9656 TOKEN_CONFIDENCE_INFERRED,
9657 TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
9658 TOKEN_BASELINE_SELECTED_CANDIDATES,
9659 TOKEN_DEDUPE_SCOPE_EVENT,
9660 ))?;
9661 let event_scoped = store.token_overview(Some("event-scoped"))?;
9662 require_eq(
9663 &event_scoped.gross_modeled_tokens_avoided,
9664 &730,
9665 "event-scoped gross modeled avoided tokens",
9666 )?;
9667 require_eq(
9668 &event_scoped.deduped_modeled_tokens_avoided,
9669 &730,
9670 "event-scoped modeled events are not collapsed",
9671 )?;
9672 require_eq(
9673 &event_scoped.repeated_baselines_deduped,
9674 &0,
9675 "event-scoped modeled events do not count as deduped repeats",
9676 )?;
9677 require_eq(
9678 &event_scoped.likely_file_reads_avoided,
9679 &0,
9680 "folder navigation events do not count as likely file reads avoided",
9681 )?;
9682 require_eq(
9683 &event_scoped.read_avoidance_confidence,
9684 &READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED.to_string(),
9685 "folder navigation read avoidance confidence",
9686 )?;
9687
9688 let mut negative_event = usage_from_estimates("negative", "outline", None, None, 20, 50);
9689 negative_event.estimated_tokens_saved = Some(999);
9690 store.record_usage(&negative_event)?;
9691 let negative = store.token_overview(Some("negative"))?;
9692 require_eq(&negative.calls, &1, "negative session call count")?;
9693 require_eq(
9694 &negative.estimated_saved,
9695 &-30,
9696 "negative session recomputed delta",
9697 )?;
9698 require_eq(
9699 &negative.savings_rate,
9700 &Some(-1.5),
9701 "negative session savings rate",
9702 )?;
9703
9704 let mut zero_event = usage_from_estimates("zero-baseline", "outline", None, None, 0, 12);
9705 zero_event.estimated_tokens_saved = Some(999);
9706 store.record_usage(&zero_event)?;
9707 let zero_baseline = store.token_overview(Some("zero-baseline"))?;
9708 require_eq(&zero_baseline.calls, &1, "zero baseline call count")?;
9709 require_eq(
9710 &zero_baseline.estimated_saved,
9711 &-12,
9712 "zero baseline recomputed delta",
9713 )?;
9714 require_eq(
9715 &zero_baseline.savings_rate,
9716 &None,
9717 "zero baseline savings rate",
9718 )?;
9719
9720 let large_project = tempfile::tempdir()?;
9721 let mut large_store = AtlasStore::in_memory()?;
9722 large_store.set_project_root(large_project.path())?;
9723 let maximum_estimate = usize::try_from(i64::MAX)?;
9724 large_store.record_usage(&usage_from_estimates(
9725 "large-primary",
9726 "large",
9727 None,
9728 None,
9729 maximum_estimate,
9730 0,
9731 ))?;
9732 let Err(overflow) = large_store.record_usage(&usage_from_estimates(
9733 "large-rejected",
9734 "large",
9735 None,
9736 None,
9737 maximum_estimate,
9738 0,
9739 )) else {
9740 return Err(io::Error::other("overflowing telemetry aggregate was committed").into());
9741 };
9742 require_eq(
9743 &matches!(overflow, DbError::TelemetryIntegerOverflow { .. }),
9744 &true,
9745 "overflowing telemetry aggregate error",
9746 )?;
9747 let large = large_store.token_overview(Some("large-primary"))?;
9748 require_eq(
9749 &large.estimated_saved,
9750 &isize::MAX,
9751 "largest accepted aggregate narrows at the public boundary",
9752 )?;
9753 require_eq(
9754 &large_store.token_overview(Some("large-rejected"))?.calls,
9755 &0,
9756 "rejected aggregate left no partial report",
9757 )?;
9758 Ok(())
9759 }
9760
9761 #[test]
9762 fn direct_library_usage_rotates_at_baseline_capacity_without_reopening_the_old_scope()
9763 -> Result<(), Box<dyn Error>> {
9764 let project = tempfile::tempdir()?;
9765 let mut store = AtlasStore::in_memory()?;
9766 store.set_project_root(project.path())?;
9767 let policy = TelemetryRetentionPolicy::default();
9768 let label = "bounded-library-label";
9769
9770 for index in 0..policy.max_baselines_per_instance {
9771 store.record_usage(&usage_from_estimates(
9772 label,
9773 "search",
9774 None,
9775 Some(format!("query-{index}")),
9776 100,
9777 20,
9778 ))?;
9779 }
9780 let old_instance = store
9781 .library_usage_instances
9782 .borrow()
9783 .get(label)
9784 .copied()
9785 .flatten()
9786 .ok_or_else(|| io::Error::other("library instance was not retained"))?;
9787
9788 let boundary_event = usage_from_estimates(
9789 label,
9790 "search",
9791 None,
9792 Some("capacity-boundary".to_string()),
9793 100,
9794 20,
9795 );
9796 store.record_usage(&boundary_event)?;
9797 let replacement = store
9798 .library_usage_instances
9799 .borrow()
9800 .get(label)
9801 .copied()
9802 .flatten()
9803 .ok_or_else(|| io::Error::other("replacement library instance was not retained"))?;
9804 require_eq(
9805 &(replacement != old_instance),
9806 &true,
9807 "capacity rotation created a new internal instance",
9808 )?;
9809 require_eq(
9810 &store.connection.query_row(
9811 "SELECT COUNT(*) FROM usage_instances WHERE state = 'sealed'",
9812 [],
9813 |row| row.get::<_, i64>(0),
9814 )?,
9815 &1,
9816 "sealed predecessor instances",
9817 )?;
9818 require_eq(
9819 &store.connection.query_row(
9820 "SELECT COUNT(*) FROM usage_instances WHERE state = 'active'",
9821 [],
9822 |row| row.get::<_, i64>(0),
9823 )?,
9824 &1,
9825 "active replacement instances",
9826 )?;
9827 require_eq(
9828 &store.connection.query_row(
9829 "SELECT COUNT(*) FROM usage_instance_baselines",
9830 [],
9831 |row| row.get::<_, i64>(0),
9832 )?,
9833 &1,
9834 "only replacement baseline witnesses remain",
9835 )?;
9836 require_eq(
9837 &store.token_overview(Some(label))?.calls,
9838 &(policy.max_baselines_per_instance + 1),
9839 "caller-label report spans both bounded instances",
9840 )?;
9841 require_eq(
9842 &store.token_overview(None)?.estimated_saved,
9843 &(isize::try_from(policy.max_baselines_per_instance + 1)? * 80),
9844 "global totals remain exact across rotation",
9845 )?;
9846 require_eq(
9847 &matches!(
9848 store.record_usage_for_instance(
9849 old_instance,
9850 UsageInstanceOwner::LibraryHandle,
9851 &boundary_event,
9852 false,
9853 ),
9854 Err(DbError::TelemetryInstanceInactive)
9855 ),
9856 &true,
9857 "sealed predecessor cannot be reopened",
9858 )?;
9859 Ok(())
9860 }
9861
9862 #[test]
9863 fn rejected_library_labels_do_not_consume_the_bounded_identity_map()
9864 -> Result<(), Box<dyn Error>> {
9865 let project = tempfile::tempdir()?;
9866 let mut store = AtlasStore::in_memory()?;
9867 store.set_project_root(project.path())?;
9868 let maximum = TelemetryRetentionPolicy::default().max_label_bytes;
9869
9870 for extra in 1..=4 {
9871 let event =
9872 usage_from_estimates(&"x".repeat(maximum + extra), "summary", None, None, 100, 20);
9873 require_eq(
9874 &matches!(
9875 store.record_usage(&event),
9876 Err(DbError::TelemetryFieldTooLarge {
9877 field: "session_id",
9878 ..
9879 })
9880 ),
9881 &true,
9882 "oversized caller label rejection",
9883 )?;
9884 }
9885 require_eq(
9886 &store.library_usage_instances.borrow().len(),
9887 &0,
9888 "rejected labels retained no identity-map entries",
9889 )?;
9890 Ok(())
9891 }
9892
9893 #[test]
9894 fn token_trends_group_usage_by_period_and_bucket() -> Result<(), Box<dyn Error>> {
9895 let project = tempfile::tempdir()?;
9896 let mut store = AtlasStore::in_memory()?;
9897 store.set_project_root(project.path())?;
9898 for (session, bucket, baseline_kind, confidence, without, with) in [
9899 (
9900 "session",
9901 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
9902 "selected_candidates",
9903 "inferred",
9904 100_usize,
9905 25_usize,
9906 ),
9907 (
9908 "session",
9909 TOKEN_BUCKET_FULL_FILE_COMPRESSION,
9910 "full_file",
9911 "observed",
9912 50_usize,
9913 10_usize,
9914 ),
9915 (
9916 "session",
9917 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
9918 "selected_candidates",
9919 "inferred",
9920 80_usize,
9921 20_usize,
9922 ),
9923 (
9924 "other",
9925 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
9926 "selected_candidates",
9927 "inferred",
9928 999_usize,
9929 1_usize,
9930 ),
9931 ] {
9932 store.record_usage(&usage_from_estimates_with_context(
9933 session,
9934 "trend",
9935 None,
9936 None,
9937 without,
9938 with,
9939 bucket,
9940 baseline_kind,
9941 confidence,
9942 ))?;
9943 }
9944
9945 let trends = store.token_trends(Some("session"), TokenTrendWindow::Day)?;
9946 require_eq(&trends.periods.len(), &1, "current daily period")?;
9947 require_eq(&trends.periods[0].calls, &3, "session trend call count")?;
9948 require_eq(
9949 &trends.periods[0].estimated_saved,
9950 &175,
9951 "session trend saved tokens",
9952 )?;
9953 require_eq(
9954 &trends.periods[0].buckets.len(),
9955 &2,
9956 "trend preserves evidence buckets",
9957 )?;
9958 require_eq(
9959 &trends.periods[0].buckets[0].token_savings_bucket,
9960 &TOKEN_BUCKET_FULL_FILE_COMPRESSION.to_string(),
9961 "full-file bucket remains visible",
9962 )?;
9963 require_eq(
9964 &trends.periods[0].buckets[0].confidence,
9965 &"observed".to_string(),
9966 "bucket confidence remains visible",
9967 )?;
9968 let all_labels = store.token_trends(None, TokenTrendWindow::Day)?;
9969 require_eq(&all_labels.periods.len(), &1, "all-label daily period")?;
9970 require_eq(&all_labels.periods[0].calls, &4, "all-label trend calls")?;
9971 Ok(())
9972 }
9973
9974 #[test]
9975 fn unsupported_legacy_schema_is_refused_without_mutation() -> Result<(), Box<dyn Error>> {
9976 let temp = tempfile::tempdir()?;
9977 let db_path = temp.path().join("legacy.db");
9978 {
9979 let connection = Connection::open(&db_path)?;
9980 connection.execute_batch(
9981 "
9982 CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL);
9983 INSERT INTO metadata(key, value) VALUES('schema_version', '7');
9984 CREATE TABLE usage_events (
9985 id INTEGER PRIMARY KEY,
9986 session_id TEXT NOT NULL,
9987 command TEXT NOT NULL,
9988 path TEXT,
9989 query TEXT,
9990 estimated_tokens_without_projectatlas INTEGER,
9991 estimated_tokens_with_projectatlas INTEGER,
9992 estimated_tokens_saved INTEGER,
9993 token_savings_bucket TEXT NOT NULL DEFAULT 'navigation_avoidance',
9994 provider TEXT NOT NULL DEFAULT 'heuristic',
9995 model TEXT NOT NULL DEFAULT 'unknown',
9996 tokenizer_backend TEXT NOT NULL DEFAULT 'chars_div_4',
9997 accuracy TEXT NOT NULL DEFAULT 'heuristic_estimate',
9998 baseline_kind TEXT NOT NULL DEFAULT 'selected_candidates',
9999 confidence TEXT NOT NULL DEFAULT 'inferred',
10000 calculation_trace TEXT NOT NULL DEFAULT 'heuristic=ceil(chars_or_bytes/4)'
10001 );
10002 INSERT INTO usage_events(
10003 session_id,
10004 command,
10005 estimated_tokens_without_projectatlas,
10006 estimated_tokens_with_projectatlas,
10007 estimated_tokens_saved
10008 )
10009 VALUES('legacy-session', 'legacy', 100, 20, 80);
10010 ",
10011 )?;
10012 }
10013
10014 let database_before = fs::read(&db_path)?;
10015 let Err(open_error) = AtlasStore::open(&db_path) else {
10016 return Err(io::Error::other("unsupported schema unexpectedly opened").into());
10017 };
10018 require_eq(
10019 &matches!(
10020 open_error,
10021 DbError::SchemaVersion {
10022 found: 7,
10023 expected: SCHEMA_VERSION,
10024 }
10025 ),
10026 &true,
10027 "unsupported schema rejection",
10028 )?;
10029 require_eq(
10030 &fs::read(&db_path)?,
10031 &database_before,
10032 "unsupported schema bytes remain unchanged",
10033 )?;
10034 Ok(())
10035 }
10036
10037 #[test]
10038 fn set_project_root_requires_existing_root_and_preserves_binding() -> Result<(), Box<dyn Error>>
10039 {
10040 let temp = tempfile::tempdir()?;
10041 let mut store = AtlasStore::in_memory()?;
10042 let missing = temp.path().join("missing-root");
10043 let Err(missing_error) = store.set_project_root(&missing) else {
10044 return Err(io::Error::other("missing project root was accepted").into());
10045 };
10046 require(
10047 matches!(missing_error, DbError::ProjectRootIdentity(_)),
10048 "missing project root returned the wrong error",
10049 )?;
10050 require_eq(
10051 &store.project_root()?,
10052 &None,
10053 "missing-root rejection changed metadata",
10054 )?;
10055 require_eq(
10056 &store.project_root_identity()?,
10057 &None,
10058 "missing-root rejection changed native identity",
10059 )?;
10060 require_eq(
10061 &store.project_instance_id()?,
10062 &None,
10063 "missing-root rejection changed project identity",
10064 )?;
10065
10066 let root = temp.path().join("workspace").join("example");
10067 fs::create_dir_all(&root)?;
10068 store.set_project_root(&root)?;
10069 require_eq(
10070 &store.project_root()?,
10071 &Some(normalize_native_path_display(&root)),
10072 "project root metadata",
10073 )?;
10074
10075 #[cfg(windows)]
10076 let extended = PathBuf::from(format!(r"\\?\{}", root.display()));
10077 #[cfg(windows)]
10078 store.set_project_root(&extended)?;
10079 #[cfg(windows)]
10080 require_eq(
10081 &store.project_root()?,
10082 &Some(normalize_native_path_display(&root)),
10083 "windows extended project root metadata",
10084 )?;
10085 let other = temp.path().join("other");
10086 fs::create_dir(&other)?;
10087 let Err(rebind_error) = store.set_project_root(&other) else {
10088 return Err(io::Error::other("project identity was rebound implicitly").into());
10089 };
10090 require_eq(
10091 &matches!(rebind_error, DbError::ProjectRootMismatch { .. }),
10092 &true,
10093 "project identity rebind rejection",
10094 )?;
10095 Ok(())
10096 }
10097
10098 #[test]
10099 fn ordinary_root_admission_rejects_missing_and_regular_paths_without_mutation()
10100 -> Result<(), Box<dyn Error>> {
10101 let temp = tempfile::tempdir()?;
10102 let root = temp.path().join("root");
10103 let missing = temp.path().join("missing-root");
10104 let regular = temp.path().join("regular-root");
10105 fs::create_dir(&root)?;
10106 fs::write(®ular, b"not a directory")?;
10107 let database = temp.path().join("projectatlas.db");
10108 drop(AtlasStore::open_for_project(&database, &root)?);
10109
10110 for (label, candidate) in [
10111 ("missing", missing.as_path()),
10112 ("regular", regular.as_path()),
10113 ] {
10114 let before = fs::read(&database)?;
10115 if AtlasStore::open_for_project(&database, candidate).is_ok() {
10116 return Err(
10117 io::Error::other(format!("ordinary {label} root admission succeeded")).into(),
10118 );
10119 }
10120 if fs::read(&database)? != before {
10121 return Err(io::Error::other(format!(
10122 "ordinary {label} root admission mutated the database"
10123 ))
10124 .into());
10125 }
10126 if verify_project_database(&database, candidate).is_ok() {
10127 return Err(
10128 io::Error::other(format!("verify admitted ordinary {label} root")).into(),
10129 );
10130 }
10131 if fs::read(&database)? != before {
10132 return Err(io::Error::other(format!(
10133 "verify of ordinary {label} root mutated the database"
10134 ))
10135 .into());
10136 }
10137 }
10138 Ok(())
10139 }
10140
10141 #[test]
10142 fn malformed_project_root_identity_is_refused_without_mutation() -> Result<(), Box<dyn Error>> {
10143 let temp = tempfile::tempdir()?;
10144 let root = temp.path().join("root");
10145 fs::create_dir(&root)?;
10146 let database = temp.path().join("projectatlas.db");
10147 drop(AtlasStore::open_for_project(&database, &root)?);
10148 let connection = Connection::open(&database)?;
10149 connection.execute(
10150 "UPDATE project_root_identity SET root = ?1 WHERE singleton = 1",
10151 [vec![1_u8, 1_u8, b'x']],
10152 )?;
10153 drop(connection);
10154 let before = fs::read(&database)?;
10155
10156 if AtlasStore::open(&database).is_ok()
10157 || AtlasStore::open_for_project(&database, &root).is_ok()
10158 || verify_project_database(&database, &root).is_ok()
10159 {
10160 return Err(io::Error::other("malformed project-root identity was admitted").into());
10161 }
10162 if fs::read(&database)? != before {
10163 return Err(io::Error::other(
10164 "malformed project-root identity refusal mutated the database",
10165 )
10166 .into());
10167 }
10168 Ok(())
10169 }
10170
10171 #[test]
10172 fn read_project_root_read_only_does_not_change_database_bytes() -> Result<(), Box<dyn Error>> {
10173 let temp = tempfile::tempdir()?;
10174 let root = temp.path().join("repo with spaces");
10175 let atlas_dir = root.join(".projectatlas");
10176 fs::create_dir_all(&atlas_dir)?;
10177 let db_path = atlas_dir.join("projectatlas.db");
10178 {
10179 let mut store = AtlasStore::open(&db_path)?;
10180 store.set_project_root(&root)?;
10181 store
10182 .connection
10183 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
10184 }
10185 let wal_path = db_path.with_extension("db-wal");
10186 let shm_path = db_path.with_extension("db-shm");
10187 for sidecar_path in [&wal_path, &shm_path] {
10188 match fs::remove_file(sidecar_path) {
10189 Ok(()) => {}
10190 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10191 Err(error) => return Err(error.into()),
10192 }
10193 }
10194 let database_before = fs::read(&db_path)?;
10195
10196 require_eq(
10197 &read_project_root_read_only(&db_path)?,
10198 &Some(normalize_native_path_display(&root)),
10199 "read-only project root",
10200 )?;
10201 require_eq(
10202 &read_legacy_project_root_candidate_read_only(&db_path)?,
10203 &None,
10204 "current schema has no legacy recovery candidate",
10205 )?;
10206 require_eq(
10207 &fs::read(&db_path)?,
10208 &database_before,
10209 "read-only project-root database bytes",
10210 )?;
10211 Ok(())
10212 }
10213
10214 #[test]
10215 fn read_project_root_read_only_observes_active_wal_state() -> Result<(), Box<dyn Error>> {
10216 let temp = tempfile::tempdir()?;
10217 let root = temp.path().join("repo-Δ");
10218 let atlas_dir = root.join(".projectatlas");
10219 fs::create_dir_all(&atlas_dir)?;
10220 let db_path = atlas_dir.join("projectatlas.db");
10221 let mut store = AtlasStore::open(&db_path)?;
10222 store
10223 .connection
10224 .execute_batch("PRAGMA wal_autocheckpoint = 0")?;
10225 store.set_project_root(&root)?;
10226
10227 require_eq(
10228 &sqlite_sidecar_path(&db_path, "-wal").exists(),
10229 &true,
10230 "active WAL exists",
10231 )?;
10232 require_eq(
10233 &read_project_root_read_only(&db_path)?,
10234 &Some(normalize_native_path_display(&root)),
10235 "WAL-aware read-only project root",
10236 )?;
10237 Ok(())
10238 }
10239
10240 #[test]
10241 fn validated_write_transaction_revalidates_before_operation_and_rolls_back()
10242 -> Result<(), Box<dyn Error>> {
10243 let temp = tempfile::tempdir()?;
10244 let root = temp.path().join("repository");
10245 fs::create_dir_all(&root)?;
10246 let db_path = temp.path().join("projectatlas.db");
10247 let store = AtlasStore::open_for_project(&db_path, &root)?;
10248 let expected_identity = store
10249 .validated_project_instance_id
10250 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
10251 let sentinel_key = "validated_write_transaction_test";
10252
10253 with_validated_write_transaction(
10254 &store.connection,
10255 store.validated_project_root.as_deref(),
10256 Some(expected_identity),
10257 |connection| set_metadata(connection, sentinel_key, "committed"),
10258 )?;
10259 require_eq(
10260 &store.connection.query_row(
10261 "SELECT value FROM metadata WHERE key = ?1",
10262 [sentinel_key],
10263 |row| row.get::<_, String>(0),
10264 )?,
10265 &"committed".to_string(),
10266 "validated write positive control",
10267 )?;
10268
10269 let Err(operation_error) = with_validated_write_transaction(
10270 &store.connection,
10271 store.validated_project_root.as_deref(),
10272 Some(expected_identity),
10273 |connection| -> DbResult<()> {
10274 set_metadata(connection, sentinel_key, "rolled-back")?;
10275 Err(DbError::TelemetryIdentityUnavailable)
10276 },
10277 ) else {
10278 return Err(io::Error::other("failing validated write unexpectedly committed").into());
10279 };
10280 require_eq(
10281 &matches!(operation_error, DbError::TelemetryIdentityUnavailable),
10282 &true,
10283 "validated write operation error",
10284 )?;
10285 require_eq(
10286 &store.connection.query_row(
10287 "SELECT value FROM metadata WHERE key = ?1",
10288 [sentinel_key],
10289 |row| row.get::<_, String>(0),
10290 )?,
10291 &"committed".to_string(),
10292 "validated write rollback",
10293 )?;
10294
10295 with_validated_native_write_transaction(
10296 &store.connection,
10297 store.validated_project_root_identity.as_ref(),
10298 Some(expected_identity),
10299 |connection| set_metadata(connection, sentinel_key, "native-committed"),
10300 )?;
10301 require_eq(
10302 &store.connection.query_row(
10303 "SELECT value FROM metadata WHERE key = ?1",
10304 [sentinel_key],
10305 |row| row.get::<_, String>(0),
10306 )?,
10307 &"native-committed".to_string(),
10308 "native validated write positive control",
10309 )?;
10310
10311 let future_schema = schema::SCHEMA_VERSION + 1;
10312 let newer_owner = Connection::open(&db_path)?;
10313 newer_owner.execute(
10314 "UPDATE metadata SET value = ?2 WHERE key = ?1",
10315 params![schema::SCHEMA_VERSION_KEY, future_schema.to_string()],
10316 )?;
10317 let operation_invoked = Cell::new(false);
10318 let Err(binding_error) = with_validated_native_write_transaction(
10319 &store.connection,
10320 store.validated_project_root_identity.as_ref(),
10321 Some(expected_identity),
10322 |connection| {
10323 operation_invoked.set(true);
10324 set_metadata(connection, sentinel_key, "wrong-schema")
10325 },
10326 ) else {
10327 return Err(io::Error::other("newer schema accepted a validated write").into());
10328 };
10329 require_eq(
10330 &matches!(
10331 binding_error,
10332 DbError::SchemaVersion { found, expected }
10333 if found == future_schema && expected == schema::SCHEMA_VERSION
10334 ),
10335 &true,
10336 "validated write schema recheck",
10337 )?;
10338 require_eq(
10339 &operation_invoked.get(),
10340 &false,
10341 "validated write closure invocation",
10342 )?;
10343 require_eq(
10344 &newer_owner.query_row(
10345 "SELECT value FROM metadata WHERE key = ?1",
10346 [sentinel_key],
10347 |row| row.get::<_, String>(0),
10348 )?,
10349 &"native-committed".to_string(),
10350 "newer-schema sentinel value",
10351 )?;
10352 Ok(())
10353 }
10354
10355 #[test]
10356 fn read_only_telemetry_revalidates_project_identity_before_write() -> Result<(), Box<dyn Error>>
10357 {
10358 let temp = tempfile::tempdir()?;
10359 let root = temp.path().join("repository");
10360 fs::create_dir_all(&root)?;
10361 let db_path = temp.path().join("projectatlas.db");
10362 drop(AtlasStore::open_for_project(&db_path, &root)?);
10363
10364 let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10365 reader.finish_index_read_snapshot()?;
10366 AtlasStore::transition_project_root(&db_path, &root, ProjectRootTransition::Detach)?;
10367
10368 let event = usage_from_estimates(
10369 "identity-race",
10370 "summary",
10371 Some("src/lib.rs".to_string()),
10372 None,
10373 100,
10374 20,
10375 );
10376 let Err(error) = reader.record_usage(&event) else {
10377 return Err(io::Error::other("telemetry wrote through replaced identity").into());
10378 };
10379 require_eq(
10380 &matches!(error, DbError::ProjectRootTransitionChanged { .. }),
10381 &true,
10382 "telemetry project identity recheck",
10383 )?;
10384 let current = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10385 require_eq(
10386 ¤t.token_overview(Some("identity-race"))?.calls,
10387 &0,
10388 "telemetry refused after identity replacement",
10389 )?;
10390 Ok(())
10391 }
10392
10393 #[test]
10394 fn read_only_recovery_requires_native_identity_before_telemetry_write()
10395 -> Result<(), Box<dyn Error>> {
10396 let temp = tempfile::tempdir()?;
10397 let root = temp.path().join("repository");
10398 fs::create_dir_all(&root)?;
10399 let database = temp.path().join("projectatlas.db");
10400 let event = usage_from_estimates(
10401 "rooted-telemetry",
10402 "summary",
10403 Some("src/lib.rs".to_string()),
10404 None,
10405 100,
10406 20,
10407 );
10408 let rooted_instance = UsageInstanceId::from_bytes([7; 16])?;
10409 let native_root_row;
10410 {
10411 let store = AtlasStore::open_for_project(&database, &root)?;
10412 native_root_row = store.connection.query_row(
10413 "SELECT codec_version, root
10414 FROM project_root_identity
10415 WHERE singleton = 1",
10416 [],
10417 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)),
10418 )?;
10419 store.record_usage(&event)?;
10420 store.record_usage_for_instance(
10421 rooted_instance,
10422 UsageInstanceOwner::LibraryHandle,
10423 &event,
10424 false,
10425 )?;
10426 store.seal_usage_instance(rooted_instance)?;
10427 require_eq(
10428 &store.token_overview(Some("rooted-telemetry"))?.calls,
10429 &2,
10430 "rooted telemetry positive write",
10431 )?;
10432 store
10433 .connection
10434 .execute("DELETE FROM project_root_identity", [])?;
10435 store
10436 .connection
10437 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
10438 }
10439
10440 let file_snapshot = || -> Result<BTreeMap<std::ffi::OsString, Vec<u8>>, io::Error> {
10441 let mut files = BTreeMap::new();
10442 for entry in fs::read_dir(temp.path())? {
10443 let entry = entry?;
10444 if entry.file_type()?.is_file() {
10445 files.insert(entry.file_name(), fs::read(entry.path())?);
10446 }
10447 }
10448 Ok(files)
10449 };
10450 let reader = AtlasStore::open_read_only(&database)?;
10451 require_eq(
10452 &reader.project_root_identity()?,
10453 &None,
10454 "rootless recovery has no native identity",
10455 )?;
10456 require_eq(
10457 &reader.validated_project_instance_id.is_some(),
10458 &true,
10459 "rootless recovery retains project identity",
10460 )?;
10461 require_eq(
10462 &reader.project_root()?,
10463 &Some(normalize_native_path_display(&root)),
10464 "rootless recovery retains legacy display root",
10465 )?;
10466 let overview_before = reader.token_overview(Some("rooted-telemetry"))?;
10467 let files_before = file_snapshot()?;
10468
10469 let missing_root_event = usage_from_estimates(
10470 "rootless-telemetry",
10471 "summary",
10472 Some("src/main.rs".to_string()),
10473 None,
10474 80,
10475 20,
10476 );
10477 let Err(error) = reader.record_usage(&missing_root_event) else {
10478 return Err(io::Error::other("rootless recovery recorded direct telemetry").into());
10479 };
10480 require_eq(
10481 &matches!(error, DbError::ProjectRootIdentityMissing),
10482 &true,
10483 "rootless direct telemetry refusal",
10484 )?;
10485
10486 let Err(error) = reader.record_usage_for_instance(
10487 UsageInstanceId::from_bytes([8; 16])?,
10488 UsageInstanceOwner::LibraryHandle,
10489 &missing_root_event,
10490 false,
10491 ) else {
10492 return Err(io::Error::other("rootless recovery recorded instance telemetry").into());
10493 };
10494 require_eq(
10495 &matches!(error, DbError::ProjectRootIdentityMissing),
10496 &true,
10497 "rootless instance telemetry refusal",
10498 )?;
10499
10500 let Err(error) = reader.record_usage_for_worktree_instance(
10501 UsageInstanceId::from_bytes([9; 16])?,
10502 UsageInstanceOwner::McpProcess,
10503 1,
10504 &missing_root_event,
10505 false,
10506 ) else {
10507 return Err(io::Error::other("rootless recovery recorded worktree telemetry").into());
10508 };
10509 require_eq(
10510 &matches!(error, DbError::ProjectRootIdentityMissing),
10511 &true,
10512 "rootless worktree telemetry refusal",
10513 )?;
10514
10515 let Err(error) = reader.seal_usage_instance(UsageInstanceId::from_bytes([10; 16])?) else {
10516 return Err(io::Error::other("rootless recovery sealed telemetry").into());
10517 };
10518 require_eq(
10519 &matches!(error, DbError::ProjectRootIdentityMissing),
10520 &true,
10521 "rootless seal telemetry refusal",
10522 )?;
10523
10524 require_eq(
10525 &reader.token_overview(Some("rooted-telemetry"))?,
10526 &overview_before,
10527 "rootless telemetry rows remain unchanged",
10528 )?;
10529 require_eq(
10530 &file_snapshot()?,
10531 &files_before,
10532 "rootless telemetry files remain unchanged",
10533 )?;
10534 drop(reader);
10535
10536 let repair = Connection::open(&database)?;
10537 schema::configure_writable(&repair)?;
10538 repair.execute(
10539 "INSERT INTO project_root_identity(singleton, codec_version, root)
10540 VALUES(1, ?1, ?2)",
10541 params![native_root_row.0, native_root_row.1],
10542 )?;
10543 drop(repair);
10544 let repaired = AtlasStore::open_for_project(&database, &root)?;
10545 repaired.record_usage(&usage_from_estimates(
10546 "repaired-telemetry",
10547 "summary",
10548 None,
10549 None,
10550 60,
10551 20,
10552 ))?;
10553 require_eq(
10554 &repaired.token_overview(Some("repaired-telemetry"))?.calls,
10555 &1,
10556 "repaired telemetry retry",
10557 )?;
10558 Ok(())
10559 }
10560
10561 #[test]
10562 fn telemetry_contention_uses_ancillary_busy_budget() -> Result<(), Box<dyn Error>> {
10563 let temp = tempfile::tempdir()?;
10564 let root = temp.path().join("repository");
10565 fs::create_dir_all(&root)?;
10566 let db_path = temp.path().join("projectatlas.db");
10567 drop(AtlasStore::open_for_project(&db_path, &root)?);
10568
10569 let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10570 let writer = Connection::open(&db_path)?;
10571 writer.execute_batch("BEGIN IMMEDIATE")?;
10572 let started = Instant::now();
10573 let result = reader.record_usage(&usage_from_estimates(
10574 "contended",
10575 "overview",
10576 None,
10577 None,
10578 100,
10579 20,
10580 ));
10581 let elapsed = started.elapsed();
10582 writer.execute_batch("ROLLBACK")?;
10583
10584 let Err(error) = result else {
10585 return Err(
10586 io::Error::other("contended telemetry write unexpectedly succeeded").into(),
10587 );
10588 };
10589 require_eq(
10590 &error.is_write_unavailable(),
10591 &true,
10592 "contended telemetry error kind",
10593 )?;
10594 require_eq(
10595 &(elapsed < Duration::from_millis(500)),
10596 &true,
10597 "ancillary telemetry busy latency",
10598 )?;
10599 require_eq(
10600 &reader.token_overview(Some("contended"))?.calls,
10601 &0,
10602 "contended telemetry rollback",
10603 )?;
10604 Ok(())
10605 }
10606
10607 #[cfg(unix)]
10608 #[test]
10609 fn read_only_telemetry_does_not_recreate_a_removed_database() -> Result<(), Box<dyn Error>> {
10610 let temp = tempfile::tempdir()?;
10611 let root = temp.path().join("repository");
10612 fs::create_dir_all(&root)?;
10613 let db_path = temp.path().join("projectatlas.db");
10614 drop(AtlasStore::open_for_project(&db_path, &root)?);
10615
10616 let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10617 fs::remove_file(&db_path)?;
10618 let event = usage_from_estimates(
10619 "removed-database",
10620 "summary",
10621 Some("src/lib.rs".to_string()),
10622 None,
10623 100,
10624 20,
10625 );
10626 if reader.record_usage(&event).is_ok() {
10627 return Err(io::Error::other("telemetry reopened a removed database").into());
10628 }
10629 require_eq(
10630 &db_path.exists(),
10631 &false,
10632 "telemetry must not recreate a removed database",
10633 )?;
10634 Ok(())
10635 }
10636
10637 #[test]
10638 fn read_only_store_opens_checkpointed_wal_without_existing_sidecars()
10639 -> Result<(), Box<dyn Error>> {
10640 let temp = tempfile::tempdir()?;
10641 let root = temp.path().join("repo with spaces");
10642 let atlas_dir = root.join(".projectatlas");
10643 fs::create_dir_all(&atlas_dir)?;
10644 let db_path = atlas_dir.join("projectatlas.db");
10645 {
10646 let mut store = AtlasStore::open(&db_path)?;
10647 store.set_project_root(&root)?;
10648 store
10649 .connection
10650 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
10651 }
10652 for sidecar_path in [
10653 sqlite_sidecar_path(&db_path, "-wal"),
10654 sqlite_sidecar_path(&db_path, "-shm"),
10655 ] {
10656 match fs::remove_file(sidecar_path) {
10657 Ok(()) => {}
10658 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10659 Err(error) => return Err(error.into()),
10660 }
10661 }
10662
10663 let store = AtlasStore::open_read_only(&db_path)?;
10664 require_eq(
10665 &store.project_root()?,
10666 &Some(normalize_native_path_display(&root)),
10667 "read-only checkpointed project root",
10668 )?;
10669 store.finish_index_read_snapshot()?;
10670 Ok(())
10671 }
10672
10673 #[test]
10674 fn partial_scan_updates_and_absents_paths() -> Result<(), Box<dyn Error>> {
10675 let mut store = AtlasStore::in_memory()?;
10676 store.replace_scan(&[
10677 test_file_node("src/a.rs", "hash-a"),
10678 test_file_node("src/b.rs", "hash-b"),
10679 ])?;
10680 store.upsert_scan_nodes(&[test_file_node("src/a.rs", "hash-a2")])?;
10681 let updated = store
10682 .load_node_by_path("src/a.rs")?
10683 .ok_or_else(|| io::Error::other("updated node missing"))?;
10684 require_eq(
10685 &updated.node.content_hash,
10686 &Some("hash-a2".to_string()),
10687 "partial content hash",
10688 )?;
10689 require_eq(
10690 &store.load_node_by_path("src/b.rs")?.is_some(),
10691 &true,
10692 "unrelated node remains indexed",
10693 )?;
10694 store.mark_paths_absent(&["src/b.rs".to_string()])?;
10695 require_eq(
10696 &store.load_node_by_path("src/b.rs")?.is_none(),
10697 &true,
10698 "absent path is no longer returned",
10699 )?;
10700 Ok(())
10701 }
10702
10703 #[test]
10704 fn approved_purpose_survives_incremental_file_hash_changes() -> Result<(), Box<dyn Error>> {
10705 let mut store = AtlasStore::in_memory()?;
10706 store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
10707 store.set_purpose(
10708 "src/main.rs",
10709 "Application entry point",
10710 PurposeSource::Agent,
10711 )?;
10712 store.upsert_scan_nodes(&[test_file_node("src/main.rs", "hash-b")])?;
10713
10714 let node = store
10715 .load_node_by_path("src/main.rs")?
10716 .ok_or_else(|| io::Error::other("changed node missing"))?;
10717 require_eq(
10718 &node.purpose.status,
10719 &PurposeStatus::Approved,
10720 "changed approved file purpose status",
10721 )?;
10722 require_eq(
10723 &node.purpose.agent_reviewed(),
10724 &true,
10725 "changed approved file remains agent reviewed",
10726 )?;
10727 Ok(())
10728 }
10729
10730 #[test]
10731 fn ranked_nodes_are_loaded_bounded_from_sql() -> Result<(), Box<dyn Error>> {
10732 let mut store = AtlasStore::in_memory()?;
10733 let mut gradle_task_node = test_file_node("build.gradle.kts", "hash-gradle");
10734 gradle_task_node.extension = Some(".kts".to_string());
10735 gradle_task_node.language = Some("kotlin".to_string());
10736 store.replace_scan(&[
10737 test_folder_node("src/auth"),
10738 test_folder_node("src/ui"),
10739 test_file_node("src/auth/login.rs", "hash-login"),
10740 test_file_node("src/ui/button.rs", "hash-button"),
10741 gradle_task_node,
10742 ])?;
10743 store.set_purpose(
10744 "src/auth",
10745 "Authentication workflow folder",
10746 PurposeSource::Agent,
10747 )?;
10748 store.set_purpose("src/ui", "User interface folder", PurposeSource::Agent)?;
10749 store.set_node_summary("src/auth/login.rs", "rust source defining login flow")?;
10750
10751 let folders = store.load_ranked_nodes("authentication", NodeKind::Folder, None, 1, 0)?;
10752 require_eq(&folders.len(), &1, "bounded folder ranking")?;
10753 require_eq(
10754 &folders[0].node.path,
10755 &"src/auth".to_string(),
10756 "semantic folder ranking",
10757 )?;
10758
10759 let files = store.load_ranked_nodes("login", NodeKind::File, Some("src/auth"), 10, 0)?;
10760 require_eq(&files.len(), &1, "folder-constrained file ranking")?;
10761 require_eq(
10762 &files[0].node.path,
10763 &"src/auth/login.rs".to_string(),
10764 "ranked file path",
10765 )?;
10766 store.replace_symbol_graph(&SymbolGraph {
10767 path: "build.gradle.kts".to_string(),
10768 language: Some("kotlin".to_string()),
10769 parser: ParserKind::TreeSitter,
10770 symbols: vec![CodeSymbol {
10771 path: "build.gradle.kts".to_string(),
10772 language: Some("kotlin".to_string()),
10773 name: "bootRunE2E".to_string(),
10774 kind: SymbolKind::Function,
10775 signature: "tasks.register<BootRun>(\"bootRunE2E\")".to_string(),
10776 exported: false,
10777 documentation: None,
10778 line_start: 1,
10779 line_end: 1,
10780 source_selector: None,
10781 parent: None,
10782 parser: ParserKind::TreeSitter,
10783 detail: Some("gradle-kotlin-dsl-task".to_string()),
10784 }],
10785 relations: Vec::new(),
10786 })?;
10787 let gradle_files = store.load_ranked_nodes("bootRunE2E", NodeKind::File, None, 10, 0)?;
10788 require_eq(&gradle_files.len(), &1, "symbol-ranked file count")?;
10789 require_eq(
10790 &gradle_files[0].node.path,
10791 &"build.gradle.kts".to_string(),
10792 "symbol-ranked file path",
10793 )?;
10794 store.clear_symbol_graph_for_path("build.gradle.kts")?;
10795 let cleared_gradle_files =
10796 store.load_ranked_nodes("bootRunE2E", NodeKind::File, None, 10, 0)?;
10797 require_eq(
10798 &cleared_gradle_files.len(),
10799 &0,
10800 "cleared symbol-ranked file count",
10801 )?;
10802 Ok(())
10803 }
10804
10805 #[test]
10806 fn ranked_node_admission_preserves_dominant_tiers_before_candidate_cap()
10807 -> Result<(), Box<dyn Error>> {
10808 let mut store = AtlasStore::in_memory()?;
10809 let mut nodes = vec![
10810 test_file_node("needle", "hash-exact"),
10811 test_file_node("deep/needle", "hash-name"),
10812 test_file_node("reviewed.rs", "hash-reviewed"),
10813 ];
10814 nodes.extend(
10815 (0..130).map(|index| test_file_node(&format!("weak/needle-{index:03}.rs"), "hash")),
10816 );
10817 store.replace_scan(&nodes)?;
10818 store.set_purpose(
10819 "reviewed.rs",
10820 "Own needle responsibility",
10821 PurposeSource::Agent,
10822 )?;
10823 for index in 0..130 {
10824 let path = format!("weak/needle-{index:03}.rs");
10825 store.set_suggested_purpose(&path, "Generated needle suggestion")?;
10826 store.set_node_summary(&path, "Observed needle summary")?;
10827 }
10828
10829 let selected = store.load_ranked_nodes("needle", NodeKind::File, None, 100, 0)?;
10830 require_eq(&selected.len(), &100, "bounded adversarial candidate count")?;
10831 require_eq(
10832 &selected[..3]
10833 .iter()
10834 .map(|node| node.node.path.as_str())
10835 .collect::<Vec<_>>(),
10836 &vec!["needle", "deep/needle", "reviewed.rs"],
10837 "pre-cap exact path basename and reviewed-purpose admission",
10838 )?;
10839 Ok(())
10840 }
10841
10842 #[test]
10843 fn folder_like_filters_treat_wildcards_as_literal_path_text() -> Result<(), Box<dyn Error>> {
10844 let mut store = AtlasStore::in_memory()?;
10845 store.replace_scan(&[
10846 test_folder_node("src/a%b"),
10847 test_folder_node("src/axb"),
10848 test_folder_node("src/a_b"),
10849 test_folder_node("src/acb"),
10850 test_file_node("src/a%b/target.rs", "hash-percent-target"),
10851 test_file_node("src/axb/false.rs", "hash-percent-false"),
10852 test_file_node("src/a_b/target.rs", "hash-underscore-target"),
10853 test_file_node("src/acb/false.rs", "hash-underscore-false"),
10854 ])?;
10855 for path in [
10856 "src/a%b/target.rs",
10857 "src/axb/false.rs",
10858 "src/a_b/target.rs",
10859 "src/acb/false.rs",
10860 ] {
10861 store.set_node_summary(path, "needle indexed summary")?;
10862 }
10863
10864 let percent_files =
10865 store.load_ranked_nodes("needle", NodeKind::File, Some("src/a%b"), 10, 0)?;
10866 require_eq(&percent_files.len(), &1, "percent folder ranked count")?;
10867 require_eq(
10868 &percent_files[0].node.path,
10869 &"src/a%b/target.rs".to_string(),
10870 "percent folder ranked path",
10871 )?;
10872 require_eq(
10873 &store.source_file_byte_count(Some("src/a%b"))?,
10874 &12,
10875 "percent folder byte count",
10876 )?;
10877
10878 let mut visited = Vec::new();
10879 store.visit_file_token_estimates(Some("src/a_b"), |path, _size| {
10880 visited.push(path);
10881 Ok(true)
10882 })?;
10883 require_eq(
10884 &visited,
10885 &vec!["src/a_b/target.rs".to_string()],
10886 "underscore folder token paths",
10887 )?;
10888
10889 store.mark_paths_absent(&["src/a%b".to_string(), "src/a_b".to_string()])?;
10890 require_eq(
10891 &store.load_node_by_path("src/axb/false.rs")?.is_some(),
10892 &true,
10893 "percent-like sibling remains indexed",
10894 )?;
10895 require_eq(
10896 &store.load_node_by_path("src/acb/false.rs")?.is_some(),
10897 &true,
10898 "underscore-like sibling remains indexed",
10899 )?;
10900 require_eq(
10901 &store.load_node_by_path("src/a%b/target.rs")?.is_none(),
10902 &true,
10903 "percent folder target removed",
10904 )?;
10905 require_eq(
10906 &store.load_node_by_path("src/a_b/target.rs")?.is_none(),
10907 &true,
10908 "underscore folder target removed",
10909 )?;
10910 Ok(())
10911 }
10912
10913 #[test]
10914 fn sql_health_findings_match_resolution_ids() -> Result<(), Box<dyn Error>> {
10915 let mut store = AtlasStore::in_memory()?;
10916 store.replace_scan(&[
10917 test_file_node("src/a.rs", "hash-a"),
10918 test_file_node("src/b.rs", "hash-b"),
10919 ])?;
10920 store.set_purpose("src/a.rs", "Shared purpose", PurposeSource::Agent)?;
10921 store.set_purpose("src/b.rs", "Shared purpose", PurposeSource::Agent)?;
10922
10923 let findings = store.unresolved_health_findings(&[])?;
10924 let duplicate = findings
10925 .iter()
10926 .find(|finding| finding.category == "duplicate-purpose")
10927 .ok_or_else(|| io::Error::other("duplicate-purpose finding missing"))?;
10928 store.resolve_health_finding(&HealthResolution {
10929 finding_id: duplicate.id.clone(),
10930 category: duplicate.category.clone(),
10931 path: duplicate.path.clone(),
10932 related_path: duplicate.related_path.clone(),
10933 rationale: "Intentional mirror for test.".to_string(),
10934 })?;
10935 let remaining = store.unresolved_health_findings(&store.resolved_health_ids()?)?;
10936 require_eq(&remaining.is_empty(), &true, "resolved SQL health finding")?;
10937 Ok(())
10938 }
10939
10940 #[test]
10941 fn unresolved_health_findings_page_filters_and_bounds_rows() -> Result<(), Box<dyn Error>> {
10942 let mut store = AtlasStore::in_memory()?;
10943 store.replace_scan(&[
10944 test_folder_node("."),
10945 test_folder_node("src"),
10946 test_file_node("src/a.rs", "hash-a"),
10947 test_file_node("docs/a.rs", "hash-doc"),
10948 ])?;
10949 let query = HealthQuery {
10950 start_index: 1,
10951 limit: 1,
10952 category: Some("missing-purpose".to_string()),
10953 severity: Some(Severity::Warning),
10954 path_prefix: Some("src".to_string()),
10955 summary_only: false,
10956 scope: HealthScope::all(),
10957 };
10958
10959 let page = store.unresolved_health_findings_page(&[], &query)?;
10960 require_eq(&page.unfiltered_total, &4, "unfiltered health total")?;
10961 require_eq(&page.total, &2, "filtered health total")?;
10962 require_eq(&page.returned, &1, "returned health rows")?;
10963 require_eq(
10964 &page.findings[0].path,
10965 &"src/a.rs".to_string(),
10966 "paged path",
10967 )?;
10968
10969 let summary_page = store.unresolved_health_findings_page(
10970 &[],
10971 &HealthQuery {
10972 summary_only: true,
10973 ..query
10974 },
10975 )?;
10976 require_eq(&summary_page.total, &2, "summary-only total")?;
10977 require_eq(
10978 &summary_page.findings.is_empty(),
10979 &true,
10980 "summary-only rows",
10981 )?;
10982 Ok(())
10983 }
10984
10985 #[test]
10986 fn unresolved_health_findings_page_skips_resolved_lifecycle_rows_before_paging()
10987 -> Result<(), Box<dyn Error>> {
10988 let mut store = AtlasStore::in_memory()?;
10989 store.replace_scan(&[
10990 test_file_node("src/a.rs", "hash-a"),
10991 test_file_node("src/b.rs", "hash-b"),
10992 test_file_node("src/c.rs", "hash-c"),
10993 test_file_node("src/d.rs", "hash-d"),
10994 ])?;
10995 store.resolve_health_finding(&HealthResolution {
10996 finding_id: finding_id("missing-purpose", "src/b.rs", None),
10997 category: "missing-purpose".to_string(),
10998 path: "src/b.rs".to_string(),
10999 related_path: None,
11000 rationale: "Resolved for pagination regression.".to_string(),
11001 })?;
11002
11003 let page = store.unresolved_health_findings_page_current(&HealthQuery {
11004 start_index: 0,
11005 limit: 2,
11006 category: Some("missing-purpose".to_string()),
11007 severity: Some(Severity::Warning),
11008 path_prefix: Some("src".to_string()),
11009 summary_only: false,
11010 scope: HealthScope::all(),
11011 })?;
11012
11013 require_eq(&page.total, &3, "filtered unresolved missing total")?;
11014 require_eq(&page.returned, &2, "returned unresolved missing rows")?;
11015 require_eq(
11016 &page
11017 .findings
11018 .iter()
11019 .map(|finding| finding.path.as_str())
11020 .collect::<Vec<_>>(),
11021 &vec!["src/a.rs", "src/c.rs"],
11022 "resolved row skipped before limit",
11023 )?;
11024 Ok(())
11025 }
11026
11027 #[test]
11028 fn stored_health_resolution_filter_stays_indexed_and_bind_bounded() -> Result<(), Box<dyn Error>>
11029 {
11030 const HISTORICAL_RESOLUTIONS: usize = 1_500;
11031
11032 let temp = tempfile::tempdir()?;
11033 let root = temp.path().join("repository");
11034 fs::create_dir_all(root.join("src"))?;
11035 let database = temp.path().join("projectatlas.db");
11036 let mut store = AtlasStore::open_for_project(&database, &root)?;
11037 store.replace_scan(&[test_file_node("src/current.rs", "hash-current")])?;
11038 {
11039 let transaction = store.connection.transaction()?;
11040 {
11041 let mut statement = transaction.prepare(
11042 "
11043 INSERT INTO health_resolutions(
11044 finding_id, category, path, related_path, rationale, resolved_by
11045 )
11046 VALUES(?1, 'missing-purpose', ?2, NULL, 'historical', 'agent')
11047 ",
11048 )?;
11049 for index in 0..HISTORICAL_RESOLUTIONS {
11050 let path = format!("removed/{index}.rs");
11051 statement.execute(params![finding_id("missing-purpose", &path, None), path])?;
11052 }
11053 }
11054 transaction.commit()?;
11055 }
11056
11057 drop(store);
11058 let store = AtlasStore::open_read_only_for_project(&database, &root)?;
11059 let page = store.unresolved_health_findings_page_current(&HealthQuery {
11060 start_index: 0,
11061 limit: 2,
11062 category: Some("missing-purpose".to_string()),
11063 severity: Some(Severity::Warning),
11064 path_prefix: Some("src".to_string()),
11065 summary_only: false,
11066 scope: HealthScope::all(),
11067 })?;
11068 require_eq(&page.total, &1, "current unresolved total")?;
11069 require_eq(
11070 &page.findings[0].path,
11071 &"src/current.rs".to_string(),
11072 "current unresolved path",
11073 )?;
11074
11075 let (where_clause, values) = purpose_status_where_clause(
11076 PURPOSE_HEALTH_SPECS[0],
11077 None,
11078 HealthResolutionFilter::Stored,
11079 HealthScope::all(),
11080 );
11081 require_eq(&values.len(), &1, "stored filter bind count")?;
11082 let plan_sql = format!(
11083 "EXPLAIN QUERY PLAN
11084 SELECT COUNT(*)
11085 FROM nodes n
11086 JOIN purposes p ON p.node_id = n.id
11087 WHERE {where_clause}"
11088 );
11089 let mut statement = store.connection.prepare(&plan_sql)?;
11090 let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(3))?;
11091 let mut plan = Vec::new();
11092 for row in rows {
11093 plan.push(row?);
11094 }
11095 if !plan.iter().any(|detail| {
11096 detail.contains("health_resolutions")
11097 && detail.contains("finding_id")
11098 && detail.contains("SEARCH")
11099 }) {
11100 return Err(io::Error::other(format!(
11101 "stored resolution anti-lookup did not use the finding-id index: {plan:?}"
11102 ))
11103 .into());
11104 }
11105 store.finish_index_read_snapshot()?;
11106 Ok(())
11107 }
11108
11109 #[test]
11110 fn unresolved_health_findings_page_streams_duplicate_and_temp_rows()
11111 -> Result<(), Box<dyn Error>> {
11112 let mut store = AtlasStore::in_memory()?;
11113 store.replace_scan(&[
11114 test_folder_node("."),
11115 test_folder_node("tmp"),
11116 test_folder_node("src"),
11117 test_folder_node("src/tmp"),
11118 test_file_node("src/a.rs", "hash-a"),
11119 test_file_node("src/b.rs", "hash-b"),
11120 ])?;
11121 store.set_purpose(".", "Repository root", PurposeSource::Agent)?;
11122 store.set_purpose("src", "Source folder", PurposeSource::Agent)?;
11123 store.set_purpose("tmp", "Temporary output", PurposeSource::Agent)?;
11124 store.set_purpose("src/tmp", "Source temporary output", PurposeSource::Agent)?;
11125 store.set_purpose("src/a.rs", "Shared implementation", PurposeSource::Agent)?;
11126 store.set_purpose("src/b.rs", "Shared implementation", PurposeSource::Agent)?;
11127
11128 let duplicate_page = store.unresolved_health_findings_page(
11129 &[],
11130 &HealthQuery {
11131 start_index: 0,
11132 limit: 1,
11133 category: Some("duplicate-purpose".to_string()),
11134 severity: Some(Severity::Warning),
11135 path_prefix: Some("src".to_string()),
11136 summary_only: false,
11137 scope: HealthScope::all(),
11138 },
11139 )?;
11140 require_eq(&duplicate_page.total, &1, "duplicate total")?;
11141 require_eq(&duplicate_page.returned, &1, "duplicate returned")?;
11142 require_eq(
11143 &duplicate_page.findings[0].category,
11144 &"duplicate-purpose".to_string(),
11145 "duplicate category",
11146 )?;
11147
11148 let temp_page = store.unresolved_health_findings_page(
11149 &[],
11150 &HealthQuery {
11151 start_index: 0,
11152 limit: 1,
11153 category: Some("repeated-temporary-folder".to_string()),
11154 severity: Some(Severity::Warning),
11155 path_prefix: Some(".".to_string()),
11156 summary_only: false,
11157 scope: HealthScope::all(),
11158 },
11159 )?;
11160 require_eq(&temp_page.total, &1, "temp total")?;
11161 require_eq(&temp_page.returned, &1, "temp returned")?;
11162 require_eq(
11163 &temp_page.findings[0].category,
11164 &"repeated-temporary-folder".to_string(),
11165 "temp category",
11166 )?;
11167 Ok(())
11168 }
11169
11170 #[test]
11171 fn unresolved_health_findings_page_source_only_filters_asset_noise()
11172 -> Result<(), Box<dyn Error>> {
11173 let mut store = AtlasStore::in_memory()?;
11174 let asset_file = Node {
11175 path: "assets/logo.png".to_string(),
11176 kind: NodeKind::File,
11177 parent_path: Some("assets".to_string()),
11178 extension: Some(".png".to_string()),
11179 language: None,
11180 size_bytes: Some(42),
11181 mtime_ns: Some(10),
11182 content_hash: Some("hash-logo".to_string()),
11183 };
11184 store.replace_scan(&[
11185 test_folder_node("."),
11186 test_folder_node("src"),
11187 test_file_node("src/main.rs", "hash-main"),
11188 test_folder_node("assets"),
11189 asset_file,
11190 ])?;
11191
11192 let page = store.unresolved_health_findings_page(
11193 &[],
11194 &HealthQuery {
11195 start_index: 0,
11196 limit: 10,
11197 category: Some("missing-purpose".to_string()),
11198 severity: Some(Severity::Warning),
11199 path_prefix: Some(".".to_string()),
11200 summary_only: false,
11201 scope: HealthScope::source_only(),
11202 },
11203 )?;
11204
11205 require_eq(&page.unfiltered_total, &5, "all unresolved rows")?;
11206 require_eq(&page.total, &3, "source-only missing total")?;
11207 require_eq(
11208 &page
11209 .findings
11210 .iter()
11211 .map(|finding| finding.path.as_str())
11212 .collect::<Vec<_>>(),
11213 &vec![".", "src", "src/main.rs"],
11214 "source-only paths",
11215 )?;
11216 Ok(())
11217 }
11218
11219 #[test]
11220 fn high_impact_purpose_queue_filters_low_priority_files() -> Result<(), Box<dyn Error>> {
11221 let mut store = AtlasStore::in_memory()?;
11222 store.replace_scan(&[
11223 test_folder_node("."),
11224 test_folder_node("src"),
11225 test_file_node("src/main.rs", "hash-main"),
11226 test_file_node("src/helper.rs", "hash-helper"),
11227 test_file_node("build.gradle.kts", "hash-gradle"),
11228 ])?;
11229
11230 let page = store.unresolved_health_findings_page(
11231 &[],
11232 &HealthQuery {
11233 start_index: 0,
11234 limit: 10,
11235 category: Some("missing-purpose".to_string()),
11236 severity: Some(Severity::Warning),
11237 path_prefix: Some(".".to_string()),
11238 summary_only: false,
11239 scope: HealthScope::purpose_default(),
11240 },
11241 )?;
11242
11243 require_eq(&page.unfiltered_total, &5, "all missing rows")?;
11244 require_eq(&page.total, &4, "default actionable rows")?;
11245 require_eq(
11246 &page
11247 .findings
11248 .iter()
11249 .map(|finding| finding.path.as_str())
11250 .collect::<Vec<_>>(),
11251 &vec![".", "src", "build.gradle.kts", "src/main.rs"],
11252 "folder-first high-impact queue paths",
11253 )?;
11254
11255 let broad_page = store.unresolved_health_findings_page(
11256 &[],
11257 &HealthQuery {
11258 start_index: 0,
11259 limit: 10,
11260 category: Some("missing-purpose".to_string()),
11261 severity: Some(Severity::Warning),
11262 path_prefix: Some(".".to_string()),
11263 summary_only: false,
11264 scope: HealthScope::all(),
11265 },
11266 )?;
11267 require_eq(&broad_page.total, &5, "explicit broad queue rows")?;
11268 Ok(())
11269 }
11270
11271 #[test]
11272 fn high_impact_purpose_queue_keeps_asset_only_folders_without_asset_files()
11273 -> Result<(), Box<dyn Error>> {
11274 let mut store = AtlasStore::in_memory()?;
11275 let asset_file = Node {
11276 path: "assets/logo.svg".to_string(),
11277 kind: NodeKind::File,
11278 parent_path: Some("assets".to_string()),
11279 extension: Some(".svg".to_string()),
11280 language: None,
11281 size_bytes: Some(42),
11282 mtime_ns: Some(10),
11283 content_hash: Some("hash-logo".to_string()),
11284 };
11285 store.replace_scan(&[
11286 test_folder_node("."),
11287 test_folder_node("assets"),
11288 test_folder_node("src"),
11289 asset_file,
11290 test_file_node("src/helper.rs", "hash-helper"),
11291 ])?;
11292
11293 let page = store.unresolved_health_findings_page(
11294 &[],
11295 &HealthQuery {
11296 start_index: 0,
11297 limit: 10,
11298 category: Some("missing-purpose".to_string()),
11299 severity: Some(Severity::Warning),
11300 path_prefix: Some(".".to_string()),
11301 summary_only: false,
11302 scope: HealthScope::purpose_default(),
11303 },
11304 )?;
11305
11306 require_eq(&page.unfiltered_total, &5, "all missing rows")?;
11307 require_eq(&page.total, &3, "all folders without asset files")?;
11308 require_eq(
11309 &page
11310 .findings
11311 .iter()
11312 .map(|finding| finding.path.as_str())
11313 .collect::<Vec<_>>(),
11314 &vec![".", "assets", "src"],
11315 "folder-first default queue keeps asset-only folders",
11316 )?;
11317 Ok(())
11318 }
11319
11320 #[test]
11321 fn high_impact_purpose_queue_pages_folders_before_files() -> Result<(), Box<dyn Error>> {
11322 let mut store = AtlasStore::in_memory()?;
11323 store.replace_scan(&[
11324 test_folder_node("."),
11325 test_folder_node("src"),
11326 test_file_node("Cargo.toml", "hash-cargo"),
11327 test_file_node("package.json", "hash-package"),
11328 test_file_node("pyproject.toml", "hash-python"),
11329 ])?;
11330
11331 let page = store.unresolved_health_findings_page(
11332 &[],
11333 &HealthQuery {
11334 start_index: 0,
11335 limit: 2,
11336 category: Some("missing-purpose".to_string()),
11337 severity: Some(Severity::Warning),
11338 path_prefix: Some(".".to_string()),
11339 summary_only: false,
11340 scope: HealthScope::purpose_default(),
11341 },
11342 )?;
11343
11344 require_eq(&page.total, &5, "default actionable total")?;
11345 require_eq(
11346 &page
11347 .findings
11348 .iter()
11349 .map(|finding| finding.path.as_str())
11350 .collect::<Vec<_>>(),
11351 &vec![".", "src"],
11352 "small page keeps folders first",
11353 )?;
11354 Ok(())
11355 }
11356
11357 #[test]
11358 fn high_impact_purpose_queue_omits_low_value_stale_files() -> Result<(), Box<dyn Error>> {
11359 let mut store = AtlasStore::in_memory()?;
11360 store.replace_scan(&[
11361 test_file_node("src/helper.rs", "hash-a"),
11362 test_file_node("Cargo.toml", "hash-cargo"),
11363 test_file_node("package.json", "hash-package"),
11364 ])?;
11365 store.set_purpose(
11366 "src/helper.rs",
11367 "Reviewed helper implementation.",
11368 PurposeSource::Agent,
11369 )?;
11370 store.set_purpose(
11371 "Cargo.toml",
11372 "Reviewed Cargo manifest.",
11373 PurposeSource::Agent,
11374 )?;
11375 store.connection.execute(
11376 "UPDATE purposes
11377 SET status = 'stale'
11378 WHERE node_id IN (
11379 SELECT id FROM nodes WHERE path IN ('src/helper.rs', 'Cargo.toml')
11380 )",
11381 [],
11382 )?;
11383 store.replace_scan(&[
11384 test_file_node("src/helper.rs", "hash-b"),
11385 test_file_node("Cargo.toml", "hash-cargo-new"),
11386 test_file_node("package.json", "hash-package"),
11387 ])?;
11388
11389 let page = store.unresolved_health_findings_page(
11390 &[],
11391 &HealthQuery {
11392 start_index: 0,
11393 limit: 1,
11394 category: None,
11395 severity: Some(Severity::Warning),
11396 path_prefix: Some(".".to_string()),
11397 summary_only: false,
11398 scope: HealthScope::purpose_default(),
11399 },
11400 )?;
11401
11402 require_eq(&page.total, &2, "default actionable total")?;
11403 require_eq(&page.returned, &1, "small page returned")?;
11404 require_eq(
11405 &page.findings[0].category,
11406 &"stale-purpose".to_string(),
11407 "stale high-impact file is still prioritized",
11408 )?;
11409 require_eq(
11410 &page.findings[0].path,
11411 &"Cargo.toml".to_string(),
11412 "stale high-impact file path",
11413 )?;
11414
11415 let broad_page = store.unresolved_health_findings_page(
11416 &[],
11417 &HealthQuery {
11418 start_index: 0,
11419 limit: 10,
11420 category: Some(CATEGORY_STALE_PURPOSE.to_string()),
11421 severity: Some(Severity::Warning),
11422 path_prefix: Some(".".to_string()),
11423 summary_only: false,
11424 scope: HealthScope::purpose_with_source_files(),
11425 },
11426 )?;
11427 require_eq(&broad_page.total, &2, "broad source stale rows")?;
11428 require_eq(
11429 &health_paths(&broad_page),
11430 &vec!["Cargo.toml", "src/helper.rs"],
11431 "broad scope includes low-value stale source files",
11432 )?;
11433 Ok(())
11434 }
11435
11436 #[test]
11437 fn include_assets_queue_includes_asset_files_not_low_priority_source()
11438 -> Result<(), Box<dyn Error>> {
11439 let mut store = AtlasStore::in_memory()?;
11440 let asset_file = Node {
11441 path: "assets/logo.svg".to_string(),
11442 kind: NodeKind::File,
11443 parent_path: Some("assets".to_string()),
11444 extension: Some(".svg".to_string()),
11445 language: None,
11446 size_bytes: Some(42),
11447 mtime_ns: Some(10),
11448 content_hash: Some("hash-logo".to_string()),
11449 };
11450 store.replace_scan(&[
11451 test_folder_node("."),
11452 test_folder_node("assets"),
11453 test_folder_node("src"),
11454 asset_file,
11455 test_file_node("src/helper.rs", "hash-helper"),
11456 ])?;
11457
11458 let page = store.unresolved_health_findings_page(
11459 &[],
11460 &HealthQuery {
11461 start_index: 0,
11462 limit: 10,
11463 category: Some("missing-purpose".to_string()),
11464 severity: Some(Severity::Warning),
11465 path_prefix: Some(".".to_string()),
11466 summary_only: false,
11467 scope: HealthScope::purpose_with_assets(),
11468 },
11469 )?;
11470
11471 require_eq(&page.unfiltered_total, &5, "all missing rows")?;
11472 require_eq(
11473 &page.total,
11474 &4,
11475 "assets included without broad source cleanup",
11476 )?;
11477 require_eq(
11478 &page
11479 .findings
11480 .iter()
11481 .map(|finding| finding.path.as_str())
11482 .collect::<Vec<_>>(),
11483 &vec![".", "assets", "src", "assets/logo.svg"],
11484 "asset files included and low-priority source omitted",
11485 )?;
11486 Ok(())
11487 }
11488
11489 #[test]
11490 fn legacy_human_stale_files_remain_in_default_queue() -> Result<(), Box<dyn Error>> {
11491 let mut store = AtlasStore::in_memory()?;
11492 store.replace_scan(&[test_file_node("src/helper.rs", "hash-a")])?;
11493 store.set_purpose(
11494 "src/helper.rs",
11495 "Legacy reviewed helper implementation.",
11496 PurposeSource::Agent,
11497 )?;
11498 store.connection.execute(
11499 "
11500 UPDATE purposes
11501 SET source = 'human', status = 'stale'
11502 WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/helper.rs')
11503 ",
11504 [],
11505 )?;
11506 store.replace_scan(&[test_file_node("src/helper.rs", "hash-b")])?;
11507
11508 let page = store.unresolved_health_findings_page(
11509 &[],
11510 &HealthQuery {
11511 start_index: 0,
11512 limit: 10,
11513 category: Some("stale-purpose".to_string()),
11514 severity: Some(Severity::Warning),
11515 path_prefix: Some(".".to_string()),
11516 summary_only: false,
11517 scope: HealthScope::purpose_default(),
11518 },
11519 )?;
11520
11521 require_eq(&page.total, &1, "legacy reviewed stale row total")?;
11522 require_eq(
11523 &page.findings[0].path,
11524 &"src/helper.rs".to_string(),
11525 "legacy reviewed stale file",
11526 )?;
11527 Ok(())
11528 }
11529
11530 #[test]
11531 fn stale_imported_files_remain_in_default_queue() -> Result<(), Box<dyn Error>> {
11532 let mut store = AtlasStore::in_memory()?;
11533 store.replace_scan(&[
11534 test_file_node("src/imported.rs", "hash-a"),
11535 test_file_node("Cargo.toml", "hash-cargo"),
11536 ])?;
11537 store.set_purpose(
11538 "src/imported.rs",
11539 "Imported helper implementation.",
11540 PurposeSource::Imported,
11541 )?;
11542 store.connection.execute(
11543 "UPDATE purposes
11544 SET status = 'stale'
11545 WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/imported.rs')",
11546 [],
11547 )?;
11548 store.replace_scan(&[
11549 test_file_node("src/imported.rs", "hash-b"),
11550 test_file_node("Cargo.toml", "hash-cargo"),
11551 ])?;
11552
11553 let page = store.unresolved_health_findings_page(
11554 &[],
11555 &HealthQuery {
11556 start_index: 0,
11557 limit: 10,
11558 category: None,
11559 severity: Some(Severity::Warning),
11560 path_prefix: Some(".".to_string()),
11561 summary_only: false,
11562 scope: HealthScope::purpose_default(),
11563 },
11564 )?;
11565
11566 require_eq(
11567 &page.total,
11568 &2,
11569 "default queue includes stale imported file",
11570 )?;
11571 require_eq(
11572 &health_paths(&page),
11573 &vec!["src/imported.rs", "Cargo.toml"],
11574 "stale imported file is queued before high-impact files",
11575 )?;
11576 require_eq(
11577 &page.findings[0].category,
11578 &"stale-purpose".to_string(),
11579 "stale imported finding category",
11580 )?;
11581 Ok(())
11582 }
11583
11584 #[test]
11585 fn duplicate_purpose_health_is_contextual_for_folders() -> Result<(), Box<dyn Error>> {
11586 let mut store = AtlasStore::in_memory()?;
11587 store.replace_scan(&[
11588 test_folder_node("."),
11589 test_folder_node("customers"),
11590 test_folder_node("customers/service"),
11591 test_folder_node("settings"),
11592 test_folder_node("settings/service"),
11593 ])?;
11594 store.set_purpose("customers/service", "Service layer", PurposeSource::Agent)?;
11595 store.set_purpose("settings/service", "Service layer", PurposeSource::Agent)?;
11596
11597 let page = store.unresolved_health_findings_page(
11598 &[],
11599 &HealthQuery {
11600 start_index: 0,
11601 limit: 10,
11602 category: Some("duplicate-purpose".to_string()),
11603 severity: Some(Severity::Warning),
11604 path_prefix: Some(".".to_string()),
11605 summary_only: false,
11606 scope: HealthScope::all(),
11607 },
11608 )?;
11609
11610 require_eq(&page.total, &0, "folder duplicates scoped by parent")?;
11611 Ok(())
11612 }
11613
11614 #[test]
11615 fn contextual_folder_duplicate_identity_matches_unpaged_health_and_resolution()
11616 -> Result<(), Box<dyn Error>> {
11617 let mut store = AtlasStore::in_memory()?;
11618 store.replace_scan(&[
11619 test_folder_node("."),
11620 test_folder_node("customers"),
11621 test_folder_node("customers/service"),
11622 test_folder_node("settings"),
11623 test_folder_node("settings/service"),
11624 test_folder_node("settings/worker"),
11625 ])?;
11626 store.set_purpose("customers/service", "Service layer", PurposeSource::Agent)?;
11627 store.set_purpose("settings/service", "Service layer", PurposeSource::Agent)?;
11628 store.set_purpose("settings/worker", "Service layer", PurposeSource::Agent)?;
11629
11630 let page = store.unresolved_health_findings_page(
11631 &[],
11632 &HealthQuery {
11633 start_index: 0,
11634 limit: 10,
11635 category: Some("duplicate-purpose".to_string()),
11636 severity: Some(Severity::Warning),
11637 path_prefix: Some(".".to_string()),
11638 summary_only: false,
11639 scope: HealthScope::all(),
11640 },
11641 )?;
11642 require_eq(&page.total, &1, "contextual duplicate total")?;
11643 let paged_finding = page
11644 .findings
11645 .first()
11646 .ok_or_else(|| io::Error::other("paged duplicate missing"))?;
11647 require_eq(
11648 &paged_finding.path,
11649 &"settings/worker".to_string(),
11650 "paged duplicate path",
11651 )?;
11652 require_eq(
11653 &paged_finding.related_path,
11654 &Some("settings/service".to_string()),
11655 "paged related path",
11656 )?;
11657
11658 let unpaged_duplicates = store
11659 .unresolved_health_findings(&[])?
11660 .into_iter()
11661 .filter(|finding| finding.category == "duplicate-purpose")
11662 .collect::<Vec<_>>();
11663 require_eq(
11664 &unpaged_duplicates,
11665 &page.findings,
11666 "unpaged duplicate identity",
11667 )?;
11668
11669 store.resolve_health_finding(&HealthResolution {
11670 finding_id: paged_finding.id.clone(),
11671 category: paged_finding.category.clone(),
11672 path: paged_finding.path.clone(),
11673 related_path: paged_finding.related_path.clone(),
11674 rationale: "Settings service and worker intentionally share a layer purpose."
11675 .to_string(),
11676 })?;
11677 let has_remaining_duplicate = store
11678 .unresolved_health_findings(&store.resolved_health_ids()?)?
11679 .into_iter()
11680 .any(|finding| finding.category == "duplicate-purpose");
11681 require_eq(
11682 &has_remaining_duplicate,
11683 &false,
11684 "resolved contextual duplicate",
11685 )?;
11686 Ok(())
11687 }
11688
11689 #[test]
11690 fn agent_review_required_scope_expands_from_low_to_strict() -> Result<(), Box<dyn Error>> {
11691 let mut store = AtlasStore::in_memory()?;
11692 let asset_file = Node {
11693 path: "assets/logo.svg".to_string(),
11694 kind: NodeKind::File,
11695 parent_path: Some("assets".to_string()),
11696 extension: Some(".svg".to_string()),
11697 language: None,
11698 size_bytes: Some(42),
11699 mtime_ns: Some(10),
11700 content_hash: Some("hash-logo".to_string()),
11701 };
11702 store.replace_scan(&[
11703 test_folder_node("."),
11704 test_folder_node("assets"),
11705 test_folder_node("src"),
11706 test_file_node("Cargo.toml", "hash-cargo"),
11707 test_file_node("src/detail.rs", "hash-detail"),
11708 asset_file,
11709 ])?;
11710 for (path, purpose) in [
11711 (".", "Imported repository root"),
11712 ("assets", "Imported asset folder"),
11713 ("src", "Imported Rust source folder"),
11714 ("Cargo.toml", "Imported Rust manifest"),
11715 ("src/detail.rs", "Imported implementation detail"),
11716 ("assets/logo.svg", "Imported SVG brand asset"),
11717 ] {
11718 store.set_purpose(path, purpose, PurposeSource::Imported)?;
11719 }
11720
11721 let low = store.unresolved_health_findings_page(
11722 &[],
11723 &HealthQuery {
11724 start_index: 0,
11725 limit: 20,
11726 category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
11727 severity: Some(Severity::Warning),
11728 path_prefix: Some(".".to_string()),
11729 summary_only: false,
11730 scope: HealthScope::purpose_default(),
11731 },
11732 )?;
11733 require_eq(
11734 &health_paths(&low),
11735 &vec![".", "assets", "src", "Cargo.toml"],
11736 "low purpose review scope",
11737 )?;
11738 require_eq(
11739 &low.unfiltered_total,
11740 &6,
11741 "agent-review findings are counted once in unfiltered total",
11742 )?;
11743
11744 let asset_scope = store.unresolved_health_findings_page(
11745 &[],
11746 &HealthQuery {
11747 scope: HealthScope::purpose_with_assets(),
11748 ..low_query()
11749 },
11750 )?;
11751 require_eq(
11752 &health_paths(&asset_scope),
11753 &vec![".", "assets", "src", "Cargo.toml", "assets/logo.svg"],
11754 "asset purpose review scope",
11755 )?;
11756
11757 let medium = store.unresolved_health_findings_page(
11758 &[],
11759 &HealthQuery {
11760 scope: HealthScope::purpose_with_source_files(),
11761 ..low_query()
11762 },
11763 )?;
11764 require_eq(
11765 &health_paths(&medium),
11766 &vec![".", "assets", "src", "Cargo.toml", "src/detail.rs"],
11767 "medium purpose review scope",
11768 )?;
11769
11770 let strict = store.unresolved_health_findings_page(
11771 &[],
11772 &HealthQuery {
11773 scope: HealthScope::purpose_strict(),
11774 ..low_query()
11775 },
11776 )?;
11777 require_eq(
11778 &health_paths(&strict),
11779 &vec![
11780 ".",
11781 "assets",
11782 "src",
11783 "Cargo.toml",
11784 "assets/logo.svg",
11785 "src/detail.rs",
11786 ],
11787 "strict purpose review scope",
11788 )?;
11789
11790 let all = store.unresolved_health_findings_page(
11791 &[],
11792 &HealthQuery {
11793 scope: HealthScope::all(),
11794 ..low_query()
11795 },
11796 )?;
11797 require_eq(
11798 &health_paths(&all),
11799 &health_paths(&strict),
11800 "all health scope should include every purpose review candidate",
11801 )?;
11802 Ok(())
11803 }
11804
11805 #[test]
11806 fn replace_scan_preserves_curated_purposes_and_reconciles_changed_paths()
11807 -> Result<(), Box<dyn Error>> {
11808 let mut store = AtlasStore::in_memory()?;
11809 store.replace_scan(&[
11810 test_folder_node("."),
11811 test_folder_node("src"),
11812 test_file_node("src/main.rs", "hash-a"),
11813 ])?;
11814 store.set_purpose(".", "Agent-reviewed repository root", PurposeSource::Agent)?;
11815 store.set_purpose(
11816 "src",
11817 "Agent-reviewed Rust source folder",
11818 PurposeSource::Agent,
11819 )?;
11820 store.set_purpose(
11821 "src/main.rs",
11822 "Agent-reviewed Rust entry point",
11823 PurposeSource::Agent,
11824 )?;
11825
11826 store.replace_scan(&[
11827 test_folder_node("."),
11828 test_folder_node("src"),
11829 test_file_node("src/main.rs", "hash-a"),
11830 test_file_node("src/new.rs", "hash-new"),
11831 ])?;
11832 let nodes = store.load_nodes_by_paths(&[
11833 ".".to_string(),
11834 "src".to_string(),
11835 "src/main.rs".to_string(),
11836 "src/new.rs".to_string(),
11837 ])?;
11838 let by_path = nodes
11839 .iter()
11840 .map(|node| (node.node.path.as_str(), node))
11841 .collect::<HashMap<_, _>>();
11842 require_eq(
11843 &by_path["src/main.rs"].purpose.purpose,
11844 &Some("Agent-reviewed Rust entry point".to_string()),
11845 "unchanged file purpose preserved",
11846 )?;
11847 require_eq(
11848 &by_path["src/main.rs"].purpose.status,
11849 &PurposeStatus::Approved,
11850 "unchanged file purpose stays approved",
11851 )?;
11852 require_eq(
11853 &by_path["src/new.rs"].purpose.status,
11854 &PurposeStatus::Missing,
11855 "new file starts missing",
11856 )?;
11857
11858 store.replace_scan(&[
11859 test_folder_node("."),
11860 test_folder_node("src"),
11861 test_file_node("src/main.rs", "hash-b"),
11862 test_file_node("src/new.rs", "hash-new"),
11863 ])?;
11864 let changed = store
11865 .load_nodes_by_paths(&["src/main.rs".to_string()])?
11866 .pop()
11867 .ok_or_else(|| io::Error::other("changed node missing"))?;
11868 require_eq(
11869 &changed.purpose.purpose,
11870 &Some("Agent-reviewed Rust entry point".to_string()),
11871 "changed file purpose text preserved",
11872 )?;
11873 require_eq(
11874 &changed.purpose.status,
11875 &PurposeStatus::Approved,
11876 "changed file purpose stays approved",
11877 )?;
11878
11879 store.replace_scan(&[test_folder_node("."), test_folder_node("src")])?;
11880 let removed = store.load_nodes_by_paths(&["src/main.rs".to_string()])?;
11881 require_eq(&removed.is_empty(), &true, "removed file is inactive")?;
11882
11883 let dormant = store.connection.query_row(
11884 "SELECT n.exists_now, p.purpose, p.status
11885 FROM nodes AS n
11886 JOIN purposes AS p ON p.node_id = n.id
11887 WHERE n.path = 'src/main.rs'",
11888 [],
11889 |row| {
11890 Ok((
11891 row.get::<_, i64>(0)?,
11892 row.get::<_, Option<String>>(1)?,
11893 row.get::<_, String>(2)?,
11894 ))
11895 },
11896 )?;
11897 require_eq(
11898 &dormant,
11899 &(
11900 0,
11901 Some("Agent-reviewed Rust entry point".to_string()),
11902 PurposeStatus::Approved.as_str().to_string(),
11903 ),
11904 "removed file keeps a dormant approved purpose",
11905 )?;
11906
11907 store.replace_scan(&[
11908 test_folder_node("."),
11909 test_folder_node("src"),
11910 test_file_node("src/renamed.rs", "hash-b"),
11911 ])?;
11912 let renamed = store
11913 .load_node_by_path("src/renamed.rs")?
11914 .ok_or_else(|| io::Error::other("renamed path missing"))?;
11915 require_eq(
11916 &renamed.purpose.status,
11917 &PurposeStatus::Missing,
11918 "rename does not transfer approval",
11919 )?;
11920
11921 store.replace_scan(&[
11922 test_folder_node("."),
11923 test_folder_node("src"),
11924 test_file_node("src/main.rs", "hash-c"),
11925 ])?;
11926 let reactivated = store
11927 .load_node_by_path("src/main.rs")?
11928 .ok_or_else(|| io::Error::other("reactivated path missing"))?;
11929 require_eq(
11930 &reactivated.purpose.purpose,
11931 &Some("Agent-reviewed Rust entry point".to_string()),
11932 "exact-path reactivation restores the dormant purpose",
11933 )?;
11934 require_eq(
11935 &reactivated.purpose.status,
11936 &PurposeStatus::Approved,
11937 "exact-path reactivation restores approval",
11938 )?;
11939 Ok(())
11940 }
11941
11942 #[test]
11943 fn file_token_estimates_are_visited_without_loading_nodes() -> Result<(), Box<dyn Error>> {
11944 let mut store = AtlasStore::in_memory()?;
11945 store.replace_scan(&[
11946 test_file_node("src/a.rs", "hash-a"),
11947 test_file_node("tests/b.rs", "hash-b"),
11948 ])?;
11949
11950 let mut visited = Vec::new();
11951 store.visit_file_token_estimates(Some("src"), |path, size_bytes| {
11952 visited.push((path, size_bytes));
11953 Ok(true)
11954 })?;
11955 require_eq(
11956 &visited,
11957 &vec![("src/a.rs".to_string(), Some(12))],
11958 "folder-scoped token estimate rows",
11959 )?;
11960 Ok(())
11961 }
11962
11963 #[test]
11964 fn indexed_file_text_replaces_and_clears_stale_rows() -> Result<(), Box<dyn Error>> {
11965 let mut store = AtlasStore::in_memory()?;
11966 store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
11967 store.replace_file_texts_for_paths(
11968 &["src/main.rs".to_string()],
11969 &[IndexedFileText {
11970 path: "src/main.rs".to_string(),
11971 content_hash: Some("hash-a".to_string()),
11972 byte_count: "needle old\n".len(),
11973 line_count: 1,
11974 content: "needle old\n".to_string(),
11975 }],
11976 )?;
11977 let texts = store.load_file_texts_for_search(Some("needle"), true)?;
11978 require_eq(&texts.len(), &1, "indexed text row count")?;
11979
11980 store.replace_file_texts_for_paths(&["src/main.rs".to_string()], &[])?;
11981 let missing = store.load_file_text("src/main.rs")?;
11982 require_eq(&missing.is_none(), &true, "cleared stale indexed text")?;
11983 Ok(())
11984 }
11985
11986 #[test]
11987 fn indexed_file_text_search_can_stop_without_collecting_all_rows() -> Result<(), Box<dyn Error>>
11988 {
11989 let mut store = AtlasStore::in_memory()?;
11990 store.replace_scan(&[
11991 test_file_node("src/a.rs", "hash-a"),
11992 test_file_node("src/b.rs", "hash-b"),
11993 ])?;
11994 store.replace_file_texts_for_paths(
11995 &["src/a.rs".to_string(), "src/b.rs".to_string()],
11996 &[
11997 IndexedFileText {
11998 path: "src/a.rs".to_string(),
11999 content_hash: Some("hash-a".to_string()),
12000 byte_count: "needle first\n".len(),
12001 line_count: 1,
12002 content: "needle first\n".to_string(),
12003 },
12004 IndexedFileText {
12005 path: "src/b.rs".to_string(),
12006 content_hash: Some("hash-b".to_string()),
12007 byte_count: "needle second\n".len(),
12008 line_count: 1,
12009 content: "needle second\n".to_string(),
12010 },
12011 ],
12012 )?;
12013
12014 let mut visited = Vec::new();
12015 store.visit_file_texts_for_search(Some("needle"), true, |text| {
12016 visited.push(text.path);
12017 Ok(false)
12018 })?;
12019 require_eq(&visited, &vec!["src/a.rs".to_string()], "early stop rows")?;
12020 Ok(())
12021 }
12022
12023 #[test]
12024 fn file_text_fts_candidates_are_bounded_scoped_safe_and_metadata_only()
12025 -> Result<(), Box<dyn Error>> {
12026 let mut store = AtlasStore::in_memory()?;
12027 store.replace_scan(&[
12028 test_file_node("src/a.rs", "hash-a"),
12029 test_file_node("src/b.rs", "hash-b"),
12030 test_file_node("tests/c.rs", "hash-c"),
12031 ])?;
12032 store.replace_file_texts_for_paths(
12033 &[
12034 "src/a.rs".to_string(),
12035 "src/b.rs".to_string(),
12036 "tests/c.rs".to_string(),
12037 ],
12038 &[
12039 IndexedFileText {
12040 path: "src/a.rs".to_string(),
12041 content_hash: Some("hash-a".to_string()),
12042 byte_count: 13,
12043 line_count: 1,
12044 content: "needle alpha\n".to_string(),
12045 },
12046 IndexedFileText {
12047 path: "src/b.rs".to_string(),
12048 content_hash: Some("hash-b".to_string()),
12049 byte_count: 19,
12050 line_count: 1,
12051 content: "prefixneedlesuffix\n".to_string(),
12052 },
12053 IndexedFileText {
12054 path: "tests/c.rs".to_string(),
12055 content_hash: Some("hash-c".to_string()),
12056 byte_count: 32,
12057 line_count: 1,
12058 content: "needle gamma AND NOT NEAR terms\n".to_string(),
12059 },
12060 ],
12061 )?;
12062
12063 let bounded = store.query_file_text_fts_candidates(
12064 &FileTextFtsQuery {
12065 literal_token: "needle",
12066 path_prefix: None,
12067 limit: 1,
12068 },
12069 None,
12070 )?;
12071 require_eq(&bounded.candidates.len(), &1, "bounded FTS row count")?;
12072 require_eq(&bounded.overflow, &true, "bounded FTS overflow")?;
12073 require(
12074 bounded.candidates[0].bm25.is_finite(),
12075 "FTS rank was non-finite",
12076 )?;
12077 let zero_limit = store.query_file_text_fts_candidates(
12078 &FileTextFtsQuery {
12079 literal_token: "needle",
12080 path_prefix: None,
12081 limit: 0,
12082 },
12083 None,
12084 )?;
12085 require_eq(
12086 &zero_limit,
12087 &FileTextFtsPage {
12088 candidates: Vec::new(),
12089 overflow: true,
12090 },
12091 "zero-limit FTS overflow",
12092 )?;
12093
12094 let scoped = store.query_file_text_fts_candidates(
12095 &FileTextFtsQuery {
12096 literal_token: "needle",
12097 path_prefix: Some("src/"),
12098 limit: 10,
12099 },
12100 None,
12101 )?;
12102 require_eq(
12103 &scoped
12104 .candidates
12105 .iter()
12106 .map(|candidate| candidate.path.as_str())
12107 .collect::<Vec<_>>(),
12108 &vec!["src/a.rs", "src/b.rs"],
12109 "path-scoped FTS rank order",
12110 )?;
12111 let exact_path = store.query_file_text_fts_candidates(
12112 &FileTextFtsQuery {
12113 literal_token: "needle",
12114 path_prefix: Some("src/a.rs"),
12115 limit: 10,
12116 },
12117 None,
12118 )?;
12119 require_eq(
12120 &exact_path.candidates[0].path,
12121 &"src/a.rs".to_string(),
12122 "exact-path FTS scope",
12123 )?;
12124 for reserved_token in ["AND", "NOT", "NEAR"] {
12125 let reserved = store.query_file_text_fts_candidates(
12126 &FileTextFtsQuery {
12127 literal_token: reserved_token,
12128 path_prefix: Some("tests/c.rs"),
12129 limit: 10,
12130 },
12131 None,
12132 )?;
12133 require_eq(
12134 &reserved.candidates.len(),
12135 &1,
12136 "quoted FTS reserved-token candidate",
12137 )?;
12138 }
12139 let mut plan_statement = store.connection.prepare(
12140 "EXPLAIN QUERY PLAN
12141 SELECT f.path, f.content_hash, f.byte_count, f.line_count, bm25(file_text_fts)
12142 FROM file_text_fts
12143 JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
12144 WHERE file_text_fts MATCH ?1
12145 ORDER BY bm25(file_text_fts), f.path
12146 LIMIT ?2",
12147 )?;
12148 let plan_details = plan_statement
12149 .query_map(params!["needle", 11], |row| row.get::<_, String>(3))?
12150 .collect::<Result<Vec<_>, _>>()?;
12151 require(
12152 plan_details
12153 .iter()
12154 .any(|detail| detail.contains("VIRTUAL TABLE INDEX")),
12155 "FTS candidate query did not use the virtual-table index",
12156 )?;
12157 require(
12158 plan_details
12159 .iter()
12160 .any(|detail| detail.contains("INTEGER PRIMARY KEY")),
12161 "FTS candidate query did not hydrate metadata by file_texts rowid",
12162 )?;
12163
12164 for unsafe_token in ["ab", "foo_bar", "needle\" OR content:*", "néedle"] {
12165 let result = store.query_file_text_fts_candidates(
12166 &FileTextFtsQuery {
12167 literal_token: unsafe_token,
12168 path_prefix: None,
12169 limit: 10,
12170 },
12171 None,
12172 );
12173 require(
12174 matches!(result, Err(DbError::FileTextFtsTokenUnsafe { .. })),
12175 "unsafe FTS token was accepted",
12176 )?;
12177 }
12178 let over_cap = store.query_file_text_fts_candidates(
12179 &FileTextFtsQuery {
12180 literal_token: "needle",
12181 path_prefix: None,
12182 limit: MAX_FILE_TEXT_FTS_CANDIDATES + 1,
12183 },
12184 None,
12185 );
12186 require(
12187 matches!(over_cap, Err(DbError::FileTextFtsCandidateLimit { .. })),
12188 "over-cap FTS request was accepted",
12189 )?;
12190
12191 store.connection.execute(
12192 "UPDATE file_texts SET content = x'80' WHERE path = 'src/a.rs'",
12193 [],
12194 )?;
12195 let metadata_only = store.query_file_text_fts_candidates(
12196 &FileTextFtsQuery {
12197 literal_token: "needle",
12198 path_prefix: Some("src/a.rs"),
12199 limit: 1,
12200 },
12201 None,
12202 )?;
12203 require_eq(
12204 &metadata_only.candidates[0].byte_count,
12205 &13,
12206 "metadata-only candidate byte count",
12207 )?;
12208 require(
12209 store.load_file_text("src/a.rs").is_err(),
12210 "content corruption fixture did not prove candidate pre-hydration",
12211 )?;
12212 Ok(())
12213 }
12214
12215 #[test]
12216 fn file_text_fts_mutations_are_atomic_and_state_reports_identity_drift()
12217 -> Result<(), Box<dyn Error>> {
12218 let mut store = AtlasStore::in_memory()?;
12219 store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
12220 let invalid_metadata = IndexedFileText {
12221 path: "src/main.rs".to_string(),
12222 content_hash: Some("hash-invalid".to_string()),
12223 byte_count: 0,
12224 line_count: 1,
12225 content: "not empty\n".to_string(),
12226 };
12227 require(
12228 matches!(
12229 store.replace_file_texts_for_paths(
12230 std::slice::from_ref(&invalid_metadata.path),
12231 std::slice::from_ref(&invalid_metadata),
12232 ),
12233 Err(DbError::FileTextMetadataMismatch {
12234 field: "byte_count",
12235 ..
12236 })
12237 ),
12238 "invalid file-text byte metadata was accepted",
12239 )?;
12240 require_eq(
12241 &store.load_file_text(&invalid_metadata.path)?,
12242 &None,
12243 "invalid metadata write rollback",
12244 )?;
12245 let invalid_line_count = IndexedFileText {
12246 byte_count: invalid_metadata.content.len(),
12247 line_count: 0,
12248 ..invalid_metadata.clone()
12249 };
12250 require(
12251 matches!(
12252 store.replace_file_texts_for_paths(
12253 std::slice::from_ref(&invalid_line_count.path),
12254 std::slice::from_ref(&invalid_line_count),
12255 ),
12256 Err(DbError::FileTextMetadataMismatch {
12257 field: "line_count",
12258 ..
12259 })
12260 ),
12261 "invalid file-text line metadata was accepted",
12262 )?;
12263 let original = IndexedFileText {
12264 path: "src/main.rs".to_string(),
12265 content_hash: Some("hash-a".to_string()),
12266 byte_count: "needle old\n".len(),
12267 line_count: 1,
12268 content: "needle old\n".to_string(),
12269 };
12270 store.replace_file_texts_for_paths(
12271 std::slice::from_ref(&original.path),
12272 std::slice::from_ref(&original),
12273 )?;
12274 require_eq(
12275 &store.file_text_fts_state()?,
12276 &FileTextFtsState {
12277 source_rows: 1,
12278 indexed_rows: 1,
12279 synchronized: true,
12280 },
12281 "initial FTS synchronization state",
12282 )?;
12283
12284 store.connection.execute_batch(
12285 "CREATE TRIGGER fail_file_text_insert
12286 BEFORE INSERT ON file_texts
12287 BEGIN
12288 SELECT RAISE(ABORT, 'injected file-text failure');
12289 END;",
12290 )?;
12291 let replacement = IndexedFileText {
12292 path: original.path.clone(),
12293 content_hash: Some("hash-b".to_string()),
12294 byte_count: 11,
12295 line_count: 1,
12296 content: "beacon new\n".to_string(),
12297 };
12298 require(
12299 store
12300 .replace_file_texts_for_paths(
12301 std::slice::from_ref(&replacement.path),
12302 std::slice::from_ref(&replacement),
12303 )
12304 .is_err(),
12305 "fault-injected replacement unexpectedly committed",
12306 )?;
12307 store
12308 .connection
12309 .execute_batch("DROP TRIGGER fail_file_text_insert")?;
12310 require_eq(
12311 &store.load_file_text(&original.path)?,
12312 &Some(original.clone()),
12313 "rolled-back authoritative text",
12314 )?;
12315 require_eq(
12316 &store
12317 .query_file_text_fts_candidates(
12318 &FileTextFtsQuery {
12319 literal_token: "needle",
12320 path_prefix: None,
12321 limit: 10,
12322 },
12323 None,
12324 )?
12325 .candidates
12326 .len(),
12327 &1,
12328 "rolled-back FTS document",
12329 )?;
12330 require_eq(
12331 &store.file_text_fts_state()?.synchronized,
12332 &true,
12333 "rollback FTS synchronization",
12334 )?;
12335
12336 store.replace_file_texts_for_paths(
12337 std::slice::from_ref(&replacement.path),
12338 std::slice::from_ref(&replacement),
12339 )?;
12340 require_eq(
12341 &store
12342 .query_file_text_fts_candidates(
12343 &FileTextFtsQuery {
12344 literal_token: "needle",
12345 path_prefix: None,
12346 limit: 10,
12347 },
12348 None,
12349 )?
12350 .candidates
12351 .len(),
12352 &0,
12353 "replaced token removed from FTS",
12354 )?;
12355 require_eq(
12356 &store
12357 .query_file_text_fts_candidates(
12358 &FileTextFtsQuery {
12359 literal_token: "beacon",
12360 path_prefix: None,
12361 limit: 10,
12362 },
12363 None,
12364 )?
12365 .candidates
12366 .len(),
12367 &1,
12368 "replacement token added to FTS",
12369 )?;
12370
12371 store.connection.execute(
12372 "INSERT INTO file_text_fts(file_text_fts, rowid, content)
12373 SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
12374 [&replacement.path],
12375 )?;
12376 require_eq(
12377 &store.file_text_fts_state()?,
12378 &FileTextFtsState {
12379 source_rows: 1,
12380 indexed_rows: 0,
12381 synchronized: false,
12382 },
12383 "desynchronized FTS identity state",
12384 )?;
12385 store.connection.execute(
12386 "INSERT INTO file_text_fts(file_text_fts) VALUES('rebuild')",
12387 [],
12388 )?;
12389 let (source_revision, projection_revision) =
12390 load_file_text_fts_revisions(&store.connection)?.ok_or_else(|| {
12391 io::Error::other("FTS revisions disappeared after synchronized writes")
12392 })?;
12393 require_eq(
12394 &source_revision,
12395 &projection_revision,
12396 "synchronized FTS revisions",
12397 )?;
12398 set_metadata(
12399 &store.connection,
12400 FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
12401 &projection_revision.saturating_sub(1).to_string(),
12402 )?;
12403 require_eq(
12404 &store.file_text_fts_ready()?,
12405 &false,
12406 "incomplete FTS revision readiness",
12407 )?;
12408 set_metadata(
12409 &store.connection,
12410 FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
12411 &source_revision.to_string(),
12412 )?;
12413 require_eq(
12414 &store.file_text_fts_ready()?,
12415 &true,
12416 "restored FTS revision readiness",
12417 )?;
12418 store.mark_paths_absent(&["src".to_string()])?;
12419 require_eq(
12420 &store.file_text_fts_state()?,
12421 &FileTextFtsState {
12422 source_rows: 0,
12423 indexed_rows: 0,
12424 synchronized: true,
12425 },
12426 "absent-path FTS synchronization",
12427 )?;
12428
12429 store.replace_scan(&[test_file_node("src/main.rs", "hash-c")])?;
12430 let rescanned = IndexedFileText {
12431 path: "src/main.rs".to_string(),
12432 content_hash: Some("hash-c".to_string()),
12433 byte_count: 13,
12434 line_count: 1,
12435 content: "rescan token\n".to_string(),
12436 };
12437 store.replace_file_texts_for_paths(
12438 std::slice::from_ref(&rescanned.path),
12439 std::slice::from_ref(&rescanned),
12440 )?;
12441 store.replace_scan(&[])?;
12442 require_eq(
12443 &store.file_text_fts_state()?,
12444 &FileTextFtsState {
12445 source_rows: 0,
12446 indexed_rows: 0,
12447 synchronized: true,
12448 },
12449 "full-scan deletion FTS synchronization",
12450 )?;
12451 Ok(())
12452 }
12453
12454 #[cfg(windows)]
12455 #[test]
12456 fn file_text_fts_migration_backfills_existing_text_and_survives_reopen()
12457 -> Result<(), Box<dyn Error>> {
12458 let temp = tempfile::tempdir()?;
12459 let root = temp.path().join("project");
12460 fs::create_dir(&root)?;
12461 let database = temp.path().join("atlas.db");
12462 let mut store = AtlasStore::open_for_project(&database, &root)?;
12463 store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
12464 store.replace_file_texts_for_paths(
12465 &["src/main.rs".to_string()],
12466 &[IndexedFileText {
12467 path: "src/main.rs".to_string(),
12468 content_hash: Some("hash-a".to_string()),
12469 byte_count: 17,
12470 line_count: 1,
12471 content: "migration needle\n".to_string(),
12472 }],
12473 )?;
12474 store.connection.execute_batch(
12475 "DROP TABLE file_content_classifications;
12476 DROP INDEX idx_nodes_path_kind;
12477 DROP TABLE usage_instance_worktree_origins;
12478 DROP TABLE worktree_usage_aggregates;
12479 DROP TABLE worktree_registrations;
12480 DROP TABLE usage_aggregate_revisions;
12481 DROP TABLE project_root_identity;
12482 DROP TABLE IF EXISTS graph_identity_rejections;",
12483 )?;
12484 crate::schema::recreate_disposable_graph_projection(&store.connection, false)?;
12485 crate::schema::recreate_pre_selector_symbol_storage_for_test(&store.connection)?;
12486 store.connection.execute_batch(
12487 "DROP INDEX idx_symbol_import_alias_lookup;
12488 DROP TABLE file_text_fts;",
12489 )?;
12490 store.connection.execute(
12491 "DELETE FROM metadata WHERE key IN (?1, ?2)",
12492 params![
12493 FILE_TEXT_FTS_SOURCE_REVISION_KEY,
12494 FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
12495 ],
12496 )?;
12497 set_metadata(
12498 &store.connection,
12499 SCHEMA_VERSION_KEY,
12500 &crate::schema::COVERAGE_DISCOVERY_SCHEMA_VERSION.to_string(),
12501 )?;
12502 drop(store);
12503
12504 let migrated = AtlasStore::open_for_project(&database, &root)?;
12505 require_eq(
12506 &migrated.file_text_fts_state()?,
12507 &FileTextFtsState {
12508 source_rows: 1,
12509 indexed_rows: 1,
12510 synchronized: true,
12511 },
12512 "migrated FTS synchronization state",
12513 )?;
12514 require_eq(
12515 &migrated
12516 .query_file_text_fts_candidates(
12517 &FileTextFtsQuery {
12518 literal_token: "needle",
12519 path_prefix: None,
12520 limit: 10,
12521 },
12522 None,
12523 )?
12524 .candidates[0]
12525 .path,
12526 &"src/main.rs".to_string(),
12527 "migrated FTS backfill candidate",
12528 )?;
12529 drop(migrated);
12530
12531 let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
12532 require_eq(
12533 &reopened.file_text_fts_state()?.synchronized,
12534 &true,
12535 "reopened FTS synchronization",
12536 )?;
12537 require_eq(
12538 &reopened
12539 .query_file_text_fts_candidates(
12540 &FileTextFtsQuery {
12541 literal_token: "migration",
12542 path_prefix: Some("src"),
12543 limit: 1,
12544 },
12545 None,
12546 )?
12547 .candidates
12548 .len(),
12549 &1,
12550 "reopened FTS candidate count",
12551 )?;
12552 Ok(())
12553 }
12554
12555 #[test]
12556 fn sqlite_read_progress_interrupts_active_repository_traversal_and_clears_handler()
12557 -> Result<(), Box<dyn Error>> {
12558 let store = AtlasStore::in_memory()?;
12559 let control = IndexWorkControl::with_deadline(
12560 projectatlas_core::IndexCancellation::new(),
12561 Instant::now() + std::time::Duration::from_millis(50),
12562 );
12563 let interrupted = with_sqlite_read_progress(
12564 &store.connection,
12565 Some(&control),
12566 IndexWorkStage::RepositoryTraversal,
12567 || {
12568 store
12569 .connection
12570 .query_row(
12571 "WITH RECURSIVE numbers(value) AS (
12572 VALUES(1)
12573 UNION ALL
12574 SELECT value + 1 FROM numbers WHERE value < 100000000
12575 )
12576 SELECT SUM(value) FROM numbers",
12577 [],
12578 |row| row.get::<_, i64>(0),
12579 )
12580 .map_err(DbError::from)
12581 },
12582 );
12583 require(
12584 matches!(
12585 interrupted,
12586 Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
12587 stage: IndexWorkStage::RepositoryTraversal
12588 }))
12589 ),
12590 "active SQLite traversal was not interrupted with its typed deadline",
12591 )?;
12592 require_eq(
12593 &store
12594 .connection
12595 .query_row("SELECT 1", [], |row| row.get::<_, i64>(0))?,
12596 &1,
12597 "cleared repository traversal progress handler",
12598 )?;
12599 store
12600 .connection
12601 .execute("CREATE TEMP TABLE follow_up(value INTEGER NOT NULL)", [])?;
12602 store
12603 .connection
12604 .execute("INSERT INTO follow_up(value) VALUES(1)", [])?;
12605 Ok(())
12606 }
12607
12608 #[cfg(feature = "sqlite-progress-test-observer")]
12609 #[test]
12610 fn sqlite_read_progress_propagates_cancellation_during_an_active_batch()
12611 -> Result<(), Box<dyn Error>> {
12612 use crate::sqlite_progress_test_observer::{
12613 SqliteReadProgressEvent, observe_sqlite_read_progress,
12614 };
12615 use std::cell::Cell;
12616 use std::rc::Rc;
12617
12618 let store = AtlasStore::in_memory()?;
12619 let cancellation = projectatlas_core::IndexCancellation::new();
12620 let control = IndexWorkControl::new(cancellation.clone(), None);
12621 let callback_seen = Rc::new(Cell::new(false));
12622 let cancelled = observe_sqlite_read_progress(
12623 {
12624 let callback_seen = Rc::clone(&callback_seen);
12625 move |event| {
12626 if matches!(
12627 event,
12628 SqliteReadProgressEvent::CallbackEntered {
12629 stage: IndexWorkStage::RepositoryTraversal
12630 }
12631 ) {
12632 callback_seen.set(true);
12633 cancellation.cancel();
12634 }
12635 }
12636 },
12637 || {
12638 with_sqlite_read_progress(
12639 &store.connection,
12640 Some(&control),
12641 IndexWorkStage::RepositoryTraversal,
12642 || {
12643 store
12644 .connection
12645 .query_row(
12646 "WITH RECURSIVE numbers(value) AS (
12647 VALUES(1)
12648 UNION ALL
12649 SELECT value + 1 FROM numbers WHERE value < 100000000
12650 )
12651 SELECT SUM(value) FROM numbers",
12652 [],
12653 |row| row.get::<_, i64>(0),
12654 )
12655 .map_err(DbError::from)
12656 },
12657 )
12658 },
12659 );
12660 require(
12661 callback_seen.get()
12662 && matches!(
12663 cancelled,
12664 Err(DbError::IndexWork(IndexWorkFailure::Cancelled {
12665 stage: IndexWorkStage::RepositoryTraversal
12666 }))
12667 ),
12668 "active SQLite batch did not propagate typed cancellation",
12669 )?;
12670 store.connection.execute(
12671 "CREATE TEMP TABLE cancellation_follow_up(value INTEGER)",
12672 [],
12673 )?;
12674 Ok(())
12675 }
12676
12677 #[test]
12678 fn fallback_admission_precedes_content_decode_and_clears_progress_handlers()
12679 -> Result<(), Box<dyn Error>> {
12680 let mut store = AtlasStore::in_memory()?;
12681 store.replace_scan(&[test_file_node("src/good.rs", "hash-good")])?;
12682 store.replace_file_texts_for_paths(
12683 &["src/good.rs".to_string()],
12684 &[IndexedFileText {
12685 path: "src/good.rs".to_string(),
12686 content_hash: Some("hash-good".to_string()),
12687 byte_count: 12,
12688 line_count: 1,
12689 content: "needle good\n".to_string(),
12690 }],
12691 )?;
12692 store.connection.execute_batch(
12693 "INSERT INTO nodes(path, kind, exists_now) VALUES('src/bad.rs', 'file', 1);
12694 INSERT INTO file_content_classifications(path, classification)
12695 VALUES('src/bad.rs', 'source');
12696 INSERT INTO file_texts(path, content_hash, byte_count, line_count, content)
12697 VALUES('src/bad.rs', 'hash-bad', 1, 1, x'80');",
12698 )?;
12699
12700 let mut admitted = Vec::new();
12701 let mut visited = Vec::new();
12702 store.visit_file_texts_for_fallback(
12703 Some("src"),
12704 None,
12705 |metadata| {
12706 admitted.push((metadata.path.clone(), metadata.classification));
12707 Ok(if metadata.path == "src/bad.rs" {
12708 FileTextAdmission::Skip
12709 } else {
12710 FileTextAdmission::Read
12711 })
12712 },
12713 |text| {
12714 visited.push(text.path);
12715 Ok(true)
12716 },
12717 )?;
12718 require_eq(
12719 &admitted,
12720 &vec![
12721 ("src/bad.rs".to_string(), ContentClassification::Source),
12722 ("src/good.rs".to_string(), ContentClassification::Opaque),
12723 ],
12724 "fallback metadata admission rows",
12725 )?;
12726 require_eq(
12727 &visited,
12728 &vec!["src/good.rs".to_string()],
12729 "fallback decoded rows",
12730 )?;
12731 let mut scoped_plan = store.connection.prepare(&format!(
12732 "EXPLAIN QUERY PLAN {FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL}"
12733 ))?;
12734 let scoped_plan_details = scoped_plan
12735 .query_map(params!["src", "src/", "src0"], |row| {
12736 row.get::<_, String>(3)
12737 })?
12738 .collect::<Result<Vec<_>, _>>()?;
12739 require(
12740 scoped_plan_details
12741 .iter()
12742 .any(|detail| detail.contains("sqlite_autoindex_file_texts_1"))
12743 && scoped_plan_details.iter().any(|detail| {
12744 detail.contains("sqlite_autoindex_file_content_classifications_1")
12745 })
12746 && scoped_plan_details.iter().all(|detail| {
12747 !detail.contains("SCAN text") && !detail.contains("SCAN classification")
12748 }),
12749 "path-scoped fallback did not use bounded binary index ranges",
12750 )?;
12751 let mut scoped_opcodes = store
12752 .connection
12753 .prepare(&format!("EXPLAIN {FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL}"))?;
12754 let scoped_opcode_rows = scoped_opcodes
12755 .query_map(params!["src", "src/", "src0"], |row| {
12756 Ok((
12757 row.get::<_, String>(1)?,
12758 row.get::<_, i64>(2)?,
12759 row.get::<_, i64>(3)?,
12760 row.get::<_, Option<String>>(5)?,
12761 ))
12762 })?
12763 .collect::<Result<Vec<_>, _>>()?;
12764 let file_text_table_cursors = scoped_opcode_rows
12765 .iter()
12766 .filter(|(opcode, _, _, shape)| opcode == "DeferredSeek" && shape.is_some())
12767 .map(|(_, _, table_cursor, _)| *table_cursor)
12768 .collect::<Vec<_>>();
12769 require(
12770 !file_text_table_cursors.is_empty()
12771 && scoped_opcode_rows
12772 .iter()
12773 .all(|(opcode, cursor, column, _)| {
12774 opcode != "Column"
12775 || !file_text_table_cursors.contains(cursor)
12776 || *column != 4
12777 }),
12778 &format!(
12779 "fallback metadata cursor read source content before admission: {scoped_opcode_rows:?}"
12780 ),
12781 )?;
12782 require(
12783 store
12784 .visit_file_texts_for_fallback(
12785 Some("src/bad.rs"),
12786 None,
12787 |_| Ok(FileTextAdmission::Read),
12788 |_| Ok(true),
12789 )
12790 .is_err(),
12791 "invalid source content decoded without error",
12792 )?;
12793
12794 let success_cancellation = projectatlas_core::IndexCancellation::new();
12795 let success_control = IndexWorkControl::new(success_cancellation.clone(), None);
12796 store.query_file_text_fts_candidates(
12797 &FileTextFtsQuery {
12798 literal_token: "needle",
12799 path_prefix: None,
12800 limit: 10,
12801 },
12802 Some(&success_control),
12803 )?;
12804 success_cancellation.cancel();
12805 let recursive_sum = store.connection.query_row(
12806 "WITH RECURSIVE numbers(value) AS (
12807 VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 5000
12808 ) SELECT SUM(value) FROM numbers",
12809 [],
12810 |row| row.get::<_, i64>(0),
12811 )?;
12812 require_eq(
12813 &recursive_sum,
12814 &12_502_500,
12815 "cleared success progress handler",
12816 )?;
12817
12818 let cancellation = projectatlas_core::IndexCancellation::new();
12819 let control = IndexWorkControl::new(cancellation.clone(), None);
12820 let mut rows_seen = 0;
12821 let cancelled = store.visit_file_texts_for_fallback(
12822 Some("src"),
12823 Some(&control),
12824 |_| {
12825 rows_seen += 1;
12826 cancellation.cancel();
12827 Ok(FileTextAdmission::Skip)
12828 },
12829 |_| Ok(true),
12830 );
12831 require_eq(&rows_seen, &1, "rows before fallback cancellation")?;
12832 require(
12833 matches!(
12834 cancelled,
12835 Err(DbError::IndexWork(IndexWorkFailure::Cancelled {
12836 stage: IndexWorkStage::TextIndex
12837 }))
12838 ),
12839 "fallback cancellation was not typed",
12840 )?;
12841 let post_error_sum = store.connection.query_row(
12842 "WITH RECURSIVE numbers(value) AS (
12843 VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 5000
12844 ) SELECT SUM(value) FROM numbers",
12845 [],
12846 |row| row.get::<_, i64>(0),
12847 )?;
12848 require_eq(
12849 &post_error_sum,
12850 &12_502_500,
12851 "cleared error progress handler",
12852 )?;
12853
12854 let expired = IndexWorkControl::with_deadline(
12855 projectatlas_core::IndexCancellation::new(),
12856 Instant::now(),
12857 );
12858 let deadline = store.query_file_text_fts_candidates(
12859 &FileTextFtsQuery {
12860 literal_token: "needle",
12861 path_prefix: None,
12862 limit: 10,
12863 },
12864 Some(&expired),
12865 );
12866 require(
12867 matches!(
12868 deadline,
12869 Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
12870 stage: IndexWorkStage::TextIndex
12871 }))
12872 ),
12873 "FTS deadline was not typed",
12874 )?;
12875 store.connection.execute(
12876 "DELETE FROM file_content_classifications WHERE path = 'src/good.rs'",
12877 [],
12878 )?;
12879 let missing_classification = store.visit_file_texts_for_fallback(
12880 Some("src/good.rs"),
12881 None,
12882 |_| Ok(FileTextAdmission::Read),
12883 |_| Ok(true),
12884 );
12885 require(
12886 matches!(
12887 missing_classification,
12888 Err(DbError::FileContentClassificationMissing { .. })
12889 ),
12890 "fallback omitted a text row whose predecode classification was missing",
12891 )?;
12892 Ok(())
12893 }
12894
12895 #[test]
12896 fn suggested_purpose_is_not_approved() -> Result<(), Box<dyn Error>> {
12897 let mut store = AtlasStore::in_memory()?;
12898 let node = Node {
12899 path: "src/main.rs".to_string(),
12900 kind: NodeKind::File,
12901 parent_path: normalized_parent("src/main.rs"),
12902 extension: Some(".rs".to_string()),
12903 language: Some("rust".to_string()),
12904 size_bytes: Some(12),
12905 mtime_ns: Some(10),
12906 content_hash: Some("abc".to_string()),
12907 };
12908 store.replace_scan(&[node])?;
12909 store.set_suggested_purpose("src/main.rs", "Maybe application entry point")?;
12910 let nodes = store.load_nodes()?;
12911 require_eq(
12912 &nodes[0].purpose.source,
12913 &PurposeSource::Generated,
12914 "suggested source",
12915 )?;
12916 require_eq(
12917 &nodes[0].purpose.status,
12918 &PurposeStatus::Suggested,
12919 "suggested status",
12920 )?;
12921 store.set_purpose(
12922 "src/main.rs",
12923 "Application entry point",
12924 PurposeSource::Agent,
12925 )?;
12926 let nodes = store.load_nodes()?;
12927 require_eq(
12928 &nodes[0].purpose.status,
12929 &PurposeStatus::Approved,
12930 "agent-approved status",
12931 )?;
12932 store.set_suggested_purpose("src/main.rs", "Late generated suggestion")?;
12933 let nodes = store.load_nodes()?;
12934 require_eq(
12935 &nodes[0].purpose.purpose,
12936 &Some("Application entry point".to_string()),
12937 "approved purpose survives a late suggestion",
12938 )?;
12939 require_eq(
12940 &nodes[0].purpose.source,
12941 &PurposeSource::Agent,
12942 "approved purpose source survives a late suggestion",
12943 )?;
12944 require_eq(
12945 &nodes[0].purpose.status,
12946 &PurposeStatus::Approved,
12947 "approved purpose status survives a late suggestion",
12948 )?;
12949
12950 store.connection.execute(
12951 "
12952 UPDATE purposes
12953 SET status = ?1
12954 WHERE node_id = (SELECT id FROM nodes WHERE path = ?2)
12955 ",
12956 params![PurposeStatus::Stale.as_str(), "src/main.rs"],
12957 )?;
12958 store.set_suggested_purpose("src/main.rs", "Later generated suggestion")?;
12959 let nodes = store.load_nodes()?;
12960 require_eq(
12961 &nodes[0].purpose.purpose,
12962 &Some("Application entry point".to_string()),
12963 "stale reviewed purpose survives a late suggestion",
12964 )?;
12965 require_eq(
12966 &nodes[0].purpose.source,
12967 &PurposeSource::Agent,
12968 "stale reviewed source survives a late suggestion",
12969 )?;
12970 require_eq(
12971 &nodes[0].purpose.status,
12972 &PurposeStatus::Stale,
12973 "stale reviewed status survives a late suggestion",
12974 )?;
12975 Ok(())
12976 }
12977
12978 #[test]
12979 fn agent_reviewed_marker_depends_on_agent_approved_source() -> Result<(), Box<dyn Error>> {
12980 let mut store = AtlasStore::in_memory()?;
12981 store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
12982
12983 store.set_suggested_purpose("src/main.rs", "Maybe application entry point")?;
12984 let nodes = store.load_nodes()?;
12985 require_eq(
12986 &nodes[0].purpose.agent_reviewed(),
12987 &false,
12988 "generated suggestion is not agent reviewed",
12989 )?;
12990
12991 store.set_purpose(
12992 "src/main.rs",
12993 "Imported application entry point",
12994 PurposeSource::Imported,
12995 )?;
12996 let nodes = store.load_nodes()?;
12997 require_eq(
12998 &nodes[0].purpose.agent_reviewed(),
12999 &false,
13000 "imported purpose is not agent reviewed",
13001 )?;
13002
13003 store.set_purpose(
13004 "src/main.rs",
13005 "Agent-reviewed application entry point",
13006 PurposeSource::Agent,
13007 )?;
13008 let nodes = store.load_nodes()?;
13009 require_eq(
13010 &nodes[0].purpose.agent_reviewed(),
13011 &true,
13012 "agent-approved purpose is agent reviewed",
13013 )?;
13014
13015 store.connection.execute(
13016 "
13017 UPDATE purposes
13018 SET source = 'human'
13019 WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/main.rs')
13020 ",
13021 [],
13022 )?;
13023 let nodes = store.load_nodes()?;
13024 require_eq(
13025 &nodes[0].purpose.source,
13026 &PurposeSource::Agent,
13027 "legacy human source normalizes to agent",
13028 )?;
13029 require_eq(
13030 &nodes[0].purpose.agent_reviewed(),
13031 &true,
13032 "legacy approved human row remains reviewed",
13033 )?;
13034
13035 store.replace_scan(&[test_file_node("src/main.rs", "hash-b")])?;
13036 let nodes = store.load_nodes()?;
13037 require_eq(
13038 &nodes[0].purpose.status,
13039 &PurposeStatus::Approved,
13040 "changed reviewed purpose stays approved",
13041 )?;
13042 require_eq(
13043 &nodes[0].purpose.agent_reviewed(),
13044 &true,
13045 "changed approved purpose remains agent reviewed",
13046 )?;
13047 Ok(())
13048 }
13049
13050 #[test]
13051 fn purpose_curation_batch_coalesces_current_unapproved_rows() -> Result<(), Box<dyn Error>> {
13052 let temp = tempfile::tempdir()?;
13053 let root = temp.path().join("project");
13054 fs::create_dir(&root)?;
13055 let database = temp.path().join("projectatlas.db");
13056 let mut store = AtlasStore::open_for_project(&database, &root)?;
13057 store.replace_scan(&[
13058 test_file_node("src/a.rs", "hash-a"),
13059 test_file_node("src/b.rs", "hash-b"),
13060 test_file_node("src/accepted.rs", "hash-accepted"),
13061 ])?;
13062 store.set_suggested_purpose("src/b.rs", "Generated B suggestion")?;
13063 store.set_purpose("src/accepted.rs", "Accepted purpose", PurposeSource::Agent)?;
13064
13065 let selected = vec![
13066 "src/b.rs".to_string(),
13067 "src/a.rs".to_string(),
13068 "src/a.rs".to_string(),
13069 "src/accepted.rs".to_string(),
13070 ];
13071 let first = store.load_purpose_curation_batch(" issue 308 ", &selected)?;
13072 let second = store.load_purpose_curation_batch(
13073 "issue 308",
13074 &selected.iter().rev().cloned().collect::<Vec<_>>(),
13075 )?;
13076 require_eq(&first.task, &"issue 308".to_string(), "normalized task")?;
13077 require_eq(&first.work_key, &second.work_key, "deterministic batch key")?;
13078 require_eq(&first.items, &second.items, "deterministic candidate rows")?;
13079 require_eq(&first.items.len(), &2, "coalesced unapproved row count")?;
13080 require_eq(
13081 &first
13082 .items
13083 .iter()
13084 .map(|item| item.node.node.path.as_str())
13085 .collect::<Vec<_>>(),
13086 &vec!["src/a.rs", "src/b.rs"],
13087 "sorted actionable paths",
13088 )?;
13089 require_eq(
13090 &first
13091 .items
13092 .iter()
13093 .all(|item| item.work_key.len() == 64 && item.state_token.len() == 64),
13094 &true,
13095 "bounded opaque item identities",
13096 )?;
13097
13098 let page = store.purpose_curation_findings_page_current(&HealthQuery {
13099 start_index: 0,
13100 limit: 20,
13101 category: None,
13102 severity: Some(Severity::Warning),
13103 path_prefix: None,
13104 summary_only: false,
13105 scope: HealthScope::all(),
13106 })?;
13107 require_eq(&page.total, &2, "actionable queue count")?;
13108 require_eq(
13109 &page
13110 .findings
13111 .iter()
13112 .any(|finding| finding.path == "src/accepted.rs"),
13113 &false,
13114 "accepted purpose omitted from automatic curation",
13115 )?;
13116
13117 store.set_purpose(
13118 "src/accepted.rs",
13119 "Deliberately corrected purpose",
13120 PurposeSource::Agent,
13121 )?;
13122 let corrected = store
13123 .load_node_by_path("src/accepted.rs")?
13124 .ok_or_else(|| io::Error::other("corrected purpose path disappeared"))?;
13125 require_eq(
13126 &corrected.purpose.purpose,
13127 &Some("Deliberately corrected purpose".to_string()),
13128 "explicit accepted-purpose correction",
13129 )?;
13130 Ok(())
13131 }
13132
13133 #[test]
13134 fn explicit_purpose_rollback_reports_storage_failure() -> Result<(), Box<dyn Error>> {
13135 let temp = tempfile::tempdir()?;
13136 let root = temp.path().join("project");
13137 fs::create_dir(&root)?;
13138 let database = temp.path().join("projectatlas.db");
13139 let mut store = AtlasStore::open_for_project(&database, &root)?;
13140 store.replace_scan(&[test_file_node("source.rs", "hash")])?;
13141 let before_revision = store.authored_purpose_revision()?;
13142 let transaction = store.begin_purpose_mutation()?;
13143 store.set_purpose("source.rs", "Rejected purpose", PurposeSource::Agent)?;
13144 store.connection.execute_batch("ROLLBACK")?;
13145
13146 require(
13147 matches!(transaction.rollback(), Err(DbError::Sqlite(_))),
13148 "explicit purpose rollback suppressed its SQLite failure",
13149 )?;
13150 require_eq(
13151 &store.authored_purpose_revision()?,
13152 &before_revision,
13153 "manually rolled-back purpose revision",
13154 )?;
13155 require(
13156 store
13157 .load_node_by_path("source.rs")?
13158 .is_none_or(|node| node.purpose.purpose.as_deref() != Some("Rejected purpose")),
13159 "manual rollback retained the rejected purpose",
13160 )
13161 }
13162
13163 #[test]
13164 fn conditional_purpose_batch_is_atomic_and_rejects_changed_work() -> Result<(), Box<dyn Error>>
13165 {
13166 let temp = tempfile::tempdir()?;
13167 let root = temp.path().join("project");
13168 fs::create_dir(&root)?;
13169 let database = temp.path().join("projectatlas.db");
13170 let mut store = AtlasStore::open_for_project(&database, &root)?;
13171 let nodes = vec![
13172 test_file_node("src/a.rs", "hash-a"),
13173 test_file_node("src/b.rs", "hash-b"),
13174 test_file_node("src/changed.rs", "hash-changed"),
13175 test_file_node("src/accepted.rs", "hash-accepted"),
13176 test_file_node("src/generation.rs", "hash-generation"),
13177 test_file_node("src/rollback-a.rs", "hash-rollback-a"),
13178 test_file_node("src/rollback-b.rs", "hash-rollback-b"),
13179 ];
13180 store.replace_scan(&nodes)?;
13181 store.set_suggested_purpose("src/b.rs", "Generated B suggestion")?;
13182 store.set_suggested_purpose("src/changed.rs", "Original suggestion")?;
13183 require_eq(
13184 &store.authored_purpose_revision()?,
13185 &0,
13186 "suggestions do not advance authored-purpose revision",
13187 )?;
13188 let task = "issue-308-purpose-curation";
13189
13190 let apply_batch = store
13191 .load_purpose_curation_batch(task, &["src/a.rs".to_string(), "src/b.rs".to_string()])?;
13192 let requests = apply_batch
13193 .items
13194 .iter()
13195 .map(|candidate| {
13196 conditional_purpose_request(
13197 task,
13198 candidate,
13199 &format!("Reviewed {}", candidate.node.node.path),
13200 )
13201 })
13202 .collect::<Vec<_>>();
13203 let applied = store.conditionally_set_purposes(&requests)?;
13204 require_eq(
13205 &applied
13206 .iter()
13207 .map(|result| result.state)
13208 .collect::<Vec<_>>(),
13209 &vec![
13210 PurposeConditionalApplyState::Applied,
13211 PurposeConditionalApplyState::Applied,
13212 ],
13213 "one-transaction batch outcomes",
13214 )?;
13215 require_eq(
13216 &applied.iter().all(|result| {
13217 result.current_purpose.as_ref().is_some_and(|purpose| {
13218 purpose.status == PurposeStatus::Approved
13219 && purpose.source == PurposeSource::Agent
13220 })
13221 }),
13222 &true,
13223 "applied transaction-owned purpose metadata",
13224 )?;
13225 require_eq(
13226 &store.authored_purpose_revision()?,
13227 &1,
13228 "one accepted batch advances one authored-purpose revision",
13229 )?;
13230
13231 let changed = store
13232 .load_purpose_curation_batch(task, &["src/changed.rs".to_string()])?
13233 .items
13234 .into_iter()
13235 .next()
13236 .ok_or_else(|| io::Error::other("changed candidate missing"))?;
13237 store.set_suggested_purpose("src/changed.rs", "Concurrent suggestion")?;
13238 let changed_result = store
13239 .conditionally_set_purposes(&[conditional_purpose_request(
13240 task,
13241 &changed,
13242 "Stale curator answer",
13243 )])?
13244 .pop()
13245 .ok_or_else(|| io::Error::other("changed conditional result missing"))?;
13246 require_eq(
13247 &changed_result.state,
13248 &PurposeConditionalApplyState::Stale,
13249 "changed suggestion state",
13250 )?;
13251 require_eq(
13252 &changed_result
13253 .current_purpose
13254 .as_ref()
13255 .map(|purpose| (purpose.status, purpose.source, purpose.purpose.as_deref())),
13256 &Some((
13257 PurposeStatus::Suggested,
13258 PurposeSource::Generated,
13259 Some("Concurrent suggestion"),
13260 )),
13261 "stale transaction-owned purpose metadata",
13262 )?;
13263 require_eq(
13264 &store.authored_purpose_revision()?,
13265 &1,
13266 "stale conditional purpose leaves revision unchanged",
13267 )?;
13268
13269 let accepted = store
13270 .load_purpose_curation_batch(task, &["src/accepted.rs".to_string()])?
13271 .items
13272 .into_iter()
13273 .next()
13274 .ok_or_else(|| io::Error::other("accepted candidate missing"))?;
13275 store.set_purpose(
13276 "src/accepted.rs",
13277 "Concurrent accepted purpose",
13278 PurposeSource::Agent,
13279 )?;
13280 require_eq(
13281 &store.authored_purpose_revision()?,
13282 &2,
13283 "explicit accepted correction advances the authored-purpose revision",
13284 )?;
13285 store.set_purpose(
13286 "src/accepted.rs",
13287 "Concurrent accepted purpose",
13288 PurposeSource::Agent,
13289 )?;
13290 require_eq(
13291 &store.authored_purpose_revision()?,
13292 &2,
13293 "identical accepted purpose leaves the authored-purpose revision unchanged",
13294 )?;
13295 let accepted_result = store
13296 .conditionally_set_purposes(&[conditional_purpose_request(
13297 task,
13298 &accepted,
13299 "Stale curator overwrite",
13300 )])?
13301 .pop()
13302 .ok_or_else(|| io::Error::other("accepted conditional result missing"))?;
13303 require_eq(
13304 &accepted_result.state,
13305 &PurposeConditionalApplyState::Accepted,
13306 "accepted concurrent state",
13307 )?;
13308 require_eq(
13309 &accepted_result
13310 .current_purpose
13311 .as_ref()
13312 .map(|purpose| (purpose.status, purpose.source, purpose.purpose.as_deref())),
13313 &Some((
13314 PurposeStatus::Approved,
13315 PurposeSource::Agent,
13316 Some("Concurrent accepted purpose"),
13317 )),
13318 "accepted transaction-owned purpose metadata",
13319 )?;
13320 require_eq(
13321 &store.authored_purpose_revision()?,
13322 &2,
13323 "accepted conditional no-op leaves revision unchanged",
13324 )?;
13325
13326 let unavailable_result = store
13327 .conditionally_set_purposes(&[PurposeConditionalApplyRequest {
13328 task: task.to_string(),
13329 path: "src/unavailable.rs".to_string(),
13330 work_key: "unavailable-work".to_string(),
13331 state_token: "unavailable-state".to_string(),
13332 purpose: "Unavailable purpose".to_string(),
13333 }])?
13334 .pop()
13335 .ok_or_else(|| io::Error::other("unavailable conditional result missing"))?;
13336 require_eq(
13337 &unavailable_result.state,
13338 &PurposeConditionalApplyState::PathUnavailable,
13339 "unavailable path state",
13340 )?;
13341 require_eq(
13342 &unavailable_result.current_purpose,
13343 &None,
13344 "unavailable transaction-owned purpose metadata",
13345 )?;
13346
13347 let generation = store
13348 .load_purpose_curation_batch(task, &["src/generation.rs".to_string()])?
13349 .items
13350 .into_iter()
13351 .next()
13352 .ok_or_else(|| io::Error::other("generation candidate missing"))?;
13353 let project = store
13354 .project_instance_id()?
13355 .ok_or_else(|| io::Error::other("project identity missing"))?;
13356 {
13357 let mut publication = store.begin_index_publication("purpose-curation-test")?;
13358 publication.begin_scan_replacement()?;
13359 publication.upsert_scan_node_batch(&nodes)?;
13360 publication.finish_scan_replacement()?;
13361 publication.replace_repository_graph(project, &[], &[], &[], &[])?;
13362 publication.complete()?;
13363 }
13364 let generation_state = store.conditionally_set_purpose(
13365 task,
13366 "src/generation.rs",
13367 &generation.work_key,
13368 &generation.state_token,
13369 "Prior-generation answer",
13370 )?;
13371 require_eq(
13372 &generation_state,
13373 &PurposeConditionalApplyState::Stale,
13374 "generation-bound state",
13375 )?;
13376
13377 let current = store.load_nodes_by_paths(&[
13378 "src/changed.rs".to_string(),
13379 "src/accepted.rs".to_string(),
13380 "src/generation.rs".to_string(),
13381 ])?;
13382 let purposes = current
13383 .iter()
13384 .map(|node| {
13385 (
13386 node.node.path.as_str(),
13387 node.purpose.purpose.as_deref(),
13388 node.purpose.status,
13389 )
13390 })
13391 .collect::<Vec<_>>();
13392 require_eq(
13393 &purposes,
13394 &vec![
13395 (
13396 "src/accepted.rs",
13397 Some("Concurrent accepted purpose"),
13398 PurposeStatus::Approved,
13399 ),
13400 (
13401 "src/changed.rs",
13402 Some("Concurrent suggestion"),
13403 PurposeStatus::Suggested,
13404 ),
13405 ("src/generation.rs", None, PurposeStatus::Missing),
13406 ],
13407 "conflicting rows remain untouched",
13408 )?;
13409
13410 let rollback_batch = store.load_purpose_curation_batch(
13411 task,
13412 &[
13413 "src/rollback-a.rs".to_string(),
13414 "src/rollback-b.rs".to_string(),
13415 ],
13416 )?;
13417 let rollback_requests = rollback_batch
13418 .items
13419 .iter()
13420 .map(|candidate| {
13421 conditional_purpose_request(
13422 task,
13423 candidate,
13424 &format!("Reviewed {}", candidate.node.node.path),
13425 )
13426 })
13427 .collect::<Vec<_>>();
13428 store.connection.execute_batch(
13429 "
13430 CREATE TEMP TRIGGER abort_second_conditional_purpose_update
13431 BEFORE UPDATE ON purposes
13432 WHEN OLD.node_id = (
13433 SELECT id FROM nodes WHERE path = 'src/rollback-b.rs'
13434 )
13435 BEGIN
13436 SELECT RAISE(ABORT, 'forced conditional purpose rollback');
13437 END;
13438 ",
13439 )?;
13440 if store.conditionally_set_purposes(&rollback_requests).is_ok() {
13441 return Err(io::Error::other("injected conditional batch failure succeeded").into());
13442 }
13443 drop(store);
13444 let reopened = AtlasStore::open_for_project(&database, &root)?;
13445 let rolled_back = reopened.load_nodes_by_paths(&[
13446 "src/rollback-a.rs".to_string(),
13447 "src/rollback-b.rs".to_string(),
13448 ])?;
13449 require_eq(
13450 &rolled_back
13451 .iter()
13452 .map(|node| node.purpose.status)
13453 .collect::<Vec<_>>(),
13454 &vec![PurposeStatus::Missing, PurposeStatus::Missing],
13455 "failed conditional batch rolled back after reopen",
13456 )?;
13457 Ok(())
13458 }
13459
13460 #[test]
13461 fn concurrent_purpose_curators_cannot_overwrite_the_winner() -> Result<(), Box<dyn Error>> {
13462 let temp = tempfile::tempdir()?;
13463 let root = temp.path().join("project");
13464 fs::create_dir(&root)?;
13465 let database = temp.path().join("projectatlas.db");
13466 let mut store = AtlasStore::open_for_project(&database, &root)?;
13467 store.replace_scan(&[test_file_node("src/main.rs", "hash-main")])?;
13468 let candidate = store
13469 .load_purpose_curation_batch("concurrent-curators", &["src/main.rs".to_string()])?
13470 .items
13471 .into_iter()
13472 .next()
13473 .ok_or_else(|| io::Error::other("concurrent candidate missing"))?;
13474 drop(store);
13475
13476 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
13477 let mut handles = Vec::new();
13478 for purpose in ["First reviewed purpose", "Second reviewed purpose"] {
13479 let database = database.clone();
13480 let root = root.clone();
13481 let barrier = std::sync::Arc::clone(&barrier);
13482 let candidate = candidate.clone();
13483 handles.push(std::thread::spawn(move || {
13484 let store = AtlasStore::open_for_project(&database, &root)?;
13485 barrier.wait();
13486 store.conditionally_set_purpose(
13487 "concurrent-curators",
13488 "src/main.rs",
13489 &candidate.work_key,
13490 &candidate.state_token,
13491 purpose,
13492 )
13493 }));
13494 }
13495 let outcomes = handles
13496 .into_iter()
13497 .map(|handle| {
13498 handle
13499 .join()
13500 .map_err(|_panic| io::Error::other("curator thread panicked"))?
13501 .map_err(io::Error::other)
13502 })
13503 .collect::<Result<Vec<_>, io::Error>>()?;
13504 require_eq(
13505 &outcomes
13506 .iter()
13507 .filter(|state| **state == PurposeConditionalApplyState::Applied)
13508 .count(),
13509 &1,
13510 "single concurrent winner",
13511 )?;
13512 require_eq(
13513 &outcomes
13514 .iter()
13515 .filter(|state| **state == PurposeConditionalApplyState::Accepted)
13516 .count(),
13517 &1,
13518 "accepted concurrent loser",
13519 )?;
13520
13521 let reopened = AtlasStore::open_for_project(&database, &root)?;
13522 let node = reopened
13523 .load_node_by_path("src/main.rs")?
13524 .ok_or_else(|| io::Error::other("concurrent path disappeared"))?;
13525 require_eq(
13526 &node.purpose.status,
13527 &PurposeStatus::Approved,
13528 "persisted concurrent winner status",
13529 )?;
13530 require_eq(
13531 &matches!(
13532 node.purpose.purpose.as_deref(),
13533 Some("First reviewed purpose" | "Second reviewed purpose")
13534 ),
13535 &true,
13536 "persisted concurrent winner value",
13537 )?;
13538 Ok(())
13539 }
13540
13541 #[test]
13542 fn purpose_curation_hydration_uses_existing_unique_indexes() -> Result<(), Box<dyn Error>> {
13543 let mut store = AtlasStore::in_memory()?;
13544 store.replace_scan(&[
13545 test_file_node("src/a.rs", "hash-a"),
13546 test_file_node("src/b.rs", "hash-b"),
13547 ])?;
13548 let sql = format!("EXPLAIN QUERY PLAN {}", load_nodes_by_paths_sql(2));
13549 let mut statement = store.connection.prepare(&sql)?;
13550 let details = statement
13551 .query_map(params!["src/a.rs", "src/b.rs"], |row| {
13552 row.get::<_, String>(3)
13553 })?
13554 .collect::<Result<Vec<_>, _>>()?;
13555 let plan = details.join("\n");
13556 require_eq(
13557 &plan.contains("SEARCH n USING INDEX sqlite_autoindex_nodes_"),
13558 &true,
13559 "unique path index query plan",
13560 )?;
13561 require_eq(
13562 &plan.contains("SEARCH p USING INTEGER PRIMARY KEY"),
13563 &true,
13564 "purpose primary-key query plan",
13565 )?;
13566 require_eq(&plan.contains("SCAN n"), &false, "no node-table scan")?;
13567 Ok(())
13568 }
13569
13570 #[test]
13571 fn updates_content_summary_without_approving_purpose() -> Result<(), Box<dyn Error>> {
13572 let mut store = AtlasStore::in_memory()?;
13573 let node = Node {
13574 path: "src/lib.rs".to_string(),
13575 kind: NodeKind::File,
13576 parent_path: normalized_parent("src/lib.rs"),
13577 extension: Some(".rs".to_string()),
13578 language: Some("rust".to_string()),
13579 size_bytes: Some(24),
13580 mtime_ns: Some(10),
13581 content_hash: Some("def".to_string()),
13582 };
13583 store.replace_scan(&[node])?;
13584 store.set_node_summary(
13585 "src/lib.rs",
13586 "rust source defining library entry functions.",
13587 )?;
13588 let nodes = store.load_nodes()?;
13589 require_eq(
13590 &nodes[0].summary,
13591 &Some("rust source defining library entry functions.".to_string()),
13592 "updated content summary",
13593 )?;
13594 require_eq(
13595 &nodes[0].purpose.status,
13596 &PurposeStatus::Missing,
13597 "summary update does not approve purpose",
13598 )?;
13599 require_eq(
13600 &store.has_agent_approved_purpose()?,
13601 &false,
13602 "missing purpose does not activate graph hydration",
13603 )?;
13604 store.set_purpose(
13605 "src/lib.rs",
13606 "Owns the library entry points.",
13607 PurposeSource::Agent,
13608 )?;
13609 require_eq(
13610 &store.has_agent_approved_purpose()?,
13611 &true,
13612 "agent-approved purpose activates graph hydration",
13613 )?;
13614 Ok(())
13615 }
13616
13617 #[test]
13618 fn replaces_symbol_graph_idempotently() -> Result<(), Box<dyn Error>> {
13619 let mut store = AtlasStore::in_memory()?;
13620 let graph = SymbolGraph {
13621 path: "src/main.rs".to_string(),
13622 language: Some("rust".to_string()),
13623 parser: ParserKind::TreeSitter,
13624 symbols: vec![CodeSymbol {
13625 path: "src/main.rs".to_string(),
13626 language: Some("rust".to_string()),
13627 name: "main".to_string(),
13628 kind: SymbolKind::Function,
13629 signature: "fn main()".to_string(),
13630 exported: true,
13631 documentation: Some("Run the application.".to_string()),
13632 line_start: 1,
13633 line_end: 3,
13634 source_selector: None,
13635 parent: None,
13636 parser: ParserKind::TreeSitter,
13637 detail: Some("function_item".to_string()),
13638 }],
13639 relations: vec![SymbolRelation {
13640 path: "src/main.rs".to_string(),
13641 source_name: "main".to_string(),
13642 target_name: "println!".to_string(),
13643 kind: RelationKind::Calls,
13644 line: 2,
13645 context: "println!(\"hello\")".to_string(),
13646 parser: ParserKind::TreeSitter,
13647 }],
13648 };
13649
13650 store.replace_symbol_graph(&graph)?;
13651 store.replace_symbol_graph(&graph)?;
13652 let symbols = store.load_symbols(Some("src/main.rs"), Some("main"), 10)?;
13653 let relations = store.load_symbol_relations(Some("src/main.rs"), Some("println"), 10)?;
13654 let metadata = store
13655 .load_source_parse_metadata("src/main.rs")?
13656 .ok_or_else(|| io::Error::other("missing source parse metadata"))?;
13657 require_eq(&symbols.len(), &1, "symbol count after replace")?;
13658 require_eq(&relations.len(), &1, "relation count after replace")?;
13659 require_eq(&metadata.parser, &ParserKind::TreeSitter, "metadata parser")?;
13660 require_eq(&metadata.symbol_count, &1, "metadata symbol count")?;
13661 require_eq(&metadata.relation_count, &1, "metadata relation count")?;
13662 require_eq(&symbols[0].exported, &true, "exported metadata")?;
13663 require_eq(
13664 &symbols[0].documentation,
13665 &Some("Run the application.".to_string()),
13666 "documentation metadata",
13667 )?;
13668 Ok(())
13669 }
13670
13671 #[test]
13672 fn symbol_source_selectors_round_trip_all_read_paths_and_reopen() -> Result<(), Box<dyn Error>>
13673 {
13674 let temp = tempfile::tempdir()?;
13675 let root = temp.path().join("repository");
13676 fs::create_dir(&root)?;
13677 let database = temp.path().join("projectatlas.db");
13678 let path = "docs/guide.md";
13679 let expected = SymbolSourceSelector {
13680 byte_start: 17,
13681 byte_end: 31,
13682 column_start: 4,
13683 column_end: 2,
13684 };
13685 let graph = SymbolGraph {
13686 path: path.to_string(),
13687 language: Some("markdown".to_string()),
13688 parser: ParserKind::Structural,
13689 symbols: vec![
13690 CodeSymbol {
13691 path: path.to_string(),
13692 language: Some("markdown".to_string()),
13693 name: "Overview".to_string(),
13694 kind: SymbolKind::Heading,
13695 signature: "overview#1".to_string(),
13696 exported: false,
13697 documentation: None,
13698 line_start: 2,
13699 line_end: 3,
13700 source_selector: Some(expected),
13701 parent: None,
13702 parser: ParserKind::Structural,
13703 detail: Some("level=2".to_string()),
13704 },
13705 CodeSymbol {
13706 path: path.to_string(),
13707 language: Some("markdown".to_string()),
13708 name: "Compatibility".to_string(),
13709 kind: SymbolKind::Heading,
13710 signature: "compatibility#1".to_string(),
13711 exported: false,
13712 documentation: None,
13713 line_start: 6,
13714 line_end: 6,
13715 source_selector: None,
13716 parent: None,
13717 parser: ParserKind::Structural,
13718 detail: Some("level=2".to_string()),
13719 },
13720 ],
13721 relations: Vec::new(),
13722 };
13723
13724 let mut store = AtlasStore::open_for_project(&database, &root)?;
13725 store.replace_scan(&[test_file_node(path, "guide-hash")])?;
13726 store.replace_symbol_graph(&graph)?;
13727 let mut publication = store.begin_index_publication("symbol-source-selectors")?;
13728 publication.upsert_file_content_classification_batch(&[FileContentClassification {
13729 path: path.to_string(),
13730 classification: ContentClassification::Documentation,
13731 }])?;
13732 publication.complete()?;
13733 verify_symbol_source_selector_read_paths(&store, path, expected)?;
13734 drop(store);
13735
13736 let reopened = AtlasStore::open_for_project(&database, &root)?;
13737 verify_symbol_source_selector_read_paths(&reopened, path, expected)?;
13738 drop(reopened);
13739
13740 let read_only = AtlasStore::open_read_only_for_project(&database, &root)?;
13741 verify_symbol_source_selector_read_paths(&read_only, path, expected)?;
13742 read_only.finish_index_read_snapshot()?;
13743 Ok(())
13744 }
13745
13746 #[test]
13747 fn symbol_source_selector_constraints_and_corrupt_reads_fail_closed()
13748 -> Result<(), Box<dyn Error>> {
13749 let mut store = AtlasStore::in_memory()?;
13750 let path = "docs/guide.md";
13751 store.replace_symbol_graph(&SymbolGraph {
13752 path: path.to_string(),
13753 language: Some("markdown".to_string()),
13754 parser: ParserKind::Structural,
13755 symbols: vec![CodeSymbol {
13756 path: path.to_string(),
13757 language: Some("markdown".to_string()),
13758 name: "Overview".to_string(),
13759 kind: SymbolKind::Heading,
13760 signature: "overview#1".to_string(),
13761 exported: false,
13762 documentation: None,
13763 line_start: 2,
13764 line_end: 2,
13765 source_selector: None,
13766 parent: None,
13767 parser: ParserKind::Structural,
13768 detail: None,
13769 }],
13770 relations: Vec::new(),
13771 })?;
13772
13773 for invalid_update in [
13774 "UPDATE symbols SET source_byte_start = 0 WHERE path = 'docs/guide.md'",
13775 "UPDATE symbols SET source_byte_start = -1, source_byte_end = 4,
13776 source_column_start = 0, source_column_end = 4
13777 WHERE path = 'docs/guide.md'",
13778 "UPDATE symbols SET source_byte_start = 5, source_byte_end = 4,
13779 source_column_start = 0, source_column_end = 4
13780 WHERE path = 'docs/guide.md'",
13781 "UPDATE symbols SET source_byte_start = 0, source_byte_end = 4,
13782 source_column_start = 5, source_column_end = 4
13783 WHERE path = 'docs/guide.md'",
13784 ] {
13785 require(
13786 store.connection.execute(invalid_update, []).is_err(),
13787 "invalid source selector bypassed its table constraint",
13788 )?;
13789 }
13790 store.connection.execute(
13791 "UPDATE symbols
13792 SET source_byte_start = 0, source_byte_end = 4,
13793 source_column_start = 0, source_column_end = 4
13794 WHERE path = ?1",
13795 [path],
13796 )?;
13797 require_symbol_source_selectors(
13798 &store.load_symbols(Some(path), None, 10)?,
13799 Some(SymbolSourceSelector {
13800 byte_start: 0,
13801 byte_end: 4,
13802 column_start: 0,
13803 column_end: 4,
13804 }),
13805 "valid constrained selector",
13806 )?;
13807
13808 store.connection.execute_batch(
13809 "PRAGMA ignore_check_constraints = ON;
13810 UPDATE symbols
13811 SET source_byte_start = 0, source_byte_end = NULL,
13812 source_column_start = 0, source_column_end = 4
13813 WHERE path = 'docs/guide.md';
13814 PRAGMA ignore_check_constraints = OFF;",
13815 )?;
13816 let corrupt = store.load_symbols(Some(path), None, 10);
13817 require(
13818 matches!(corrupt, Err(DbError::Sqlite(_))),
13819 "partially populated source selector did not fail closed",
13820 )?;
13821 Ok(())
13822 }
13823
13824 #[test]
13825 fn symbol_source_selector_write_failure_rolls_back_graph() -> Result<(), Box<dyn Error>> {
13826 let mut store = AtlasStore::in_memory()?;
13827 let path = "docs/guide.md";
13828 let previous = SymbolGraph {
13829 path: path.to_string(),
13830 language: Some("markdown".to_string()),
13831 parser: ParserKind::Structural,
13832 symbols: vec![batch_test_symbol(path, "previous", 1, 0)],
13833 relations: Vec::new(),
13834 };
13835 store.replace_symbol_graph(&previous)?;
13836
13837 let mut valid = batch_test_symbol(path, "valid", 2, 0);
13838 valid.source_selector = Some(SymbolSourceSelector {
13839 byte_start: 4,
13840 byte_end: 9,
13841 column_start: 0,
13842 column_end: 5,
13843 });
13844 let mut invalid = batch_test_symbol(path, "invalid", 3, 0);
13845 invalid.source_selector = Some(SymbolSourceSelector {
13846 byte_start: 12,
13847 byte_end: 11,
13848 column_start: 0,
13849 column_end: 5,
13850 });
13851 let replacement = SymbolGraph {
13852 path: path.to_string(),
13853 language: Some("markdown".to_string()),
13854 parser: ParserKind::Structural,
13855 symbols: vec![valid, invalid],
13856 relations: Vec::new(),
13857 };
13858 let Err(error) = store.replace_symbol_graph(&replacement) else {
13859 return Err(io::Error::other("invalid selector replacement committed").into());
13860 };
13861 require(
13862 matches!(error, DbError::SymbolGraphRowShape { .. }),
13863 "invalid selector replacement returned the wrong error",
13864 )?;
13865 require_eq(
13866 &store.load_symbol_graphs_for_paths(&[path.to_string()])?,
13867 &vec![previous],
13868 "selector failure transaction rollback",
13869 )?;
13870 Ok(())
13871 }
13872
13873 #[test]
13874 fn classified_symbol_list_filters_before_limit_and_preserves_legacy_order()
13875 -> Result<(), Box<dyn Error>> {
13876 let mut store = AtlasStore::in_memory()?;
13877 let classified_paths = [
13878 (
13879 "config/first.toml",
13880 ContentClassification::ConfigurationData,
13881 ),
13882 ("docs/guide.md", ContentClassification::Documentation),
13883 ("other/notes.txt", ContentClassification::OtherText),
13884 ("src/lib.rs", ContentClassification::Source),
13885 ("src/second.rs", ContentClassification::Source),
13886 ];
13887 let nodes = classified_paths
13888 .iter()
13889 .enumerate()
13890 .map(|(index, (path, _))| test_file_node(path, &format!("hash-{index}")))
13891 .collect::<Vec<_>>();
13892 store.replace_scan(&nodes)?;
13893 for (index, (path, _)) in classified_paths.iter().enumerate() {
13894 store.replace_symbol_graph(&SymbolGraph {
13895 path: (*path).to_string(),
13896 language: Some("rust".to_string()),
13897 parser: ParserKind::TreeSitter,
13898 symbols: vec![batch_test_symbol(path, &format!("needle_{index}"), 1, 0)],
13899 relations: Vec::new(),
13900 })?;
13901 }
13902 let classifications = classified_paths
13903 .iter()
13904 .map(|(path, classification)| FileContentClassification {
13905 path: (*path).to_string(),
13906 classification: *classification,
13907 })
13908 .collect::<Vec<_>>();
13909 let mut publication = store.begin_index_publication("classified-symbol-list")?;
13910 publication.upsert_file_content_classification_batch(&classifications)?;
13911 publication.complete()?;
13912
13913 for (file, query, limit) in [
13914 (None, None, 3),
13915 (None, Some("needle"), 3),
13916 (Some("src/second.rs"), None, 10),
13917 (Some("src/second.rs"), Some("needle_4"), 10),
13918 (Some("src/second.rs"), Some("second"), 10),
13919 (None, None, 0),
13920 ] {
13921 let legacy = store.load_symbols(file, query, limit)?;
13922 let classified = store.load_classified_symbols(
13923 file,
13924 query,
13925 ContentSelection::UnspecifiedLegacy,
13926 limit,
13927 )?;
13928 require_eq(
13929 &classified
13930 .into_iter()
13931 .map(|row| row.symbol)
13932 .collect::<Vec<_>>(),
13933 &legacy,
13934 "omitted classified symbol candidates and order",
13935 )?;
13936 }
13937 let omitted = store.load_classified_symbols(
13938 None,
13939 Some("needle"),
13940 ContentSelection::UnspecifiedLegacy,
13941 3,
13942 )?;
13943 require_eq(
13944 &omitted
13945 .iter()
13946 .map(|row| row.classification)
13947 .collect::<Vec<_>>(),
13948 &vec![
13949 ContentClassification::ConfigurationData,
13950 ContentClassification::Documentation,
13951 ContentClassification::OtherText,
13952 ],
13953 "additive legacy classifications",
13954 )?;
13955
13956 let source =
13957 store.load_classified_symbols(None, Some("needle"), ContentSelection::Source, 1)?;
13958 require_eq(
13959 &source.first().map(|row| row.symbol.path.as_str()),
13960 &Some("src/lib.rs"),
13961 "source selection before limit",
13962 )?;
13963 let documentation = store.load_classified_symbols(
13964 None,
13965 Some("needle"),
13966 ContentSelection::Documentation,
13967 1,
13968 )?;
13969 require_eq(
13970 &documentation.first().map(|row| row.symbol.path.as_str()),
13971 &Some("docs/guide.md"),
13972 "documentation selection before limit",
13973 )?;
13974 let both =
13975 store.load_classified_symbols(None, Some("needle"), ContentSelection::Both, 2)?;
13976 require_eq(
13977 &both
13978 .iter()
13979 .map(|row| row.symbol.path.as_str())
13980 .collect::<Vec<_>>(),
13981 &vec!["docs/guide.md", "src/lib.rs"],
13982 "combined selection order",
13983 )?;
13984 let file_query = store.load_classified_symbols(
13985 Some("src/second.rs"),
13986 Some("needle_4"),
13987 ContentSelection::Source,
13988 10,
13989 )?;
13990 require_eq(&file_query.len(), &1, "file/query classified symbol filter")?;
13991 Ok(())
13992 }
13993
13994 #[test]
13995 fn classified_symbol_list_rejects_missing_owner_and_uses_existing_indexes()
13996 -> Result<(), Box<dyn Error>> {
13997 let mut store = AtlasStore::in_memory()?;
13998 store.replace_scan(&[test_file_node("src/lib.rs", "hash")])?;
13999 store.replace_symbol_graph(&SymbolGraph {
14000 path: "src/lib.rs".to_string(),
14001 language: Some("rust".to_string()),
14002 parser: ParserKind::TreeSitter,
14003 symbols: vec![batch_test_symbol("src/lib.rs", "needle", 1, 0)],
14004 relations: Vec::new(),
14005 })?;
14006
14007 for selection in [
14008 ContentSelection::UnspecifiedLegacy,
14009 ContentSelection::Source,
14010 ContentSelection::Both,
14011 ] {
14012 let (sql, bindings) = classified_symbols_sql(None, Some("needle"), selection, 1);
14013 let mut statement = store
14014 .connection
14015 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
14016 let plan = statement
14017 .query_map(params_from_iter(bindings.iter()), |row| {
14018 row.get::<_, String>(3)
14019 })?
14020 .collect::<Result<Vec<_>, _>>()?
14021 .join("\n");
14022 require(
14023 plan.contains("idx_symbols_path")
14024 && plan.contains("sqlite_autoindex_file_content_classifications_1")
14025 && !plan.contains("SCAN classification"),
14026 &format!("classified symbol query missed existing indexes: {plan}"),
14027 )?;
14028 }
14029
14030 store.connection.execute(
14031 "DELETE FROM file_content_classifications WHERE path = 'src/lib.rs'",
14032 [],
14033 )?;
14034 for selection in [
14035 ContentSelection::UnspecifiedLegacy,
14036 ContentSelection::Source,
14037 ContentSelection::Both,
14038 ] {
14039 let Err(error) = store.load_classified_symbols(Some("src/lib.rs"), None, selection, 10)
14040 else {
14041 return Err(
14042 io::Error::other("symbol without a file classification was accepted").into(),
14043 );
14044 };
14045 require(
14046 matches!(error, DbError::FileContentClassificationMissing { .. }),
14047 "missing symbol classification returned the wrong error",
14048 )?;
14049 }
14050 Ok(())
14051 }
14052
14053 #[test]
14054 fn import_alias_lookup_uses_kind_first_covering_index() -> Result<(), Box<dyn Error>> {
14055 let mut store = AtlasStore::in_memory()?;
14056 store.replace_symbol_graph(&SymbolGraph {
14057 path: "src/main.rs".to_string(),
14058 language: Some("rust".to_string()),
14059 parser: ParserKind::TreeSitter,
14060 symbols: Vec::new(),
14061 relations: vec![
14062 SymbolRelation {
14063 path: "src/main.rs".to_string(),
14064 source_name: "main".to_string(),
14065 target_name: "use crate::service::run".to_string(),
14066 kind: RelationKind::Imports,
14067 line: 1,
14068 context: "use crate::service::run;".to_string(),
14069 parser: ParserKind::TreeSitter,
14070 },
14071 SymbolRelation {
14072 path: "src/main.rs".to_string(),
14073 source_name: "main".to_string(),
14074 target_name: "use crate::other::Thing".to_string(),
14075 kind: RelationKind::Imports,
14076 line: 2,
14077 context: "use crate::other::Thing;".to_string(),
14078 parser: ParserKind::TreeSitter,
14079 },
14080 ],
14081 })?;
14082
14083 let relations =
14084 store.load_import_relations_matching_targets(&["service".to_string()], 10)?;
14085 require_eq(&relations.len(), &1, "matched import relation count")?;
14086 require_eq(
14087 &store
14088 .load_import_relations_for_path("src/main.rs", 10)?
14089 .len(),
14090 &2,
14091 "exact-path import relation count",
14092 )?;
14093 let mut statement = store.connection.prepare(
14094 "EXPLAIN QUERY PLAN
14095 SELECT path, source_name, target_name, line
14096 FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
14097 WHERE kind = 'imports' AND target_name LIKE '%service%' ESCAPE '\\'
14098 ORDER BY path, line, source_name, target_name
14099 LIMIT 10",
14100 )?;
14101 let plan = statement
14102 .query_map([], |row| row.get::<_, String>(3))?
14103 .collect::<Result<Vec<_>, _>>()?
14104 .join("\n");
14105 require(
14106 plan.contains(
14107 "SEARCH symbol_relations USING COVERING INDEX idx_symbol_import_alias_lookup (kind=?)",
14108 ),
14109 &format!("import alias lookup missed kind-first covering index: {plan}"),
14110 )?;
14111 Ok(())
14112 }
14113
14114 #[test]
14115 fn file_scoped_symbol_kind_lookup_uses_path_index() -> Result<(), Box<dyn Error>> {
14116 let store = AtlasStore::in_memory()?;
14117 for sql in [
14118 "EXPLAIN QUERY PLAN
14119 SELECT path, language, name, kind, signature, line_start, line_end,
14120 parent, parser, detail, exported, documentation,
14121 source_byte_start, source_byte_end,
14122 source_column_start, source_column_end
14123 FROM symbols INDEXED BY idx_symbols_path
14124 WHERE path = 'src/lib.rs' AND kind IN ('function')
14125 ORDER BY path, line_start, name
14126 LIMIT 25",
14127 "EXPLAIN QUERY PLAN
14128 SELECT COUNT(*) FROM symbols INDEXED BY idx_symbols_path
14129 WHERE path = 'src/lib.rs' AND kind IN ('function')",
14130 ] {
14131 let mut statement = store.connection.prepare(sql)?;
14132 let plan = statement
14133 .query_map([], |row| row.get::<_, String>(3))?
14134 .collect::<Result<Vec<_>, _>>()?
14135 .join("\n");
14136 require(
14137 plan.contains("SEARCH symbols USING INDEX idx_symbols_path (path=?)"),
14138 &format!("file-scoped symbol lookup missed exact-path index: {plan}"),
14139 )?;
14140 }
14141 Ok(())
14142 }
14143
14144 #[test]
14145 fn preserves_source_parse_and_fact_parser_provenance_independently()
14146 -> Result<(), Box<dyn Error>> {
14147 let temp = tempfile::tempdir()?;
14148 let database = temp.path().join("projectatlas.db");
14149 let mut store = AtlasStore::open(&database)?;
14150 let graph = SymbolGraph {
14151 path: "src/optional.lang".to_string(),
14152 language: Some("optional-language".to_string()),
14153 parser: ParserKind::Fallback,
14154 symbols: vec![CodeSymbol {
14155 path: "src/optional.lang".to_string(),
14156 language: Some("optional-language".to_string()),
14157 name: "entry".to_string(),
14158 kind: SymbolKind::Function,
14159 signature: "entry()".to_string(),
14160 exported: false,
14161 documentation: None,
14162 line_start: 1,
14163 line_end: 1,
14164 source_selector: None,
14165 parent: None,
14166 parser: ParserKind::Fallback,
14167 detail: None,
14168 }],
14169 relations: vec![SymbolRelation {
14170 path: "src/optional.lang".to_string(),
14171 source_name: "entry".to_string(),
14172 target_name: "helper".to_string(),
14173 kind: RelationKind::Calls,
14174 line: 1,
14175 context: "entry()".to_string(),
14176 parser: ParserKind::Fallback,
14177 }],
14178 };
14179 let metadata = SourceParseMetadata {
14180 path: graph.path.clone(),
14181 language: graph.language.clone(),
14182 parser: ParserKind::TreeSitter,
14183 symbol_count: graph.symbols.len(),
14184 relation_count: graph.relations.len(),
14185 };
14186
14187 store.replace_symbol_graph_with_metadata(&graph, &metadata)?;
14188
14189 let stored_metadata = store
14190 .load_source_parse_metadata(&graph.path)?
14191 .ok_or_else(|| io::Error::other("missing independent source parse metadata"))?;
14192 let symbols = store.load_symbols(Some(&graph.path), Some("entry"), 10)?;
14193 let relations = store.load_symbol_relations(Some(&graph.path), Some("helper"), 10)?;
14194 require_eq(
14195 &stored_metadata.parser,
14196 &ParserKind::TreeSitter,
14197 "grammar-backed source parser",
14198 )?;
14199 require_eq(
14200 &symbols[0].parser,
14201 &ParserKind::Fallback,
14202 "fallback symbol provenance",
14203 )?;
14204 require_eq(
14205 &relations[0].parser,
14206 &ParserKind::Fallback,
14207 "fallback relation provenance",
14208 )?;
14209
14210 let invalid_metadata = SourceParseMetadata {
14211 symbol_count: 0,
14212 ..metadata
14213 };
14214 let Err(error) = store.replace_symbol_graph_with_metadata(&graph, &invalid_metadata) else {
14215 return Err(io::Error::other("mismatched explicit metadata was accepted").into());
14216 };
14217 if !matches!(error, DbError::SymbolGraphRowShape { .. }) {
14218 return Err(io::Error::other(format!(
14219 "mismatched explicit metadata returned the wrong error: {error}"
14220 ))
14221 .into());
14222 }
14223
14224 let empty_graph = SymbolGraph {
14225 path: "src/empty.optional".to_string(),
14226 language: Some("optional-language".to_string()),
14227 parser: ParserKind::Fallback,
14228 symbols: Vec::new(),
14229 relations: Vec::new(),
14230 };
14231 let empty_metadata = SourceParseMetadata {
14232 path: empty_graph.path.clone(),
14233 language: empty_graph.language.clone(),
14234 parser: ParserKind::TreeSitter,
14235 symbol_count: 0,
14236 relation_count: 0,
14237 };
14238 store.replace_symbol_graph_with_metadata(&empty_graph, &empty_metadata)?;
14239 drop(store);
14240
14241 let reader = AtlasStore::open_read_only(&database)?;
14242 let reopened_metadata = reader
14243 .load_source_parse_metadata(&graph.path)?
14244 .ok_or_else(|| io::Error::other("missing reopened source parse metadata"))?;
14245 let metadata_paths = reader.source_parse_metadata_paths_for_paths(&[
14246 "src/missing.optional".to_string(),
14247 empty_graph.path.clone(),
14248 graph.path.clone(),
14249 graph.path.clone(),
14250 ])?;
14251 let reopened_graphs =
14252 reader.load_symbol_graphs_for_paths(&[graph.path.clone(), empty_graph.path.clone()])?;
14253 require_eq(
14254 &reopened_metadata.parser,
14255 &ParserKind::TreeSitter,
14256 "reopened source parser provenance",
14257 )?;
14258 require_eq(
14259 &metadata_paths,
14260 &HashSet::from([graph.path.clone(), empty_graph.path.clone()]),
14261 "batched source parse metadata paths",
14262 )?;
14263 require_eq(
14264 &reopened_graphs,
14265 &vec![empty_graph, graph],
14266 "reopened fact graph provenance",
14267 )?;
14268 reader.finish_index_read_snapshot()?;
14269 Ok(())
14270 }
14271
14272 #[test]
14273 fn reconstructs_exact_symbol_graph_batches_from_disk_and_fails_closed()
14274 -> Result<(), Box<dyn Error>> {
14275 let temp = tempfile::tempdir()?;
14276 let db_path = temp.path().join("projectatlas.db");
14277 let mut store = AtlasStore::open(&db_path)?;
14278 store.replace_scan(&[
14279 test_file_node("src/a.rs", "hash-a"),
14280 test_file_node("src/b.rs", "hash-b"),
14281 ])?;
14282 let graph = SymbolGraph {
14283 path: "src/a.rs".to_string(),
14284 language: Some("rust".to_string()),
14285 parser: ParserKind::TreeSitter,
14286 symbols: vec![CodeSymbol {
14287 path: "src/a.rs".to_string(),
14288 language: Some("rust".to_string()),
14289 name: "owner".to_string(),
14290 kind: SymbolKind::Function,
14291 signature: "fn owner()".to_string(),
14292 exported: true,
14293 documentation: None,
14294 line_start: 1,
14295 line_end: 2,
14296 source_selector: None,
14297 parent: None,
14298 parser: ParserKind::TreeSitter,
14299 detail: Some("function_item".to_string()),
14300 }],
14301 relations: vec![SymbolRelation {
14302 path: "src/a.rs".to_string(),
14303 source_name: "owner".to_string(),
14304 target_name: "dependency".to_string(),
14305 kind: RelationKind::Calls,
14306 line: 2,
14307 context: "dependency()".to_string(),
14308 parser: ParserKind::TreeSitter,
14309 }],
14310 };
14311 store.replace_symbol_graph(&graph)?;
14312 let empty_graph = SymbolGraph {
14313 path: "src/b.rs".to_string(),
14314 language: Some("rust".to_string()),
14315 parser: ParserKind::TreeSitter,
14316 symbols: Vec::new(),
14317 relations: Vec::new(),
14318 };
14319 store.replace_symbol_graph(&empty_graph)?;
14320 drop(store);
14321
14322 let reader = AtlasStore::open_read_only(&db_path)?;
14323 let loaded = reader.load_symbol_graphs_for_paths(&[
14324 "src/b.rs".to_string(),
14325 "src/a.rs".to_string(),
14326 "src/a.rs".to_string(),
14327 "src/missing.rs".to_string(),
14328 ])?;
14329 require_eq(
14330 &loaded,
14331 &vec![graph, empty_graph],
14332 "batched symbol graph round trip",
14333 )?;
14334 for (table, expected_index) in [
14335 (
14336 "source_parse_metadata",
14337 "sqlite_autoindex_source_parse_metadata_1",
14338 ),
14339 ("symbols", "idx_symbols_path"),
14340 ("symbol_relations", "idx_symbol_relations_path"),
14341 ] {
14342 let sql = format!(
14343 "EXPLAIN QUERY PLAN SELECT path FROM {table}
14344 WHERE path IN ('src/a.rs', 'src/b.rs')"
14345 );
14346 let mut statement = reader.connection.prepare(&sql)?;
14347 let plan = statement
14348 .query_map([], |row| row.get::<_, String>(3))?
14349 .collect::<Result<Vec<_>, _>>()?
14350 .join("\n");
14351 if !plan.contains(expected_index) {
14352 return Err(io::Error::other(format!(
14353 "{table} batch lookup missed {expected_index}: {plan}"
14354 ))
14355 .into());
14356 }
14357 }
14358 reader.finish_index_read_snapshot()?;
14359 drop(reader);
14360
14361 let writer = AtlasStore::open(&db_path)?;
14362 writer.connection.execute(
14363 "UPDATE source_parse_metadata SET symbol_count = 2 WHERE path = 'src/a.rs'",
14364 [],
14365 )?;
14366 let Err(error) = writer.load_symbol_graphs_for_paths(&["src/a.rs".to_string()]) else {
14367 return Err(io::Error::other("metadata count corruption was accepted").into());
14368 };
14369 if !matches!(error, DbError::SymbolGraphRowShape { .. }) {
14370 return Err(io::Error::other(format!(
14371 "metadata count corruption returned the wrong error: {error}"
14372 ))
14373 .into());
14374 }
14375 Ok(())
14376 }
14377
14378 #[test]
14379 fn bounded_symbol_batch_streams_exact_paths_and_uses_path_index() -> Result<(), Box<dyn Error>>
14380 {
14381 let mut store = AtlasStore::in_memory()?;
14382 store.replace_scan(&[
14383 test_file_node("src/a.rs", "hash-a"),
14384 test_file_node("src/b.rs", "hash-b"),
14385 ])?;
14386 store.replace_symbol_graph(&SymbolGraph {
14387 path: "src/a.rs".to_string(),
14388 language: Some("rust".to_string()),
14389 parser: ParserKind::TreeSitter,
14390 symbols: vec![
14391 batch_test_symbol("src/a.rs", "alpha", 1, 32),
14392 batch_test_symbol("src/a.rs", "beta", 2, 32),
14393 ],
14394 relations: Vec::new(),
14395 })?;
14396 store.replace_symbol_graph(&SymbolGraph {
14397 path: "src/b.rs".to_string(),
14398 language: Some("rust".to_string()),
14399 parser: ParserKind::TreeSitter,
14400 symbols: vec![batch_test_symbol("src/b.rs", "gamma", 1, 32)],
14401 relations: Vec::new(),
14402 })?;
14403
14404 let complete = store.load_symbols_for_paths_bounded(
14405 &[
14406 "src/b.rs".to_string(),
14407 "src/a.rs".to_string(),
14408 "src/a.rs".to_string(),
14409 ],
14410 SymbolBatchReadBudget::new(2, 3, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14411 None,
14412 )?;
14413 require_eq(
14414 &complete
14415 .rows
14416 .iter()
14417 .map(|symbol| symbol.name.as_str())
14418 .collect::<Vec<_>>(),
14419 &vec!["alpha", "beta", "gamma"],
14420 "deterministic exact-path symbol order",
14421 )?;
14422 require_eq(&complete.truncated, &false, "complete symbol batch")?;
14423 require_eq(&complete.reached_limit, &None, "complete batch limit")?;
14424 require_eq(&complete.work.requested_paths, &2, "unique admitted paths")?;
14425 require_eq(&complete.work.returned_rows, &3, "complete returned rows")?;
14426
14427 let row_limited = store.load_symbols_for_paths_bounded(
14428 &["src/b.rs".to_string(), "src/a.rs".to_string()],
14429 SymbolBatchReadBudget::new(2, 1, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14430 None,
14431 )?;
14432 require_eq(
14433 &row_limited.reached_limit,
14434 &Some(SymbolBatchReadLimit::Rows),
14435 "row-bound classification",
14436 )?;
14437 require_eq(&row_limited.rows.len(), &1, "row-bound retained rows")?;
14438
14439 let byte_limited = store.load_symbols_for_paths_bounded(
14440 &["src/a.rs".to_string()],
14441 SymbolBatchReadBudget::new(1, 3, 1)?,
14442 None,
14443 )?;
14444 require_eq(
14445 &byte_limited.reached_limit,
14446 &Some(SymbolBatchReadLimit::DecodedBytes),
14447 "decoded-byte-bound classification",
14448 )?;
14449 require_eq(
14450 &byte_limited.rows.len(),
14451 &0,
14452 "tiny byte budget retained no partial row",
14453 )?;
14454 require_eq(
14455 &byte_limited.work.decoded_bytes,
14456 &0,
14457 "tiny byte budget retained no row payload",
14458 )?;
14459
14460 let gamma_bytes = code_symbol_decoded_bytes(&complete.rows[2])?;
14461 let exact_byte_boundary = store.load_symbols_for_paths_bounded(
14462 &["src/b.rs".to_string()],
14463 SymbolBatchReadBudget::new(1, 1, gamma_bytes)?,
14464 None,
14465 )?;
14466 require_eq(
14467 &exact_byte_boundary.rows.len(),
14468 &1,
14469 "exact decoded-byte boundary retained the complete row",
14470 )?;
14471 require_eq(
14472 &exact_byte_boundary.work.decoded_bytes,
14473 &gamma_bytes,
14474 "exact decoded-byte boundary accounting",
14475 )?;
14476
14477 let oversized_signature =
14478 "x".repeat(usize::try_from(MAX_SYMBOL_BATCH_DECODED_BYTES)?.saturating_add(1));
14479 store.connection.execute(
14480 "UPDATE symbols SET signature = ?1, line_start = 'invalid' WHERE path = 'src/b.rs'",
14481 [&oversized_signature],
14482 )?;
14483 let oversized_row = store.load_symbols_for_paths_bounded(
14484 &["src/b.rs".to_string()],
14485 SymbolBatchReadBudget::new(1, 1, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14486 None,
14487 )?;
14488 require_eq(
14489 &oversized_row.reached_limit,
14490 &Some(SymbolBatchReadLimit::DecodedBytes),
14491 "oversized persisted row was rejected before hydration",
14492 )?;
14493 require_eq(
14494 &oversized_row.rows.len(),
14495 &0,
14496 "oversized persisted row retained no allocation",
14497 )?;
14498 store.connection.execute(
14499 "UPDATE symbols SET signature = 'fn gamma()', line_start = 1 WHERE path = 'src/b.rs'",
14500 [],
14501 )?;
14502
14503 let many_paths = (0..65)
14504 .rev()
14505 .map(|index| format!("generated/{index:04}.rs"))
14506 .collect::<Vec<_>>();
14507 for index in 0..65 {
14508 let path = format!("generated/{index:04}.rs");
14509 store.replace_symbol_graph(&SymbolGraph {
14510 path: path.clone(),
14511 language: Some("rust".to_string()),
14512 parser: ParserKind::TreeSitter,
14513 symbols: vec![batch_test_symbol(
14514 &path,
14515 &format!("generated_{index:04}"),
14516 1,
14517 0,
14518 )],
14519 relations: Vec::new(),
14520 })?;
14521 }
14522 let path_limited = store.load_symbols_for_paths_bounded(
14523 &many_paths,
14524 SymbolBatchReadBudget::new(
14525 MAX_SYMBOL_BATCH_PATHS,
14526 MAX_SYMBOL_BATCH_ROWS,
14527 MAX_SYMBOL_BATCH_DECODED_BYTES,
14528 )?,
14529 None,
14530 )?;
14531 require_eq(
14532 &path_limited.reached_limit,
14533 &Some(SymbolBatchReadLimit::Paths),
14534 "path-bound classification",
14535 )?;
14536 require_eq(
14537 &path_limited.work.requested_paths,
14538 &MAX_SYMBOL_BATCH_PATHS,
14539 "bounded path admission",
14540 )?;
14541 require_eq(
14542 &path_limited.rows.len(),
14543 &usize::try_from(MAX_SYMBOL_BATCH_PATHS)?,
14544 "all admitted paths loaded across binding chunks",
14545 )?;
14546 require(
14547 path_limited
14548 .rows
14549 .iter()
14550 .any(|symbol| symbol.name == "generated_0063"),
14551 "last deterministically admitted path was not loaded",
14552 )?;
14553 require(
14554 path_limited
14555 .rows
14556 .iter()
14557 .all(|symbol| symbol.name != "generated_0064"),
14558 "path outside the deterministic admission set was loaded",
14559 )?;
14560
14561 let expired = IndexWorkControl::with_deadline(
14562 projectatlas_core::IndexCancellation::new(),
14563 Instant::now(),
14564 );
14565 let expired_result = store.load_symbols_for_paths_bounded(
14566 &["src/a.rs".to_string()],
14567 SymbolBatchReadBudget::new(1, 3, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14568 Some(&expired),
14569 );
14570 require(
14571 matches!(
14572 expired_result,
14573 Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
14574 stage: IndexWorkStage::RepositoryTraversal
14575 }))
14576 ),
14577 "expired symbol control returned a partial batch instead of a typed error",
14578 )?;
14579
14580 let mut plan_statement = store.connection.prepare(
14581 "EXPLAIN QUERY PLAN
14582 SELECT
14583 path,
14584 language,
14585 name,
14586 kind,
14587 signature,
14588 line_start,
14589 line_end,
14590 parent,
14591 parser,
14592 detail,
14593 exported,
14594 documentation,
14595 source_byte_start,
14596 source_byte_end,
14597 source_column_start,
14598 source_column_end,
14599 length(CAST(path AS BLOB))
14600 + COALESCE(length(CAST(language AS BLOB)), 0)
14601 + length(CAST(name AS BLOB))
14602 + length(CAST(signature AS BLOB))
14603 + COALESCE(length(CAST(parent AS BLOB)), 0)
14604 + COALESCE(length(CAST(detail AS BLOB)), 0)
14605 + COALESCE(length(CAST(documentation AS BLOB)), 0)
14606 FROM symbols
14607 WHERE path IN (?1, ?2)
14608 ORDER BY path, line_start, name
14609 LIMIT ?3",
14610 )?;
14611 let plan = plan_statement
14612 .query_map(params!["src/a.rs", "src/b.rs", 4], |row| {
14613 row.get::<_, String>(3)
14614 })?
14615 .collect::<Result<Vec<_>, _>>()?
14616 .join("\n");
14617 require(
14618 plan.contains("idx_symbols_path"),
14619 &format!("bounded symbol batch missed idx_symbols_path: {plan}"),
14620 )?;
14621 Ok(())
14622 }
14623
14624 #[test]
14625 fn full_scan_removal_clears_source_parse_metadata() -> Result<(), Box<dyn Error>> {
14626 let mut store = AtlasStore::in_memory()?;
14627 store.replace_scan(&[test_file_node("src/a.rs", "hash-a")])?;
14628 store.replace_symbol_graph(&SymbolGraph {
14629 path: "src/a.rs".to_string(),
14630 language: Some("rust".to_string()),
14631 parser: ParserKind::TreeSitter,
14632 symbols: Vec::new(),
14633 relations: Vec::new(),
14634 })?;
14635 require_eq(
14636 &store.load_source_parse_metadata("src/a.rs")?.is_some(),
14637 &true,
14638 "metadata exists before removal",
14639 )?;
14640
14641 store.replace_scan(&[test_file_node("src/b.rs", "hash-b")])?;
14642 require_eq(
14643 &store.load_source_parse_metadata("src/a.rs")?,
14644 &None,
14645 "metadata cleared after full scan removal",
14646 )?;
14647 Ok(())
14648 }
14649
14650 #[test]
14651 fn call_relations_are_limited_per_target() -> Result<(), Box<dyn Error>> {
14652 let mut store = AtlasStore::in_memory()?;
14653 let mut relations = Vec::new();
14654 for index in 0..5 {
14655 relations.push(SymbolRelation {
14656 path: format!("src/a{index}.rs"),
14657 source_name: format!("alpha_caller_{index}"),
14658 target_name: "alpha".to_string(),
14659 kind: RelationKind::Calls,
14660 line: index + 1,
14661 context: "alpha();".to_string(),
14662 parser: ParserKind::TreeSitter,
14663 });
14664 }
14665 relations.push(SymbolRelation {
14666 path: "src/z.rs".to_string(),
14667 source_name: "beta_caller".to_string(),
14668 target_name: "beta".to_string(),
14669 kind: RelationKind::Calls,
14670 line: 99,
14671 context: "beta();".to_string(),
14672 parser: ParserKind::TreeSitter,
14673 });
14674 store.replace_symbol_graph(&SymbolGraph {
14675 path: "src/main.rs".to_string(),
14676 language: Some("rust".to_string()),
14677 parser: ParserKind::TreeSitter,
14678 symbols: Vec::new(),
14679 relations,
14680 })?;
14681
14682 let loaded =
14683 store.load_call_relations_to_targets(&["alpha".to_string(), "beta".to_string()], 2)?;
14684 let alpha_count = loaded
14685 .iter()
14686 .filter(|relation| relation.target_name == "alpha")
14687 .count();
14688 let beta_count = loaded
14689 .iter()
14690 .filter(|relation| relation.target_name == "beta")
14691 .count();
14692 require_eq(&alpha_count, &2, "alpha per-target limit")?;
14693 require_eq(&beta_count, &1, "beta preserved despite alpha skew")?;
14694 let mut statement = store.connection.prepare(
14695 "EXPLAIN QUERY PLAN
14696 SELECT path, source_name, target_name, kind, line, context, parser
14697 FROM (
14698 SELECT path, source_name, target_name, kind, line, context, parser,
14699 ROW_NUMBER() OVER (
14700 PARTITION BY target_name
14701 ORDER BY path, line, source_name, target_name
14702 ) AS target_row
14703 FROM symbol_relations INDEXED BY idx_symbol_relations_target
14704 WHERE kind = 'calls' AND target_name IN ('alpha', 'beta')
14705 )
14706 WHERE target_row <= 2
14707 ORDER BY path, line, source_name, target_name",
14708 )?;
14709 let plan = statement
14710 .query_map([], |row| row.get::<_, String>(3))?
14711 .collect::<Result<Vec<_>, _>>()?
14712 .join("\n");
14713 require(
14714 plan.contains(
14715 "SEARCH symbol_relations USING INDEX idx_symbol_relations_target (target_name=?)",
14716 ),
14717 &format!("call target lookup missed exact-target index: {plan}"),
14718 )?;
14719 Ok(())
14720 }
14721
14722 #[test]
14723 fn stores_health_resolution_ids() -> Result<(), Box<dyn Error>> {
14724 let mut store = AtlasStore::in_memory()?;
14725 store.replace_scan(&[
14726 test_file_node("src/a.rs", "hash-a"),
14727 test_file_node("src/b.rs", "hash-b"),
14728 ])?;
14729 store.set_purpose("src/a.rs", "Shared purpose", PurposeSource::Agent)?;
14730 store.set_purpose("src/b.rs", "Shared purpose", PurposeSource::Agent)?;
14731 let duplicate = store
14732 .unresolved_health_findings(&[])?
14733 .into_iter()
14734 .find(|finding| finding.category == "duplicate-purpose")
14735 .ok_or_else(|| io::Error::other("duplicate-purpose finding missing"))?;
14736 let duplicate_id = duplicate.id.clone();
14737 store.resolve_health_finding(&HealthResolution {
14738 finding_id: duplicate_id.clone(),
14739 category: duplicate.category,
14740 path: duplicate.path,
14741 related_path: duplicate.related_path,
14742 rationale: "Paths intentionally mirror agent skill variants.".to_string(),
14743 })?;
14744 let ids = store.resolved_health_ids()?;
14745 require_eq(&ids, &vec![duplicate_id], "resolved ids")?;
14746 Ok(())
14747 }
14748
14749 #[test]
14750 fn health_resolution_accepts_all_scope_agent_review_findings() -> Result<(), Box<dyn Error>> {
14751 let mut store = AtlasStore::in_memory()?;
14752 let mut asset_file = test_file_node("assets/logo.svg", "hash-logo");
14753 asset_file.extension = Some(".svg".to_string());
14754 asset_file.language = None;
14755 store.replace_scan(&[test_folder_node("assets"), asset_file])?;
14756 store.set_purpose(
14757 "assets/logo.svg",
14758 "Imported SVG brand asset purpose",
14759 PurposeSource::Imported,
14760 )?;
14761
14762 let page = store.unresolved_health_findings_page(
14763 &[],
14764 &HealthQuery {
14765 start_index: 0,
14766 limit: 20,
14767 category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
14768 severity: Some(Severity::Warning),
14769 path_prefix: Some(".".to_string()),
14770 summary_only: false,
14771 scope: HealthScope::all(),
14772 },
14773 )?;
14774 let finding = page
14775 .findings
14776 .iter()
14777 .find(|finding| finding.path == "assets/logo.svg")
14778 .ok_or_else(|| io::Error::other("asset review finding missing"))?;
14779 store.resolve_health_finding(&HealthResolution {
14780 finding_id: finding.id.clone(),
14781 category: finding.category.clone(),
14782 path: finding.path.clone(),
14783 related_path: finding.related_path.clone(),
14784 rationale: "Asset purpose imported from legacy metadata and intentionally accepted."
14785 .to_string(),
14786 })?;
14787
14788 let remaining = store.unresolved_health_findings_page(
14789 &store.resolved_health_ids()?,
14790 &HealthQuery {
14791 start_index: 0,
14792 limit: 20,
14793 category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
14794 severity: Some(Severity::Warning),
14795 path_prefix: Some(".".to_string()),
14796 summary_only: false,
14797 scope: HealthScope::all(),
14798 },
14799 )?;
14800 require_eq(
14801 &health_paths(&remaining).contains(&"assets/logo.svg"),
14802 &false,
14803 "resolved all-scope asset review finding",
14804 )?;
14805 Ok(())
14806 }
14807
14808 #[test]
14809 fn purpose_set_reports_unindexed_path_without_sqlite_leak() -> Result<(), Box<dyn Error>> {
14810 let mut store = AtlasStore::in_memory()?;
14811 store.replace_scan(&[test_file_node("src/main.rs", "hash")])?;
14812 let error = match store.set_purpose("no/such/file.rs", "Missing file", PurposeSource::Agent)
14813 {
14814 Ok(()) => return Err(io::Error::other("missing path should fail").into()),
14815 Err(error) => error,
14816 };
14817
14818 require_eq(
14819 &error.to_string().contains("no/such/file.rs"),
14820 &true,
14821 "path named in error",
14822 )?;
14823 require_eq(
14824 &error.to_string().contains("sqlite error"),
14825 &false,
14826 "raw sqlite error hidden",
14827 )?;
14828 store.replace_scan(&[])?;
14829 let error = match store.set_purpose("src/main.rs", "Removed file", PurposeSource::Agent) {
14830 Ok(()) => return Err(io::Error::other("stale indexed path should fail").into()),
14831 Err(error) => error,
14832 };
14833 require_eq(
14834 &error.to_string().contains("src/main.rs"),
14835 &true,
14836 "stale path named in error",
14837 )?;
14838 Ok(())
14839 }
14840
14841 #[test]
14842 fn health_resolution_requires_active_finding_tuple() -> Result<(), Box<dyn Error>> {
14843 let mut store = AtlasStore::in_memory()?;
14844 store.replace_scan(&[test_file_node("src/main.rs", "hash")])?;
14845 let error = match store.resolve_health_finding(&HealthResolution {
14846 finding_id: "missing-id".to_string(),
14847 category: "duplicate-purpose".to_string(),
14848 path: "no/such/file.rs".to_string(),
14849 related_path: None,
14850 rationale: "typo".to_string(),
14851 }) {
14852 Ok(()) => {
14853 return Err(io::Error::other("nonexistent health finding should fail").into());
14854 }
14855 Err(error) => error,
14856 };
14857
14858 require_eq(
14859 &error.to_string().contains("not active"),
14860 &true,
14861 "inactive finding rejected",
14862 )?;
14863 Ok(())
14864 }
14865
14866 fn write_released_schema_eight_compatibility_fixture(
14868 db_path: &Path,
14869 root: &Path,
14870 ) -> Result<(), Box<dyn Error>> {
14871 write_schema_eight_compatibility_fixture(
14872 db_path,
14873 root,
14874 schema::create_released_schema_eight,
14875 )
14876 }
14877
14878 #[cfg(windows)]
14880 fn write_evolved_released_schema_eight_compatibility_fixture(
14881 db_path: &Path,
14882 root: &Path,
14883 ) -> Result<(), Box<dyn Error>> {
14884 write_schema_eight_compatibility_fixture(
14885 db_path,
14886 root,
14887 schema::create_evolved_released_schema_eight,
14888 )
14889 }
14890
14891 fn write_schema_eight_compatibility_fixture(
14893 db_path: &Path,
14894 root: &Path,
14895 create_schema: fn(&Connection) -> DbResult<()>,
14896 ) -> Result<(), Box<dyn Error>> {
14897 let connection = Connection::open(db_path)?;
14898 create_schema(&connection)?;
14899 schema::configure_writable(&connection)?;
14900 connection.execute_batch("BEGIN IMMEDIATE")?;
14901 let write_result = (|| -> DbResult<()> {
14902 set_metadata(
14903 &connection,
14904 PROJECT_ROOT_KEY,
14905 &normalize_native_path_display(root),
14906 )?;
14907 set_metadata(&connection, "custom_setting", "preserved")?;
14908 set_metadata(&connection, INDEX_PUBLICATION_STATE_KEY, "complete")?;
14909 set_metadata(
14910 &connection,
14911 INDEX_PUBLICATION_FINGERPRINT_KEY,
14912 "untrusted-contract",
14913 )?;
14914 set_metadata(&connection, INDEX_PUBLICATION_GENERATION_KEY, "7")?;
14915 connection.execute_batch(
14916 "
14917 INSERT INTO nodes(
14918 id, path, kind, parent_path, extension, language,
14919 size_bytes, mtime_ns, content_hash
14920 )
14921 VALUES(1, 'src/lib.rs', 'file', 'src', '.rs', 'rust', 12, 10, 'hash-legacy');
14922
14923 INSERT INTO purposes(node_id, purpose, source, status, updated_by)
14924 VALUES(1, 'Schema compatibility source', 'agent', 'approved', 'agent');
14925
14926 INSERT INTO summaries(node_id, summary_level, subject, summary)
14927 VALUES(1, 'node', '', 'released schema source');
14928
14929 INSERT INTO file_texts(path, content_hash, byte_count, line_count, content)
14930 VALUES('src/lib.rs', 'hash-legacy', 6, 1, 'legacy');
14931 ",
14932 )?;
14933 connection.execute(
14934 "
14935 INSERT INTO health_resolutions(
14936 finding_id, category, path, rationale
14937 )
14938 VALUES('schema-review', ?1, 'src/lib.rs', 'Reviewed schema fixture')
14939 ",
14940 [CATEGORY_DUPLICATE_PURPOSE],
14941 )?;
14942 record_released_schema_eight_usage_event(
14943 &connection,
14944 &usage_from_estimates(
14945 "schema-session",
14946 "summary",
14947 Some("src/lib.rs".to_string()),
14948 None,
14949 100,
14950 20,
14951 ),
14952 )
14953 })();
14954 match write_result {
14955 Ok(()) => connection.execute_batch("COMMIT")?,
14956 Err(error) => {
14957 if let Err(rollback) = connection.execute_batch("ROLLBACK") {
14958 return Err(DbError::TransactionRollback {
14959 operation: Box::new(error),
14960 rollback,
14961 }
14962 .into());
14963 }
14964 return Err(error.into());
14965 }
14966 }
14967 Ok(())
14968 }
14969
14970 fn write_schema_compatibility_fixture(
14972 db_path: &Path,
14973 root: &Path,
14974 schema_version: i64,
14975 label: &str,
14976 ) -> Result<(), Box<dyn Error>> {
14977 let mut store = AtlasStore::open(db_path)?;
14978 store.set_project_root(root)?;
14979 populate_schema_compatibility_fixture(&mut store, label)?;
14980 set_metadata(
14981 &store.connection,
14982 SCHEMA_VERSION_KEY,
14983 &schema_version.to_string(),
14984 )?;
14985 store
14986 .connection
14987 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
14988 Ok(())
14989 }
14990
14991 pub(crate) fn populate_schema_compatibility_fixture(
14993 store: &mut AtlasStore,
14994 label: &str,
14995 ) -> Result<(), Box<dyn Error>> {
14996 {
14997 let mut publication = store.begin_index_publication("untrusted-contract")?;
14998 write_test_projection(&mut publication, label)?;
14999 publication.complete()?;
15000 }
15001 store.set_purpose(
15002 "src/lib.rs",
15003 "Schema compatibility source",
15004 PurposeSource::Agent,
15005 )?;
15006 store.connection.execute(
15007 "
15008 INSERT INTO health_resolutions(
15009 finding_id,
15010 category,
15011 path,
15012 rationale
15013 )
15014 VALUES('schema-review', ?1, 'src/lib.rs', 'Reviewed schema fixture')
15015 ",
15016 [CATEGORY_DUPLICATE_PURPOSE],
15017 )?;
15018 store.record_usage(&usage_from_estimates(
15019 "schema-session",
15020 "summary",
15021 Some("src/lib.rs".to_string()),
15022 None,
15023 100,
15024 20,
15025 ))?;
15026 set_metadata(&store.connection, "custom_setting", "preserved")?;
15027 Ok(())
15028 }
15029
15030 fn test_file_node(path: &str, hash: &str) -> Node {
15032 Node {
15033 path: path.to_string(),
15034 kind: NodeKind::File,
15035 parent_path: normalized_parent(path),
15036 extension: Some(".rs".to_string()),
15037 language: Some("rust".to_string()),
15038 size_bytes: Some(12),
15039 mtime_ns: Some(10),
15040 content_hash: Some(hash.to_string()),
15041 }
15042 }
15043
15044 fn batch_test_symbol(
15046 path: &str,
15047 name: &str,
15048 line_start: usize,
15049 documentation_bytes: usize,
15050 ) -> CodeSymbol {
15051 CodeSymbol {
15052 path: path.to_string(),
15053 language: Some("rust".to_string()),
15054 name: name.to_string(),
15055 kind: SymbolKind::Function,
15056 signature: format!("fn {name}()"),
15057 exported: false,
15058 documentation: Some("d".repeat(documentation_bytes)),
15059 line_start,
15060 line_end: line_start.saturating_add(1),
15061 source_selector: None,
15062 parent: None,
15063 parser: ParserKind::TreeSitter,
15064 detail: Some("function_item".to_string()),
15065 }
15066 }
15067
15068 fn require_symbol_source_selectors(
15070 symbols: &[CodeSymbol],
15071 expected: Option<SymbolSourceSelector>,
15072 context: &str,
15073 ) -> Result<(), Box<dyn Error>> {
15074 let heading = symbols
15075 .iter()
15076 .find(|symbol| symbol.name == "Overview")
15077 .ok_or_else(|| io::Error::other(format!("{context} omitted the heading symbol")))?;
15078 require_eq(&heading.source_selector, &expected, context)?;
15079 if let Some(compatibility) = symbols.iter().find(|symbol| symbol.name == "Compatibility") {
15080 require_eq(
15081 &compatibility.source_selector,
15082 &None,
15083 &format!("{context} changed the selector-free symbol"),
15084 )?;
15085 }
15086 Ok(())
15087 }
15088
15089 fn verify_symbol_source_selector_read_paths(
15091 store: &AtlasStore,
15092 path: &str,
15093 expected: SymbolSourceSelector,
15094 ) -> Result<(), Box<dyn Error>> {
15095 require_symbol_source_selectors(
15096 &store.load_symbols(Some(path), None, 10)?,
15097 Some(expected),
15098 "symbol list",
15099 )?;
15100 require_symbol_source_selectors(
15101 &store.load_symbols_by_kinds(path, &[SymbolKind::Heading], 10)?,
15102 Some(expected),
15103 "kind-filtered symbols",
15104 )?;
15105 require_symbol_source_selectors(
15106 &store.load_symbols_by_names(&["Overview".to_string()])?,
15107 Some(expected),
15108 "name-filtered symbols",
15109 )?;
15110 let named = store
15111 .load_symbol_by_name(path, "Overview")?
15112 .ok_or_else(|| io::Error::other("exact-name symbol lookup omitted the heading"))?;
15113 require_eq(
15114 &named.source_selector,
15115 &Some(expected),
15116 "single exact-name symbol",
15117 )?;
15118 require_symbol_source_selectors(
15119 &store.load_symbols_by_exact_file_and_name(path, "Overview")?,
15120 Some(expected),
15121 "exact-file-and-name symbols",
15122 )?;
15123 require_symbol_source_selectors(
15124 &store
15125 .load_symbols_for_paths_bounded(
15126 &[path.to_string()],
15127 SymbolBatchReadBudget::new(1, 10, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
15128 None,
15129 )?
15130 .rows,
15131 Some(expected),
15132 "bounded exact-path symbols",
15133 )?;
15134 let classified =
15135 store.load_classified_symbols(Some(path), None, ContentSelection::Documentation, 10)?;
15136 require(
15137 classified
15138 .iter()
15139 .all(|row| row.classification == ContentClassification::Documentation),
15140 "classified symbol read changed the owning file classification",
15141 )?;
15142 require_symbol_source_selectors(
15143 &classified
15144 .into_iter()
15145 .map(|row| row.symbol)
15146 .collect::<Vec<_>>(),
15147 Some(expected),
15148 "classified symbols",
15149 )?;
15150 let graphs = store.load_symbol_graphs_for_paths(&[path.to_string()])?;
15151 require_eq(&graphs.len(), &1, "reconstructed graph count")?;
15152 require_symbol_source_selectors(
15153 &graphs[0].symbols,
15154 Some(expected),
15155 "reconstructed symbol graph",
15156 )?;
15157 Ok(())
15158 }
15159
15160 fn conditional_purpose_request(
15162 task: &str,
15163 candidate: &PurposeCurationCandidate,
15164 purpose: &str,
15165 ) -> PurposeConditionalApplyRequest {
15166 PurposeConditionalApplyRequest {
15167 task: task.to_string(),
15168 path: candidate.node.node.path.clone(),
15169 work_key: candidate.work_key.clone(),
15170 state_token: candidate.state_token.clone(),
15171 purpose: purpose.to_string(),
15172 }
15173 }
15174
15175 fn write_test_projection(store: &mut AtlasStore, label: &str) -> DbResult<()> {
15177 let path = "src/lib.rs";
15178 let hash = format!("hash-{label}");
15179 store.replace_scan(&[test_file_node(path, &hash)])?;
15180 store.replace_file_texts_for_paths(
15181 &[path.to_string()],
15182 &[IndexedFileText {
15183 path: path.to_string(),
15184 content_hash: Some(hash),
15185 byte_count: label.len(),
15186 line_count: 1,
15187 content: label.to_string(),
15188 }],
15189 )?;
15190 store.replace_symbol_graph(&SymbolGraph {
15191 path: path.to_string(),
15192 language: Some("rust".to_string()),
15193 parser: ParserKind::TreeSitter,
15194 symbols: vec![CodeSymbol {
15195 path: path.to_string(),
15196 language: Some("rust".to_string()),
15197 name: format!("{label}_symbol"),
15198 kind: SymbolKind::Function,
15199 signature: format!("fn {label}_symbol()"),
15200 exported: true,
15201 documentation: None,
15202 line_start: 1,
15203 line_end: 1,
15204 source_selector: None,
15205 parent: None,
15206 parser: ParserKind::TreeSitter,
15207 detail: Some("function_item".to_string()),
15208 }],
15209 relations: vec![SymbolRelation {
15210 path: path.to_string(),
15211 source_name: format!("{label}_symbol"),
15212 target_name: format!("{label}_target"),
15213 kind: RelationKind::Calls,
15214 line: 1,
15215 context: format!("{label}_target();"),
15216 parser: ParserKind::TreeSitter,
15217 }],
15218 })?;
15219 store.set_node_summary(path, &format!("{label} summary"))?;
15220 Ok(())
15221 }
15222
15223 fn require_test_projection(
15225 store: &AtlasStore,
15226 generation: u64,
15227 label: &str,
15228 ) -> Result<(), Box<dyn Error>> {
15229 let path = "src/lib.rs";
15230 let publication = store
15231 .index_publication()?
15232 .ok_or_else(|| io::Error::other("publication missing"))?;
15233 require_eq(
15234 &publication.generation,
15235 &IndexGeneration::new(generation),
15236 "publication generation",
15237 )?;
15238 let node = store
15239 .load_node_by_path(path)?
15240 .ok_or_else(|| io::Error::other("projection node missing"))?;
15241 require_eq(
15242 &node.node.content_hash,
15243 &Some(format!("hash-{label}")),
15244 "projection node hash",
15245 )?;
15246 require_eq(
15247 &node.summary,
15248 &Some(format!("{label} summary")),
15249 "projection node summary",
15250 )?;
15251 let text = store
15252 .load_file_text(path)?
15253 .ok_or_else(|| io::Error::other("projection text missing"))?;
15254 require_eq(&text.content, &label.to_string(), "projection text")?;
15255 let symbols = store.load_symbols(Some(path), None, 10)?;
15256 require_eq(
15257 &symbols.first().map(|symbol| symbol.name.as_str()),
15258 &Some(format!("{label}_symbol")).as_deref(),
15259 "projection symbol",
15260 )?;
15261 let relations = store.load_symbol_relations(Some(path), None, 10)?;
15262 require_eq(
15263 &relations
15264 .first()
15265 .map(|relation| relation.target_name.as_str()),
15266 &Some(format!("{label}_target")).as_deref(),
15267 "projection relation",
15268 )?;
15269 let metadata = store
15270 .load_source_parse_metadata(path)?
15271 .ok_or_else(|| io::Error::other("projection parse metadata missing"))?;
15272 require_eq(&metadata.symbol_count, &1, "projection symbol metadata")?;
15273 require_eq(&metadata.relation_count, &1, "projection relation metadata")?;
15274 Ok(())
15275 }
15276
15277 fn require_writable_connection_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
15279 let foreign_keys =
15280 connection.pragma_query_value(None, "foreign_keys", |row| row.get::<_, i64>(0))?;
15281 require_eq(&foreign_keys, &1, "writable foreign-key enforcement")?;
15282 require_wal_profile(connection)?;
15283 let synchronous =
15284 connection.pragma_query_value(None, "synchronous", |row| row.get::<_, i64>(0))?;
15285 require_eq(&synchronous, &2, "writable FULL synchronous mode")?;
15286 require_busy_timeout(connection)
15287 }
15288
15289 fn require_read_connection_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
15291 let query_only =
15292 connection.pragma_query_value(None, "query_only", |row| row.get::<_, i64>(0))?;
15293 require_eq(&query_only, &1, "read query-only mode")?;
15294 require_wal_profile(connection)?;
15295 require_busy_timeout(connection)
15296 }
15297
15298 fn require_wal_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
15300 let journal_mode =
15301 connection.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))?;
15302 require_eq(
15303 &journal_mode.to_ascii_lowercase(),
15304 &"wal".to_string(),
15305 "WAL journal mode",
15306 )
15307 }
15308
15309 fn require_busy_timeout(connection: &Connection) -> Result<(), Box<dyn Error>> {
15311 let busy_timeout =
15312 connection.pragma_query_value(None, "busy_timeout", |row| row.get::<_, i64>(0))?;
15313 require_eq(
15314 &u128::try_from(busy_timeout)?,
15315 &SQLITE_BUSY_TIMEOUT.as_millis(),
15316 "bounded connection busy timeout",
15317 )
15318 }
15319
15320 fn test_folder_node(path: &str) -> Node {
15322 Node {
15323 path: path.to_string(),
15324 kind: NodeKind::Folder,
15325 parent_path: normalized_parent(path),
15326 extension: None,
15327 language: None,
15328 size_bytes: None,
15329 mtime_ns: Some(10),
15330 content_hash: None,
15331 }
15332 }
15333
15334 fn low_query() -> HealthQuery {
15336 HealthQuery {
15337 start_index: 0,
15338 limit: 20,
15339 category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
15340 severity: Some(Severity::Warning),
15341 path_prefix: Some(".".to_string()),
15342 summary_only: false,
15343 scope: HealthScope::purpose_default(),
15344 }
15345 }
15346
15347 fn health_paths(page: &HealthFindingsPage) -> Vec<&str> {
15349 page.findings
15350 .iter()
15351 .map(|finding| finding.path.as_str())
15352 .collect()
15353 }
15354
15355 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
15357 if condition {
15358 Ok(())
15359 } else {
15360 Err(io::Error::other(message).into())
15361 }
15362 }
15363
15364 fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
15366 where
15367 T: Debug + PartialEq,
15368 {
15369 if actual == expected {
15370 Ok(())
15371 } else {
15372 Err(io::Error::other(format!(
15373 "{label} mismatch: expected {expected:?}, got {actual:?}"
15374 ))
15375 .into())
15376 }
15377 }
15378}