1mod derived_snapshot;
4mod diagnostics;
5mod project_identity;
6mod repository_graph;
7mod schema;
8mod sqlite_profile;
9mod telemetry;
10
11pub use derived_snapshot::{
12 DerivedGraphSnapshot, DerivedGraphSnapshotImport, DerivedGraphSnapshotMetadata,
13 DerivedSnapshotContent, MAX_DERIVED_SNAPSHOT_JSON_BYTES,
14};
15pub use diagnostics::{
16 DatabaseCoverageSample, DatabaseCoverageSummary, DatabaseCoverageTotalState,
17 DatabaseFilesystemSupport, DatabaseOperatingProfileReport, DatabasePublicationContractState,
18 DatabasePublicationReport, DatabaseSchemaCompatibility, DatabaseSchemaReport,
19 DatabaseSettingsReport, SqliteCompileOptionsIdentity, SqliteRuntimeReport,
20 database_settings_report,
21};
22pub use project_identity::{ProjectRootTransition, ProjectRootTransitionResult};
23pub use repository_graph::{
24 MAX_REPOSITORY_GRAPH_FRONTIER, RepositoryAffectedSourceFootprint, RepositoryCoverageQuery,
25 RepositoryCoverageRow, RepositoryGraphAdjacencyContinuation, RepositoryGraphAdjacencyPage,
26 RepositoryGraphAdjacencyReadPage, RepositoryGraphAdjacencyRow, RepositoryGraphDirection,
27 RepositoryGraphPage, RepositoryGraphReadBatch, RepositoryGraphReadBudget,
28 RepositoryGraphReadPage, RepositoryGraphReadPages, RepositoryGraphReadWork,
29 RepositoryGraphRelationQuery, RepositoryGraphRelationRow, RepositoryGraphStagingGuard,
30 RepositoryNavigationConnections, RepositoryNavigationNode, RepositoryResolutionCandidate,
31};
32pub use sqlite_profile::validate_database_location;
33pub use telemetry::{
34 PlannerStatisticsPolicy, PlannerStatisticsState, SpillCleanupState, TelemetryCheckpointState,
35 TelemetryRetentionPolicy, TelemetryRetentionState,
36};
37
38use blake3::Hasher;
39use projectatlas_core::graph::{GraphContractError, ProjectInstanceId};
40use projectatlas_core::health::{
41 CATEGORY_DUPLICATE_PURPOSE, CATEGORY_MISSING_PURPOSE, CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
42 CATEGORY_REPEATED_TEMPORARY_FOLDER, CATEGORY_STALE_PURPOSE, CATEGORY_SUGGESTED_PURPOSE_REVIEW,
43 HealthFinding, MESSAGE_MISSING_PURPOSE, MESSAGE_PURPOSE_AGENT_REVIEW_REQUIRED,
44 MESSAGE_STALE_PURPOSE, MESSAGE_SUGGESTED_PURPOSE_REVIEW, RECOMMENDATION_DUPLICATE_PURPOSE,
45 RECOMMENDATION_MISSING_PURPOSE_QUEUE, RECOMMENDATION_PURPOSE_AGENT_REVIEW_REQUIRED,
46 RECOMMENDATION_REPEATED_TEMPORARY_FOLDER, RECOMMENDATION_STALE_PURPOSE,
47 RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW_QUEUE, STRUCTURAL_HEALTH_CATEGORIES, Severity,
48 TEMP_FOLDER_BUCKETS, finding_id,
49};
50use projectatlas_core::symbols::{
51 CodeSymbol, ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
52 SymbolRelation,
53};
54use projectatlas_core::telemetry::{
55 TelemetryContractError, TokenOverview, TokenTrendReport, TokenTrendWindow, UsageEvent,
56 UsageInstanceId, UsageInstanceOwner,
57};
58use projectatlas_core::{
59 AGENT_REVIEWED_SOURCE_VALUES, HIGH_IMPACT_FILE_NAMES, HIGH_IMPACT_PATH_PREFIXES,
60 HIGH_IMPACT_PATH_SEGMENTS, IndexGeneration, IndexWorkControl, IndexWorkFailure, IndexWorkStage,
61 IndexedNode, LEGACY_HUMAN_PURPOSE_SOURCE, Node, NodeKind, Overview, Purpose, PurposeSource,
62 PurposeStatus, normalize_native_path_display, normalize_repo_path_prefix,
63};
64use rusqlite::types::Value;
65use rusqlite::{
66 Connection, ErrorCode, OpenFlags, OptionalExtension, TransactionBehavior, params,
67 params_from_iter,
68};
69use serde::{Deserialize, Serialize};
70use std::cell::{Cell, RefCell};
71use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
72use std::num::{ParseIntError, TryFromIntError};
73use std::ops::{Deref, DerefMut};
74use std::path::{Path, PathBuf};
75use std::time::Duration;
76use thiserror::Error;
77
78use schema::{
79 FILE_TEXT_FTS_PROJECTION_REVISION_KEY, FILE_TEXT_FTS_SOURCE_REVISION_KEY,
80 INDEX_PUBLICATION_FINGERPRINT_KEY, INDEX_PUBLICATION_GENERATION_KEY,
81 INDEX_PUBLICATION_STATE_KEY, PROJECT_ROOT_KEY, SchemaState,
82};
83#[cfg(test)]
84use schema::{PREVIOUS_SCHEMA_VERSION, SCHEMA_VERSION, SCHEMA_VERSION_KEY, sqlite_sidecar_path};
85use sqlite_profile::{
86 DatabaseLocation, JournalModePolicy, SQLITE_BUSY_TIMEOUT, open_writable_connection,
87};
88
89const MAX_SYMBOL_SEARCH_SUMMARY_CHARS: usize = 16_000;
91const SQLITE_PUBLICATION_ACQUIRE_TIMEOUT: Duration = Duration::ZERO;
93const SQLITE_TELEMETRY_BUSY_TIMEOUT: Duration = Duration::from_millis(25);
95pub const MAX_PURPOSE_CURATION_BATCH_ROWS: usize = 200;
97pub const MAX_FILE_TEXT_FTS_CANDIDATES: usize = 4_096;
99const FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL: &str = "
101 SELECT path, content_hash, byte_count, line_count
102 FROM file_texts
103 WHERE path = ?1 OR (path >= ?2 AND path < ?3)
104 ORDER BY path
105";
106const FILE_TEXT_FALLBACK_ALL_METADATA_SQL: &str = "
108 SELECT path, content_hash, byte_count, line_count
109 FROM file_texts
110 ORDER BY path
111";
112const FILE_TEXT_FALLBACK_CONTENT_SQL: &str = "SELECT content FROM file_texts WHERE path = ?1";
114const MAX_PURPOSE_CURATION_TASK_BYTES: usize = 256;
116const PURPOSE_CURATION_ITEM_KEY_DOMAIN: &str = "projectatlas:purpose-curation:item:v1";
118const PURPOSE_CURATION_STATE_TOKEN_DOMAIN: &str = "projectatlas:purpose-curation:state:v1";
120const PURPOSE_CURATION_BATCH_KEY_DOMAIN: &str = "projectatlas:purpose-curation:batch:v1";
122const AUTHORED_PURPOSE_REVISION_KEY: &str = "purpose.authored_revision";
124fn writable_open_flags(state: SchemaState, database_exists: bool) -> OpenFlags {
126 match (state, database_exists) {
127 (SchemaState::Fresh, false) => {
128 OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE
129 }
130 (SchemaState::Fresh | SchemaState::Current | SchemaState::UpgradeRequired, true)
131 | (SchemaState::Current | SchemaState::UpgradeRequired, false) => {
132 OpenFlags::SQLITE_OPEN_READ_WRITE
133 }
134 }
135}
136
137const fn writable_journal_policy(state: SchemaState) -> JournalModePolicy {
139 match state {
140 SchemaState::Current => JournalModePolicy::RequireWal,
141 SchemaState::Fresh | SchemaState::UpgradeRequired => JournalModePolicy::EnsureWal,
142 }
143}
144
145#[derive(Debug, Error)]
147pub enum DbError {
148 #[error("sqlite error: {0}")]
150 Sqlite(#[from] rusqlite::Error),
151 #[error(
153 "database path {path:?} is on an unsupported filesystem (mount: {mount_point:?}, type: {filesystem_type:?}); live SQLite WAL requires supported local storage"
154 )]
155 DatabaseFilesystemUnsupported {
156 path: PathBuf,
158 mount_point: Option<PathBuf>,
160 filesystem_type: Option<String>,
162 },
163 #[error(
165 "database path {path:?} has uncertain filesystem placement (mount: {mount_point:?}, type: {filesystem_type:?}): {reason}"
166 )]
167 DatabaseFilesystemUncertain {
168 path: PathBuf,
170 mount_point: Option<PathBuf>,
172 filesystem_type: Option<String>,
174 reason: String,
176 },
177 #[error("SQLite operating profile mismatch for {setting}: expected {expected}, found {found}")]
179 DatabaseOperatingProfile {
180 setting: &'static str,
182 expected: String,
184 found: String,
186 },
187 #[error("repository graph contract error: {0}")]
189 GraphContract(#[from] GraphContractError),
190 #[error("telemetry contract error: {0}")]
192 TelemetryContract(#[from] TelemetryContractError),
193 #[error(
195 "repository graph project identity {found} does not match selected identity {expected}"
196 )]
197 GraphProjectIdentityMismatch {
198 expected: String,
200 found: String,
202 },
203 #[error("repository graph is unavailable without a complete published generation")]
205 GraphPublicationUnavailable,
206 #[error("invalid {table} row: {reason}")]
208 GraphRowShape {
209 table: &'static str,
211 reason: &'static str,
213 },
214 #[error("invalid derived graph snapshot: {reason}")]
216 DerivedSnapshotInvalid {
217 reason: &'static str,
219 },
220 #[error(
222 "derived graph snapshot {resource} exceeds the limit: found {found}, maximum {maximum}"
223 )]
224 DerivedSnapshotLimit {
225 resource: &'static str,
227 found: u64,
229 maximum: u64,
231 },
232 #[error("derived graph snapshot I/O failed for {path:?}: {source}")]
234 DerivedSnapshotIo {
235 path: PathBuf,
237 #[source]
239 source: std::io::Error,
240 },
241 #[error("derived graph snapshot JSON is invalid: {0}")]
243 DerivedSnapshotJson(#[from] serde_json::Error),
244 #[error("invalid persisted symbol graph for {path:?}: {reason}")]
246 SymbolGraphRowShape {
247 path: String,
249 reason: &'static str,
251 },
252 #[error("resolution-key collision in {domain} for digest {digest:?}")]
254 ResolutionKeyCollision {
255 domain: &'static str,
257 digest: [u8; 32],
259 },
260 #[error("invalid {field} blob length {found}; expected {expected}")]
262 InvalidBlobLength {
263 field: &'static str,
265 expected: usize,
267 found: usize,
269 },
270 #[error("graph count for {field} exceeds SQLite integer range: {value}")]
272 GraphCountOverflow {
273 field: &'static str,
275 value: u64,
277 },
278 #[error("unsupported schema version {found}, expected {expected}")]
280 SchemaVersion {
281 found: i64,
283 expected: i64,
285 },
286 #[error("existing database is missing schema_version metadata")]
288 SchemaVersionMissing,
289 #[error("incompatible schema object {object:?}: expected {expected}, found {found}")]
291 SchemaShape {
292 object: String,
294 expected: String,
296 found: String,
298 },
299 #[error("database integrity check failed: {message}")]
301 IntegrityCheck {
302 message: String,
304 },
305 #[error("schema migration did not reach expected version {expected}")]
307 SchemaPostcondition {
308 expected: i64,
310 },
311 #[error("existing database is missing project_root metadata")]
313 ProjectRootMissing,
314 #[error("database project root {found:?} does not match selected root {expected:?}")]
316 ProjectRootMismatch {
317 expected: String,
319 found: String,
321 },
322 #[error("invalid project root transition destination {root:?}: {source}")]
324 ProjectRootDestinationInvalid {
325 root: String,
327 #[source]
329 source: std::io::Error,
330 },
331 #[error("project root transition requires an existing bound root")]
333 ProjectRootTransitionRequiresExistingRoot,
334 #[error("project root move destination {root:?} matches the existing root")]
336 ProjectRootTransitionRequiresDifferentRoot {
337 root: String,
339 },
340 #[error("project root {root:?} still exists; use detach for an independent copy")]
342 ProjectRootStillPresent {
343 root: String,
345 },
346 #[error("cannot prove project root {root:?} is absent: {source}")]
348 ProjectRootAbsenceUncertain {
349 root: String,
351 #[source]
353 source: std::io::Error,
354 },
355 #[error(
357 "project root transition state changed: root {expected_root:?} -> {found_root:?}, identity {expected_identity:?} -> {found_identity:?}"
358 )]
359 ProjectRootTransitionChanged {
360 expected_root: Option<String>,
362 found_root: Option<String>,
364 expected_identity: Option<String>,
366 found_identity: Option<String>,
368 },
369 #[error("bound project database is missing project instance identity")]
371 ProjectInstanceIdentityMissing,
372 #[error("failed to generate a distinct nonzero project instance identity")]
374 ProjectInstanceIdentityGenerationFailed,
375 #[error("{operation}; rollback also failed: {rollback}")]
377 TransactionRollback {
378 #[source]
380 operation: Box<DbError>,
381 rollback: rusqlite::Error,
383 },
384 #[error(
386 "publication writer acquisition failed: {operation}; restoring the standard busy policy also failed: {restore}"
387 )]
388 PublicationAcquirePolicyRestore {
389 #[source]
391 operation: Box<rusqlite::Error>,
392 restore: Box<rusqlite::Error>,
394 },
395 #[error("invalid {field} value in database: {value}")]
397 InvalidEnum {
398 field: &'static str,
400 value: String,
402 },
403 #[error("literal token cannot use FTS acceleration: {reason}")]
405 FileTextFtsTokenUnsafe {
406 reason: &'static str,
408 },
409 #[error("FTS candidate request {requested} exceeds the maximum {maximum}")]
411 FileTextFtsCandidateLimit {
412 requested: usize,
414 maximum: usize,
416 },
417 #[error("invalid FTS BM25 score for {path:?}")]
419 FileTextFtsScoreInvalid {
420 path: String,
422 },
423 #[error("invalid FTS synchronization state: {reason}")]
425 FileTextFtsStateInvalid {
426 reason: &'static str,
428 },
429 #[error("file text {path:?} has invalid {field}: recorded {recorded}, actual {actual}")]
431 FileTextMetadataMismatch {
432 path: String,
434 field: &'static str,
436 recorded: usize,
438 actual: usize,
440 },
441 #[error("{0}")]
443 IndexWork(#[from] IndexWorkFailure),
444 #[error("invalid count for {field}: {value}")]
446 InvalidCount {
447 field: &'static str,
449 value: i64,
451 source: TryFromIntError,
453 },
454 #[error("invalid integer metadata for {field}: {value:?}: {source}")]
456 InvalidInteger {
457 field: &'static str,
459 value: String,
461 source: ParseIntError,
463 },
464 #[error("integer metadata for {field} overflowed at {value}")]
466 IntegerMetadataOverflow {
467 field: &'static str,
469 value: u64,
471 },
472 #[error("path {path:?} is not indexed; run scan, fix the path, or choose an indexed path")]
474 PathNotIndexed {
475 path: String,
477 },
478 #[error("invalid purpose-curation task label: {reason}")]
480 PurposeCurationTaskInvalid {
481 reason: &'static str,
483 },
484 #[error("purpose-curation batch requested {requested} paths; maximum is {maximum}")]
486 PurposeCurationBatchTooLarge {
487 requested: usize,
489 maximum: usize,
491 },
492 #[error(
494 "health finding {finding_id:?} with category {category:?} and path {path:?} is not active; run health-check and use an exact finding id/path/category"
495 )]
496 HealthFindingNotActive {
497 finding_id: String,
499 category: String,
501 path: String,
503 },
504 #[error("index publication contract changed during projection refresh")]
506 PublicationContractChanged,
507 #[error("index publication base generation changed: expected {expected}, found {found}")]
509 PublicationBaseGenerationChanged {
510 expected: IndexGeneration,
512 found: IndexGeneration,
514 },
515 #[error("index publication generation overflowed")]
517 PublicationGenerationOverflow,
518 #[error("index publication cannot complete before scan replacement finishes")]
520 ScanReplacementIncomplete,
521 #[error("index read snapshot is already active on this store")]
523 IndexReadSnapshotActive,
524 #[error("read-only store has no database path for telemetry persistence")]
526 TelemetryPathUnavailable,
527 #[error("telemetry field {field} uses {bytes} UTF-8 bytes; limit is {limit}")]
529 TelemetryFieldTooLarge {
530 field: &'static str,
532 bytes: usize,
534 limit: usize,
536 },
537 #[error("invalid telemetry retention limit for {field}: {value}")]
539 TelemetryLimitInvalid {
540 field: &'static str,
542 value: usize,
544 },
545 #[error("telemetry integer overflow for {field}")]
547 TelemetryIntegerOverflow {
548 field: &'static str,
550 },
551 #[error("telemetry usage instance is sealed or expired")]
553 TelemetryInstanceInactive,
554 #[error("telemetry runtime identity is unavailable")]
556 TelemetryIdentityUnavailable,
557 #[error("telemetry runtime identity does not match its retained owner or caller label")]
559 TelemetryInstanceMismatch,
560 #[error("telemetry active-instance capacity is exhausted")]
562 TelemetryInstanceCapacity,
563 #[error("telemetry modeled-baseline capacity is exhausted")]
565 TelemetryBaselineCapacity,
566 #[error("telemetry modeled-baseline key collided with different witness material")]
568 TelemetryBaselineCollision,
569}
570
571impl DbError {
572 #[must_use]
575 pub fn is_write_unavailable(&self) -> bool {
576 match self {
577 Self::Sqlite(error) => matches!(
578 error.sqlite_error_code(),
579 Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked | ErrorCode::ReadOnly)
580 ),
581 _ => false,
582 }
583 }
584}
585
586pub type DbResult<T> = Result<T, DbError>;
588
589pub const MAX_SYMBOL_BATCH_PATHS: u32 = 64;
591pub const MAX_SYMBOL_BATCH_ROWS: u32 = 4_096;
593pub const MAX_SYMBOL_BATCH_DECODED_BYTES: u64 = 4 * 1_024 * 1_024;
595const SYMBOL_BATCH_BIND_PATHS: usize = 48;
597
598#[derive(Clone, Copy, Debug, Eq, PartialEq)]
600pub struct SymbolBatchReadBudget {
601 paths: u32,
603 rows: u32,
605 decoded_bytes: u64,
607}
608
609impl SymbolBatchReadBudget {
610 pub fn new(paths: u32, rows: u32, decoded_bytes: u64) -> DbResult<Self> {
616 if paths == 0
617 || paths > MAX_SYMBOL_BATCH_PATHS
618 || rows == 0
619 || rows > MAX_SYMBOL_BATCH_ROWS
620 || decoded_bytes == 0
621 || decoded_bytes > MAX_SYMBOL_BATCH_DECODED_BYTES
622 {
623 return Err(GraphContractError::InvalidLimits {
624 reason: "symbol batch limit is zero or above the product ceiling",
625 }
626 .into());
627 }
628 Ok(Self {
629 paths,
630 rows,
631 decoded_bytes,
632 })
633 }
634
635 #[must_use]
637 pub const fn paths(self) -> u32 {
638 self.paths
639 }
640
641 #[must_use]
643 pub const fn rows(self) -> u32 {
644 self.rows
645 }
646
647 #[must_use]
649 pub const fn decoded_bytes(self) -> u64 {
650 self.decoded_bytes
651 }
652}
653
654#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
656pub struct SymbolBatchReadWork {
657 pub requested_paths: u32,
659 pub returned_rows: u32,
661 pub decoded_bytes: u64,
663}
664
665#[derive(Clone, Debug, Default, Eq, PartialEq)]
667pub struct SymbolBatchRead {
668 pub rows: Vec<CodeSymbol>,
670 pub truncated: bool,
672 pub reached_limit: Option<SymbolBatchReadLimit>,
674 pub work: SymbolBatchReadWork,
676}
677
678#[derive(Clone, Copy, Debug, Eq, PartialEq)]
680pub enum SymbolBatchReadLimit {
681 Paths,
683 Rows,
685 DecodedBytes,
687}
688
689#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
691#[serde(rename_all = "snake_case")]
692pub enum IndexPublicationState {
693 Updating,
695 Complete,
697}
698
699impl IndexPublicationState {
700 const fn as_str(self) -> &'static str {
702 match self {
703 Self::Updating => "updating",
704 Self::Complete => "complete",
705 }
706 }
707
708 fn from_db(value: String) -> DbResult<Self> {
710 match value.as_str() {
711 "updating" => Ok(Self::Updating),
712 "complete" => Ok(Self::Complete),
713 _ => Err(DbError::InvalidEnum {
714 field: INDEX_PUBLICATION_STATE_KEY,
715 value,
716 }),
717 }
718 }
719}
720
721#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
723pub struct IndexPublication {
724 pub state: IndexPublicationState,
726 pub contract_fingerprint: Option<String>,
728 pub generation: IndexGeneration,
730}
731
732enum PublicationContract {
734 Full(String),
736 Projection(String),
738}
739
740pub struct IndexPublicationGuard<'store> {
742 store: &'store mut AtlasStore,
744 contract: PublicationContract,
746 previous_generation: IndexGeneration,
748 scan_replacement_pending: bool,
750 active: bool,
752}
753
754pub struct AtlasStore {
756 connection: Connection,
758 read_snapshot_active: Cell<bool>,
760 database_path: Option<PathBuf>,
762 database_location: Option<DatabaseLocation>,
764 read_only: bool,
766 validated_project_root: Option<String>,
768 validated_project_instance_id: Option<ProjectInstanceId>,
770 library_usage_instances: RefCell<HashMap<String, Option<UsageInstanceId>>>,
772}
773
774#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
776pub struct CapturedProjectBinding {
777 pub project_instance_id: ProjectInstanceId,
779 pub project_root: String,
781}
782
783#[derive(Clone, Debug, Eq, PartialEq)]
785pub struct StoredImportRelation {
786 pub path: String,
788 pub source_name: String,
790 pub target_name: String,
792 pub line: usize,
794}
795
796#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
798pub struct PurposeCurationCandidate {
799 pub node: IndexedNode,
801 pub work_key: String,
803 pub state_token: String,
805}
806
807#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
809pub struct PurposeCurationBatch {
810 pub project_instance_id: ProjectInstanceId,
812 pub active_generation: IndexGeneration,
814 pub task: String,
816 pub work_key: String,
818 pub items: Vec<PurposeCurationCandidate>,
820}
821
822#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
824#[serde(rename_all = "snake_case")]
825pub enum PurposeConditionalApplyState {
826 Applied,
828 Stale,
830 Accepted,
832 PathUnavailable,
834}
835
836#[derive(Clone, Debug, Eq, PartialEq)]
838pub struct PurposeConditionalApplyRequest {
839 pub task: String,
841 pub path: String,
843 pub work_key: String,
845 pub state_token: String,
847 pub purpose: String,
849}
850
851#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
853pub struct PurposeConditionalApplyResult {
854 pub path: String,
856 pub state: PurposeConditionalApplyState,
858 pub current_purpose: Option<Purpose>,
860}
861
862#[derive(Clone, Copy, Debug, Eq, PartialEq)]
864enum ProjectIdentityRequirement {
865 Required,
867 TransitionOwned,
869}
870
871impl ProjectIdentityRequirement {
872 const fn is_required(self) -> bool {
874 matches!(self, Self::Required)
875 }
876}
877
878impl Deref for IndexPublicationGuard<'_> {
879 type Target = AtlasStore;
880
881 fn deref(&self) -> &Self::Target {
882 self.store
883 }
884}
885
886impl DerefMut for IndexPublicationGuard<'_> {
887 fn deref_mut(&mut self) -> &mut Self::Target {
888 self.store
889 }
890}
891
892impl IndexPublicationGuard<'_> {
893 pub fn begin_scan_replacement(&mut self) -> DbResult<()> {
903 mark_all_scan_nodes_absent(&self.store.connection)?;
904 self.scan_replacement_pending = true;
905 Ok(())
906 }
907
908 pub fn upsert_scan_node_batch(&mut self, nodes: &[Node]) -> DbResult<()> {
914 upsert_nodes(&self.store.connection, nodes)
915 }
916
917 pub fn finish_scan_replacement(&mut self) -> DbResult<()> {
923 delete_absent_scan_projections(&self.store.connection)?;
924 self.scan_replacement_pending = false;
925 Ok(())
926 }
927
928 pub fn complete(mut self) -> DbResult<()> {
936 if self.scan_replacement_pending {
937 return Err(DbError::ScanReplacementIncomplete);
938 }
939 let next_generation = self
940 .previous_generation
941 .checked_next()
942 .ok_or(DbError::PublicationGenerationOverflow)?;
943 match &self.contract {
944 PublicationContract::Full(contract_fingerprint) => {
945 set_metadata(
946 &self.store.connection,
947 INDEX_PUBLICATION_FINGERPRINT_KEY,
948 contract_fingerprint,
949 )?;
950 }
951 PublicationContract::Projection(contract_fingerprint) => {
952 let matches =
953 load_index_publication(&self.store.connection)?.is_some_and(|publication| {
954 publication.state == IndexPublicationState::Updating
955 && publication.contract_fingerprint.as_deref()
956 == Some(contract_fingerprint.as_str())
957 && publication.generation == self.previous_generation
958 });
959 if !matches {
960 return Err(DbError::PublicationContractChanged);
961 }
962 }
963 }
964 set_metadata(
965 &self.store.connection,
966 INDEX_PUBLICATION_GENERATION_KEY,
967 &next_generation.to_string(),
968 )?;
969 set_metadata(
970 &self.store.connection,
971 INDEX_PUBLICATION_STATE_KEY,
972 IndexPublicationState::Complete.as_str(),
973 )?;
974 self.store.connection.execute_batch("COMMIT")?;
975 self.active = false;
976 Ok(())
977 }
978}
979
980impl Drop for IndexPublicationGuard<'_> {
981 fn drop(&mut self) {
982 if self.active {
983 let _rollback_result = self.store.connection.execute_batch("ROLLBACK");
984 }
985 }
986}
987
988#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
990pub struct IndexedFileText {
991 pub path: String,
993 pub content_hash: Option<String>,
995 pub byte_count: usize,
997 pub line_count: usize,
999 pub content: String,
1001}
1002
1003#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1005pub struct FileTextFtsQuery<'query> {
1006 pub literal_token: &'query str,
1008 pub path_prefix: Option<&'query str>,
1010 pub limit: usize,
1012}
1013
1014#[derive(Clone, Debug, PartialEq)]
1016pub struct FileTextFtsCandidate {
1017 pub path: String,
1019 pub content_hash: Option<String>,
1021 pub byte_count: usize,
1023 pub line_count: usize,
1025 pub bm25: f64,
1027}
1028
1029#[derive(Clone, Debug, PartialEq)]
1031pub struct FileTextFtsPage {
1032 pub candidates: Vec<FileTextFtsCandidate>,
1034 pub overflow: bool,
1036}
1037
1038#[derive(Clone, Debug, Eq, PartialEq)]
1040pub struct FileTextMetadata {
1041 pub path: String,
1043 pub content_hash: Option<String>,
1045 pub byte_count: usize,
1047 pub line_count: usize,
1049}
1050
1051#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1053pub enum FileTextAdmission {
1054 Read,
1056 Skip,
1058 Stop,
1060}
1061
1062#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1064pub struct FileTextFtsState {
1065 pub source_rows: usize,
1067 pub indexed_rows: usize,
1069 pub synchronized: bool,
1071}
1072
1073#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1075pub struct HealthResolution {
1076 pub finding_id: String,
1078 pub category: String,
1080 pub path: String,
1082 pub related_path: Option<String>,
1084 pub rationale: String,
1086}
1087
1088#[derive(Clone, Debug, Eq, PartialEq)]
1090pub struct HealthQuery {
1091 pub start_index: usize,
1093 pub limit: usize,
1095 pub category: Option<String>,
1097 pub severity: Option<Severity>,
1099 pub path_prefix: Option<String>,
1101 pub summary_only: bool,
1103 pub scope: HealthScope,
1105}
1106
1107#[derive(Clone, Copy)]
1109enum HealthResolutionFilter<'a> {
1110 Explicit(&'a [String]),
1112 Stored,
1114}
1115
1116#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1118pub enum HealthScope {
1119 All,
1121 SourceOnly,
1123 PurposeDefault,
1125 PurposeWithAssets,
1127 PurposeWithSourceFiles,
1129 PurposeStrict,
1131}
1132
1133impl HealthScope {
1134 pub fn all() -> Self {
1136 Self::All
1137 }
1138
1139 pub fn source_only() -> Self {
1141 Self::SourceOnly
1142 }
1143
1144 pub fn purpose_default() -> Self {
1146 Self::PurposeDefault
1147 }
1148
1149 pub fn purpose_with_assets() -> Self {
1151 Self::PurposeWithAssets
1152 }
1153
1154 pub fn purpose_with_source_files() -> Self {
1156 Self::PurposeWithSourceFiles
1157 }
1158
1159 pub fn purpose_strict() -> Self {
1161 Self::PurposeStrict
1162 }
1163
1164 pub fn is_source_focused(self) -> bool {
1166 self.source_only_filter()
1167 }
1168
1169 pub fn is_purpose_queue(self) -> bool {
1171 self.high_impact_queue()
1172 }
1173
1174 fn source_only_filter(self) -> bool {
1176 matches!(
1177 self,
1178 Self::SourceOnly | Self::PurposeDefault | Self::PurposeWithSourceFiles
1179 )
1180 }
1181
1182 fn high_impact_queue(self) -> bool {
1184 matches!(
1185 self,
1186 Self::PurposeDefault
1187 | Self::PurposeWithAssets
1188 | Self::PurposeWithSourceFiles
1189 | Self::PurposeStrict
1190 )
1191 }
1192
1193 fn include_assets(self) -> bool {
1195 matches!(self, Self::PurposeWithAssets)
1196 }
1197
1198 fn include_source_files(self) -> bool {
1200 matches!(self, Self::PurposeWithSourceFiles | Self::PurposeStrict)
1201 }
1202
1203 fn include_all_files(self) -> bool {
1205 matches!(self, Self::PurposeStrict)
1206 }
1207}
1208
1209#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1211pub struct HealthFindingsPage {
1212 pub total: usize,
1214 pub unfiltered_total: usize,
1216 pub returned: usize,
1218 pub start_index: usize,
1220 pub limit: usize,
1222 pub findings: Vec<HealthFinding>,
1224}
1225
1226#[derive(Clone, Copy, Debug)]
1228struct PurposeHealthSpec {
1229 status: &'static str,
1231 category: &'static str,
1233 message: &'static str,
1235 recommendation: &'static str,
1237}
1238
1239const PURPOSE_HEALTH_SPECS: [PurposeHealthSpec; 3] = [
1241 PurposeHealthSpec {
1242 status: PurposeStatus::Missing.as_str(),
1243 category: CATEGORY_MISSING_PURPOSE,
1244 message: MESSAGE_MISSING_PURPOSE,
1245 recommendation: RECOMMENDATION_MISSING_PURPOSE_QUEUE,
1246 },
1247 PurposeHealthSpec {
1248 status: PurposeStatus::Suggested.as_str(),
1249 category: CATEGORY_SUGGESTED_PURPOSE_REVIEW,
1250 message: MESSAGE_SUGGESTED_PURPOSE_REVIEW,
1251 recommendation: RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW_QUEUE,
1252 },
1253 PurposeHealthSpec {
1254 status: PurposeStatus::Stale.as_str(),
1255 category: CATEGORY_STALE_PURPOSE,
1256 message: MESSAGE_STALE_PURPOSE,
1257 recommendation: RECOMMENDATION_STALE_PURPOSE,
1258 },
1259];
1260
1261impl AtlasStore {
1262 fn require_mutation_scope(&self) -> DbResult<()> {
1264 if self.read_snapshot_active.get() {
1265 Err(DbError::IndexReadSnapshotActive)
1266 } else {
1267 Ok(())
1268 }
1269 }
1270
1271 fn validated_savepoint(&mut self) -> DbResult<rusqlite::Savepoint<'_>> {
1273 self.require_mutation_scope()?;
1274 let validate_binding = self.connection.is_autocommit();
1275 let expected_root = self.validated_project_root.clone();
1276 let expected_identity = self.validated_project_instance_id;
1277 let savepoint = self.connection.savepoint()?;
1278 if validate_binding {
1279 schema::validate_active_binding(
1280 &savepoint,
1281 expected_root.as_deref(),
1282 expected_identity,
1283 )?;
1284 }
1285 Ok(savepoint)
1286 }
1287
1288 fn with_validated_write<T>(
1290 &self,
1291 operation: impl FnOnce(&Connection) -> DbResult<T>,
1292 ) -> DbResult<T> {
1293 self.require_mutation_scope()?;
1294 if !self.connection.is_autocommit() {
1295 return operation(&self.connection);
1296 }
1297 let transaction =
1298 rusqlite::Transaction::new_unchecked(&self.connection, TransactionBehavior::Immediate)?;
1299 let result = schema::validate_active_binding(
1300 &transaction,
1301 self.validated_project_root.as_deref(),
1302 self.validated_project_instance_id,
1303 )
1304 .and_then(|()| operation(&transaction));
1305 match result {
1306 Ok(value) => {
1307 transaction.commit()?;
1308 Ok(value)
1309 }
1310 Err(operation) => match transaction.rollback() {
1311 Ok(()) => Err(operation),
1312 Err(rollback) => Err(DbError::TransactionRollback {
1313 operation: Box::new(operation),
1314 rollback,
1315 }),
1316 },
1317 }
1318 }
1319
1320 fn with_telemetry_connection<T>(
1327 &self,
1328 operation: impl FnOnce(&Connection) -> DbResult<T>,
1329 ) -> DbResult<T> {
1330 if self.read_only {
1331 self.finish_index_read_snapshot()?;
1332 } else {
1333 self.require_mutation_scope()?;
1334 }
1335 let (Some(path), Some(location)) =
1336 (self.database_path.as_ref(), self.database_location.as_ref())
1337 else {
1338 if self.database_path.is_none() && self.database_location.is_none() {
1339 return operation(&self.connection);
1340 }
1341 return Err(DbError::TelemetryPathUnavailable);
1342 };
1343 let connection = open_writable_connection(
1344 path,
1345 OpenFlags::SQLITE_OPEN_READ_WRITE,
1346 location,
1347 SQLITE_TELEMETRY_BUSY_TIMEOUT,
1348 JournalModePolicy::RequireWal,
1349 )?;
1350 operation(&connection)
1351 }
1352
1353 pub fn open(path: &Path) -> DbResult<Self> {
1359 Self::open_with_project_root(path, None)
1360 }
1361
1362 pub fn open_for_project(path: &Path, root: &Path) -> DbResult<Self> {
1373 let expected_root = normalize_native_path_display(root);
1374 Self::open_with_project_root(path, Some(&expected_root))
1375 }
1376
1377 fn open_with_project_root(path: &Path, expected_root: Option<&str>) -> DbResult<Self> {
1379 Self::open_with_binding_requirement(
1380 path,
1381 expected_root,
1382 ProjectIdentityRequirement::Required,
1383 )
1384 }
1385
1386 fn open_for_root_transition(path: &Path) -> DbResult<Self> {
1388 Self::open_with_binding_requirement(path, None, ProjectIdentityRequirement::TransitionOwned)
1389 }
1390
1391 fn open_with_binding_requirement(
1393 path: &Path,
1394 expected_root: Option<&str>,
1395 identity_requirement: ProjectIdentityRequirement,
1396 ) -> DbResult<Self> {
1397 let (preflight, location) = schema::preflight(path, expected_root)?;
1398 let validated_project_root = expected_root.map(str::to_owned).or(preflight.project_root);
1399 let connection = open_writable_connection(
1400 path,
1401 writable_open_flags(preflight.state, location.database_exists),
1402 &location,
1403 SQLITE_BUSY_TIMEOUT,
1404 writable_journal_policy(preflight.state),
1405 )?;
1406 let validated_project_instance_id = if preflight.state == SchemaState::Current {
1407 schema::revalidate_current_binding(
1408 &connection,
1409 validated_project_root.as_deref(),
1410 identity_requirement.is_required(),
1411 )?
1412 } else {
1413 schema::initialize(&connection, expected_root)?;
1414 project_identity::load_project_identity(&connection)?
1415 };
1416 if identity_requirement.is_required()
1417 && validated_project_root.is_some()
1418 && validated_project_instance_id.is_none()
1419 {
1420 return Err(DbError::ProjectInstanceIdentityMissing);
1421 }
1422 let database_location = if location.database_exists {
1423 location
1424 } else {
1425 sqlite_profile::inspect_database_location(path)?
1426 };
1427 Ok(Self {
1428 connection,
1429 read_snapshot_active: Cell::new(false),
1430 database_path: Some(path.to_path_buf()),
1431 database_location: Some(database_location),
1432 read_only: false,
1433 validated_project_root,
1434 validated_project_instance_id,
1435 library_usage_instances: RefCell::new(HashMap::new()),
1436 })
1437 }
1438
1439 pub fn open_read_only(path: &Path) -> DbResult<Self> {
1446 Self::open_read_only_with_project_root(path, None)
1447 }
1448
1449 pub fn open_read_only_for_project(path: &Path, root: &Path) -> DbResult<Self> {
1456 let expected_root = normalize_native_path_display(root);
1457 Self::open_read_only_with_project_root(path, Some(&expected_root))
1458 }
1459
1460 #[must_use]
1462 pub const fn is_read_only(&self) -> bool {
1463 self.read_only
1464 }
1465
1466 #[must_use]
1468 pub fn has_active_read_snapshot(&self) -> bool {
1469 self.read_snapshot_active.get()
1470 }
1471
1472 fn open_read_only_with_project_root(
1474 path: &Path,
1475 expected_root: Option<&str>,
1476 ) -> DbResult<Self> {
1477 let (connection, preflight) = schema::open_current_read_only(path, expected_root)?;
1478 let validated_project_instance_id = project_identity::load_project_identity(&connection)?;
1479 schema::validate_binding_completeness(
1480 preflight.project_root.as_deref(),
1481 validated_project_instance_id,
1482 true,
1483 )?;
1484 let database_location = sqlite_profile::inspect_database_location(path)?;
1485 Ok(Self {
1486 connection,
1487 read_snapshot_active: Cell::new(true),
1488 database_path: Some(path.to_path_buf()),
1489 database_location: Some(database_location),
1490 read_only: true,
1491 validated_project_root: preflight.project_root,
1492 validated_project_instance_id,
1493 library_usage_instances: RefCell::new(HashMap::new()),
1494 })
1495 }
1496
1497 pub fn in_memory() -> DbResult<Self> {
1503 let store = Self {
1504 connection: Connection::open_in_memory()?,
1505 read_snapshot_active: Cell::new(false),
1506 database_path: None,
1507 database_location: None,
1508 read_only: false,
1509 validated_project_root: None,
1510 validated_project_instance_id: None,
1511 library_usage_instances: RefCell::new(HashMap::new()),
1512 };
1513 schema::initialize(&store.connection, None)?;
1514 Ok(store)
1515 }
1516
1517 pub fn initialize_schema(&self) -> DbResult<()> {
1523 schema::initialize(&self.connection, None)
1524 }
1525
1526 pub fn replace_scan(&mut self, nodes: &[Node]) -> DbResult<()> {
1532 let savepoint = self.validated_savepoint()?;
1533 mark_all_scan_nodes_absent(&savepoint)?;
1534 upsert_nodes(&savepoint, nodes)?;
1535 delete_absent_scan_projections(&savepoint)?;
1536 savepoint.commit()?;
1537 Ok(())
1538 }
1539
1540 pub fn upsert_scan_nodes(&mut self, nodes: &[Node]) -> DbResult<()> {
1546 let savepoint = self.validated_savepoint()?;
1547 upsert_nodes(&savepoint, nodes)?;
1548 savepoint.commit()?;
1549 Ok(())
1550 }
1551
1552 pub fn mark_paths_absent(&mut self, paths: &[String]) -> DbResult<()> {
1558 let savepoint = self.validated_savepoint()?;
1559 let fts_revision = begin_file_text_fts_update(&savepoint)?;
1560 {
1561 let mut mark_nodes = savepoint.prepare_cached(
1562 "UPDATE nodes SET exists_now = 0 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
1563 )?;
1564 let mut delete_relations = savepoint.prepare_cached(
1565 "DELETE FROM symbol_relations WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
1566 )?;
1567 let mut delete_symbols = savepoint.prepare_cached(
1568 "DELETE FROM symbols WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
1569 )?;
1570 let mut delete_parse_metadata = savepoint.prepare_cached(
1571 "DELETE FROM source_parse_metadata WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
1572 )?;
1573 let mut delete_text_fts = savepoint.prepare_cached(
1574 "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
1575 SELECT 'delete', rowid, content FROM file_texts \
1576 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
1577 )?;
1578 let mut delete_text = savepoint.prepare_cached(
1579 "DELETE FROM file_texts WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
1580 )?;
1581 for path in paths {
1582 if path == "." || path.is_empty() {
1583 continue;
1584 }
1585 let descendant_pattern = sqlite_descendant_pattern(path);
1586 mark_nodes.execute(params![path, descendant_pattern])?;
1587 delete_relations.execute(params![path, descendant_pattern])?;
1588 delete_symbols.execute(params![path, descendant_pattern])?;
1589 delete_parse_metadata.execute(params![path, descendant_pattern])?;
1590 delete_text_fts.execute(params![path, descendant_pattern])?;
1591 delete_text.execute(params![path, descendant_pattern])?;
1592 }
1593 }
1594 complete_file_text_fts_update(&savepoint, fts_revision)?;
1595 savepoint.commit()?;
1596 Ok(())
1597 }
1598
1599 pub fn replace_file_texts_for_paths<'text>(
1609 &mut self,
1610 paths: &[String],
1611 texts: impl IntoIterator<Item = &'text IndexedFileText>,
1612 ) -> DbResult<()> {
1613 let texts = texts.into_iter().collect::<Vec<_>>();
1614 for text in &texts {
1615 validate_indexed_file_text(text)?;
1616 }
1617 let savepoint = self.validated_savepoint()?;
1618 let fts_revision = begin_file_text_fts_update(&savepoint)?;
1619 {
1620 let mut delete_fts = savepoint.prepare_cached(
1621 "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
1622 SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
1623 )?;
1624 let mut delete = savepoint.prepare_cached("DELETE FROM file_texts WHERE path = ?1")?;
1625 for path in paths {
1626 delete_fts.execute([path])?;
1627 delete.execute([path])?;
1628 }
1629 }
1630 {
1631 let mut delete_current_fts = savepoint.prepare_cached(
1632 "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
1633 SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
1634 )?;
1635 let mut upsert = savepoint.prepare_cached(
1636 "
1637 INSERT INTO file_texts(path, content_hash, byte_count, line_count, content, updated_at)
1638 VALUES(?1, ?2, ?3, ?4, ?5, CURRENT_TIMESTAMP)
1639 ON CONFLICT(path) DO UPDATE SET
1640 content_hash = excluded.content_hash,
1641 byte_count = excluded.byte_count,
1642 line_count = excluded.line_count,
1643 content = excluded.content,
1644 updated_at = CURRENT_TIMESTAMP
1645 ",
1646 )?;
1647 let mut index = savepoint.prepare_cached(
1648 "INSERT INTO file_text_fts(rowid, content) \
1649 SELECT rowid, content FROM file_texts WHERE path = ?1",
1650 )?;
1651 for text in texts {
1652 delete_current_fts.execute([&text.path])?;
1653 upsert.execute(params![
1654 text.path,
1655 text.content_hash.as_deref(),
1656 usize_to_i64(text.byte_count),
1657 usize_to_i64(text.line_count),
1658 text.content
1659 ])?;
1660 index.execute([&text.path])?;
1661 }
1662 }
1663 complete_file_text_fts_update(&savepoint, fts_revision)?;
1664 savepoint.commit()?;
1665 Ok(())
1666 }
1667
1668 pub fn load_file_text(&self, path: &str) -> DbResult<Option<IndexedFileText>> {
1674 let mut statement = self.connection.prepare(
1675 "
1676 SELECT path, content_hash, byte_count, line_count, content
1677 FROM file_texts
1678 WHERE path = ?1
1679 ",
1680 )?;
1681 let mut rows = statement.query([path])?;
1682 rows.next()?.map(file_text_from_row).transpose()
1683 }
1684
1685 pub fn query_file_text_fts_candidates(
1697 &self,
1698 query: &FileTextFtsQuery<'_>,
1699 control: Option<&IndexWorkControl>,
1700 ) -> DbResult<FileTextFtsPage> {
1701 validate_file_text_fts_token(query.literal_token)?;
1702 if query.limit > MAX_FILE_TEXT_FTS_CANDIDATES {
1703 return Err(DbError::FileTextFtsCandidateLimit {
1704 requested: query.limit,
1705 maximum: MAX_FILE_TEXT_FTS_CANDIDATES,
1706 });
1707 }
1708 let match_expression = format!("\"{}\"", query.literal_token);
1709 let row_limit = usize_to_i64(query.limit.saturating_add(1));
1710 with_file_text_progress(&self.connection, control, || {
1711 let mut candidates = Vec::with_capacity(query.limit.saturating_add(1));
1712 if let Some(path_prefix) = normalized_file_text_path_prefix(query.path_prefix) {
1713 let descendant_pattern = sqlite_descendant_pattern(path_prefix);
1714 let mut statement = self.connection.prepare_cached(
1715 "
1716 SELECT
1717 f.path,
1718 f.content_hash,
1719 f.byte_count,
1720 f.line_count,
1721 bm25(file_text_fts)
1722 FROM file_text_fts
1723 JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
1724 WHERE file_text_fts MATCH ?1
1725 AND (f.path = ?2 OR f.path LIKE ?3 ESCAPE '\\')
1726 ORDER BY bm25(file_text_fts), f.path
1727 LIMIT ?4
1728 ",
1729 )?;
1730 let mut rows = statement.query(params![
1731 match_expression,
1732 path_prefix,
1733 descendant_pattern,
1734 row_limit,
1735 ])?;
1736 collect_file_text_fts_candidates(&mut rows, control, &mut candidates)?;
1737 } else {
1738 let mut statement = self.connection.prepare_cached(
1739 "
1740 SELECT
1741 f.path,
1742 f.content_hash,
1743 f.byte_count,
1744 f.line_count,
1745 bm25(file_text_fts)
1746 FROM file_text_fts
1747 JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
1748 WHERE file_text_fts MATCH ?1
1749 ORDER BY bm25(file_text_fts), f.path
1750 LIMIT ?2
1751 ",
1752 )?;
1753 let mut rows = statement.query(params![match_expression, row_limit])?;
1754 collect_file_text_fts_candidates(&mut rows, control, &mut candidates)?;
1755 }
1756 let overflow = candidates.len() > query.limit;
1757 candidates.truncate(query.limit);
1758 Ok(FileTextFtsPage {
1759 candidates,
1760 overflow,
1761 })
1762 })
1763 }
1764
1765 pub fn visit_file_texts_for_fallback<A, V>(
1777 &self,
1778 path_prefix: Option<&str>,
1779 control: Option<&IndexWorkControl>,
1780 mut admit: A,
1781 mut visitor: V,
1782 ) -> DbResult<()>
1783 where
1784 A: FnMut(&FileTextMetadata) -> DbResult<FileTextAdmission>,
1785 V: FnMut(IndexedFileText) -> DbResult<bool>,
1786 {
1787 with_file_text_progress(&self.connection, control, || {
1788 let mut content_statement = self
1789 .connection
1790 .prepare_cached(FILE_TEXT_FALLBACK_CONTENT_SQL)?;
1791 if let Some(path_prefix) = normalized_file_text_path_prefix(path_prefix) {
1792 let (descendant_start, descendant_end) = file_text_descendant_range(path_prefix);
1793 let mut statement = self
1794 .connection
1795 .prepare_cached(FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL)?;
1796 let mut rows =
1797 statement.query(params![path_prefix, descendant_start, descendant_end])?;
1798 visit_file_text_fallback_rows(
1799 &mut rows,
1800 &mut content_statement,
1801 control,
1802 &mut admit,
1803 &mut visitor,
1804 )
1805 } else {
1806 let mut statement = self
1807 .connection
1808 .prepare_cached(FILE_TEXT_FALLBACK_ALL_METADATA_SQL)?;
1809 let mut rows = statement.query([])?;
1810 visit_file_text_fallback_rows(
1811 &mut rows,
1812 &mut content_statement,
1813 control,
1814 &mut admit,
1815 &mut visitor,
1816 )
1817 }
1818 })
1819 }
1820
1821 pub fn file_text_fts_ready(&self) -> DbResult<bool> {
1830 Ok(load_file_text_fts_revisions(&self.connection)?
1831 .is_some_and(|(source, projection)| source == projection))
1832 }
1833
1834 pub fn file_text_fts_state(&self) -> DbResult<FileTextFtsState> {
1841 let revision_synchronized = self.file_text_fts_ready()?;
1842 let (source_rows, indexed_rows, synchronized) = self.connection.query_row(
1843 "
1844 SELECT
1845 (SELECT COUNT(*) FROM file_texts),
1846 (SELECT COUNT(*) FROM file_text_fts_docsize),
1847 NOT EXISTS(
1848 SELECT 1
1849 FROM file_texts AS f
1850 LEFT JOIN file_text_fts_docsize AS d ON d.id = f.rowid
1851 WHERE d.id IS NULL
1852 )
1853 AND NOT EXISTS(
1854 SELECT 1
1855 FROM file_text_fts_docsize AS d
1856 LEFT JOIN file_texts AS f ON f.rowid = d.id
1857 WHERE f.rowid IS NULL
1858 )
1859 ",
1860 [],
1861 |row| {
1862 Ok((
1863 row.get::<_, i64>(0)?,
1864 row.get::<_, i64>(1)?,
1865 row.get::<_, i64>(2)? != 0,
1866 ))
1867 },
1868 )?;
1869 Ok(FileTextFtsState {
1870 source_rows: count_to_usize("file_texts", source_rows)?,
1871 indexed_rows: count_to_usize("file_text_fts", indexed_rows)?,
1872 synchronized: revision_synchronized && synchronized,
1873 })
1874 }
1875
1876 pub fn load_file_texts_for_search(
1887 &self,
1888 literal_pattern: Option<&str>,
1889 case_sensitive: bool,
1890 ) -> DbResult<Vec<IndexedFileText>> {
1891 let mut texts = Vec::new();
1892 self.visit_file_texts_for_search(literal_pattern, case_sensitive, |text| {
1893 texts.push(text);
1894 Ok(true)
1895 })?;
1896 Ok(texts)
1897 }
1898
1899 pub fn visit_file_texts_for_search<F>(
1910 &self,
1911 literal_pattern: Option<&str>,
1912 case_sensitive: bool,
1913 mut visitor: F,
1914 ) -> DbResult<()>
1915 where
1916 F: FnMut(IndexedFileText) -> DbResult<bool>,
1917 {
1918 if let Some(pattern) = literal_pattern.filter(|pattern| !pattern.is_empty()) {
1919 if case_sensitive {
1920 let mut statement = self.connection.prepare(
1921 "
1922 SELECT path, content_hash, byte_count, line_count, content
1923 FROM file_texts
1924 WHERE instr(content, ?1) > 0
1925 ORDER BY path
1926 ",
1927 )?;
1928 let mut rows = statement.query([pattern])?;
1929 while let Some(row) = rows.next()? {
1930 if !visitor(file_text_from_row(row)?)? {
1931 return Ok(());
1932 }
1933 }
1934 } else {
1935 let pattern = pattern.to_ascii_lowercase();
1936 let mut statement = self.connection.prepare(
1937 "
1938 SELECT path, content_hash, byte_count, line_count, content
1939 FROM file_texts
1940 WHERE instr(lower(content), ?1) > 0
1941 ORDER BY path
1942 ",
1943 )?;
1944 let mut rows = statement.query([pattern])?;
1945 while let Some(row) = rows.next()? {
1946 if !visitor(file_text_from_row(row)?)? {
1947 return Ok(());
1948 }
1949 }
1950 }
1951 } else {
1952 let mut statement = self.connection.prepare(
1953 "
1954 SELECT path, content_hash, byte_count, line_count, content
1955 FROM file_texts
1956 ORDER BY path
1957 ",
1958 )?;
1959 let mut rows = statement.query([])?;
1960 while let Some(row) = rows.next()? {
1961 if !visitor(file_text_from_row(row)?)? {
1962 return Ok(());
1963 }
1964 }
1965 }
1966 Ok(())
1967 }
1968
1969 pub fn file_text_count(&self) -> DbResult<usize> {
1975 let count = self
1976 .connection
1977 .query_row("SELECT COUNT(*) FROM file_texts", [], |row| {
1978 row.get::<_, i64>(0)
1979 })?;
1980 count_to_usize("file_texts", count)
1981 }
1982
1983 pub fn file_text_byte_count(&self) -> DbResult<usize> {
1989 let count = self.connection.query_row(
1990 "SELECT COALESCE(SUM(byte_count), 0) FROM file_texts",
1991 [],
1992 |row| row.get::<_, i64>(0),
1993 )?;
1994 count_to_usize("file_text_bytes", count)
1995 }
1996
1997 pub fn set_project_root(&mut self, root: &Path) -> DbResult<()> {
2003 let value = normalize_metadata_path(root);
2004 let previous_identity = self.validated_project_instance_id;
2005 let savepoint = self.validated_savepoint()?;
2006 if let Some(found) = savepoint
2007 .query_row(
2008 "SELECT value FROM metadata WHERE key = ?1",
2009 [PROJECT_ROOT_KEY],
2010 |row| row.get::<_, String>(0),
2011 )
2012 .optional()?
2013 {
2014 if found == value {
2015 let (identity, identity_changed) =
2016 project_identity::ensure_project_identity(&savepoint)?;
2017 savepoint.commit()?;
2018 self.validated_project_root = Some(value);
2019 self.validated_project_instance_id = Some(identity);
2020 if identity_changed {
2021 self.library_usage_instances.get_mut().clear();
2022 }
2023 return Ok(());
2024 }
2025 return Err(DbError::ProjectRootMismatch {
2026 expected: value,
2027 found,
2028 });
2029 }
2030 set_metadata(&savepoint, PROJECT_ROOT_KEY, &value)?;
2031 let (identity, _) = project_identity::ensure_project_identity(&savepoint)?;
2032 let identity_changed = previous_identity != Some(identity);
2033 savepoint.commit()?;
2034 self.validated_project_root = Some(value);
2035 self.validated_project_instance_id = Some(identity);
2036 if identity_changed {
2037 self.library_usage_instances.get_mut().clear();
2038 }
2039 Ok(())
2040 }
2041
2042 pub fn project_root(&self) -> DbResult<Option<String>> {
2048 self.connection
2049 .query_row(
2050 "SELECT value FROM metadata WHERE key = ?1",
2051 [PROJECT_ROOT_KEY],
2052 |row| row.get::<_, String>(0),
2053 )
2054 .optional()
2055 .map_err(DbError::from)
2056 }
2057
2058 pub fn captured_project_binding(&self) -> DbResult<CapturedProjectBinding> {
2067 Ok(CapturedProjectBinding {
2068 project_instance_id: self
2069 .validated_project_instance_id
2070 .ok_or(DbError::ProjectInstanceIdentityMissing)?,
2071 project_root: self
2072 .validated_project_root
2073 .clone()
2074 .ok_or(DbError::ProjectRootMissing)?,
2075 })
2076 }
2077
2078 pub fn revalidate_captured_project_binding(&self) -> DbResult<()> {
2090 let binding = self.captured_project_binding()?;
2091 let Some(path) = self.database_path.as_deref() else {
2092 return schema::validate_active_binding(
2093 &self.connection,
2094 Some(&binding.project_root),
2095 Some(binding.project_instance_id),
2096 );
2097 };
2098 let (connection, _) = schema::open_current_read_only(path, Some(&binding.project_root))?;
2099 schema::validate_active_binding(
2100 &connection,
2101 Some(&binding.project_root),
2102 Some(binding.project_instance_id),
2103 )
2104 }
2105
2106 fn purpose_curation_context(
2108 &self,
2109 connection: &Connection,
2110 ) -> DbResult<(ProjectInstanceId, IndexGeneration)> {
2111 let selected = self
2112 .validated_project_instance_id
2113 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
2114 let current = project_identity::load_project_identity(connection)?
2115 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
2116 if current != selected {
2117 return Err(DbError::GraphProjectIdentityMismatch {
2118 expected: selected.to_string(),
2119 found: current.to_string(),
2120 });
2121 }
2122 let generation =
2123 project_identity::load_graph_generation(connection)?.unwrap_or(IndexGeneration::ZERO);
2124 Ok((current, generation))
2125 }
2126
2127 pub fn begin_index_publication(
2137 &mut self,
2138 contract_fingerprint: &str,
2139 ) -> DbResult<IndexPublicationGuard<'_>> {
2140 let base_generation = self
2141 .index_publication()?
2142 .map_or(IndexGeneration::ZERO, |publication| publication.generation);
2143 self.begin_index_publication_from(contract_fingerprint, base_generation)
2144 }
2145
2146 pub fn begin_index_publication_from(
2158 &mut self,
2159 contract_fingerprint: &str,
2160 expected_base_generation: IndexGeneration,
2161 ) -> DbResult<IndexPublicationGuard<'_>> {
2162 self.begin_publication(
2163 PublicationContract::Full(contract_fingerprint.to_string()),
2164 Some(expected_base_generation),
2165 )
2166 }
2167
2168 pub fn begin_index_projection_refresh(
2176 &mut self,
2177 contract_fingerprint: &str,
2178 ) -> DbResult<IndexPublicationGuard<'_>> {
2179 let base_generation = self
2180 .index_publication()?
2181 .map_or(IndexGeneration::ZERO, |publication| publication.generation);
2182 self.begin_index_projection_refresh_from(contract_fingerprint, base_generation)
2183 }
2184
2185 pub fn begin_index_projection_refresh_from(
2194 &mut self,
2195 contract_fingerprint: &str,
2196 expected_base_generation: IndexGeneration,
2197 ) -> DbResult<IndexPublicationGuard<'_>> {
2198 self.begin_publication(
2199 PublicationContract::Projection(contract_fingerprint.to_string()),
2200 Some(expected_base_generation),
2201 )
2202 }
2203
2204 fn begin_publication(
2206 &mut self,
2207 contract: PublicationContract,
2208 expected_base_generation: Option<IndexGeneration>,
2209 ) -> DbResult<IndexPublicationGuard<'_>> {
2210 begin_immediate_publication(&self.connection)?;
2211 let setup = (|| {
2212 schema::validate_active_binding(
2213 &self.connection,
2214 self.validated_project_root.as_deref(),
2215 self.validated_project_instance_id,
2216 )?;
2217 let previous = load_index_publication(&self.connection)?;
2218 if let Some(expected) = expected_base_generation {
2219 let base_matches = match previous.as_ref() {
2220 None => expected == IndexGeneration::ZERO,
2221 Some(publication) => {
2222 publication.state == IndexPublicationState::Complete
2223 && publication.generation != IndexGeneration::ZERO
2224 && publication.generation == expected
2225 }
2226 };
2227 if !base_matches {
2228 return Err(DbError::PublicationBaseGenerationChanged {
2229 expected,
2230 found: previous
2231 .as_ref()
2232 .map_or(IndexGeneration::ZERO, |publication| publication.generation),
2233 });
2234 }
2235 }
2236 if let PublicationContract::Projection(expected) = &contract {
2237 let matches = previous.as_ref().is_some_and(|publication| {
2238 publication.state == IndexPublicationState::Complete
2239 && publication.contract_fingerprint.as_deref() == Some(expected.as_str())
2240 });
2241 if !matches {
2242 return Err(DbError::PublicationContractChanged);
2243 }
2244 }
2245 set_metadata(
2246 &self.connection,
2247 INDEX_PUBLICATION_STATE_KEY,
2248 IndexPublicationState::Updating.as_str(),
2249 )?;
2250 Ok(previous.map_or(IndexGeneration::ZERO, |publication| publication.generation))
2251 })();
2252 let previous_generation = match setup {
2253 Ok(generation) => generation,
2254 Err(error) => {
2255 self.connection.execute_batch("ROLLBACK")?;
2256 return Err(error);
2257 }
2258 };
2259 Ok(IndexPublicationGuard {
2260 store: self,
2261 contract,
2262 previous_generation,
2263 scan_replacement_pending: false,
2264 active: true,
2265 })
2266 }
2267
2268 pub fn begin_index_read_snapshot(&self) -> DbResult<()> {
2276 if self.read_snapshot_active.replace(true) {
2277 return Err(DbError::IndexReadSnapshotActive);
2278 }
2279 if let Err(error) = self.connection.execute_batch("BEGIN DEFERRED") {
2280 self.read_snapshot_active.set(false);
2281 return Err(error.into());
2282 }
2283 Ok(())
2284 }
2285
2286 pub fn finish_index_read_snapshot(&self) -> DbResult<()> {
2295 if !self.read_snapshot_active.get() {
2296 return Ok(());
2297 }
2298 self.connection.execute_batch("COMMIT")?;
2299 self.read_snapshot_active.set(false);
2300 Ok(())
2301 }
2302
2303 pub fn index_publication(&self) -> DbResult<Option<IndexPublication>> {
2309 load_index_publication(&self.connection)
2310 }
2311
2312 pub fn replace_symbol_graph(&mut self, graph: &SymbolGraph) -> DbResult<()> {
2318 let metadata = SourceParseMetadata::from_graph(graph);
2319 self.replace_symbol_graph_with_metadata(graph, &metadata)
2320 }
2321
2322 pub fn replace_symbol_graph_with_metadata(
2331 &mut self,
2332 graph: &SymbolGraph,
2333 metadata: &SourceParseMetadata,
2334 ) -> DbResult<()> {
2335 if metadata.path != graph.path
2336 || metadata.language != graph.language
2337 || metadata.symbol_count != graph.symbols.len()
2338 || metadata.relation_count != graph.relations.len()
2339 {
2340 return Err(DbError::SymbolGraphRowShape {
2341 path: graph.path.clone(),
2342 reason: "source parse metadata identity or fact counts differ from the graph",
2343 });
2344 }
2345 let savepoint = self.validated_savepoint()?;
2346 let node_id = {
2347 let mut delete_symbols =
2348 savepoint.prepare_cached("DELETE FROM symbols WHERE path = ?1")?;
2349 let mut delete_relations =
2350 savepoint.prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?;
2351 delete_symbols.execute([&graph.path])?;
2352 delete_relations.execute([&graph.path])?;
2353
2354 let mut upsert_metadata = savepoint.prepare_cached(
2355 "
2356 INSERT INTO source_parse_metadata(
2357 path,
2358 language,
2359 source_parser,
2360 fact_parser,
2361 symbol_count,
2362 relation_count,
2363 updated_at
2364 )
2365 VALUES(?1, ?2, ?3, ?4, ?5, ?6, CURRENT_TIMESTAMP)
2366 ON CONFLICT(path) DO UPDATE SET
2367 language = excluded.language,
2368 source_parser = excluded.source_parser,
2369 fact_parser = excluded.fact_parser,
2370 symbol_count = excluded.symbol_count,
2371 relation_count = excluded.relation_count,
2372 updated_at = CURRENT_TIMESTAMP
2373 ",
2374 )?;
2375 upsert_metadata.execute(params![
2376 metadata.path,
2377 metadata.language.as_deref(),
2378 metadata.parser.to_string(),
2379 graph.parser.to_string(),
2380 usize_to_i64(metadata.symbol_count),
2381 usize_to_i64(metadata.relation_count),
2382 ])?;
2383 let mut select_node = savepoint
2384 .prepare_cached("SELECT id FROM nodes WHERE path = ?1 AND exists_now = 1")?;
2385 let node_id = select_node
2386 .query_row([&graph.path], |row| row.get::<_, i64>(0))
2387 .optional()?;
2388
2389 let mut insert_symbol = savepoint.prepare_cached(
2390 "
2391 INSERT INTO symbols(
2392 path,
2393 language,
2394 name,
2395 kind,
2396 signature,
2397 exported,
2398 documentation,
2399 line_start,
2400 line_end,
2401 parent,
2402 parser,
2403 detail
2404 )
2405 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
2406 ",
2407 )?;
2408 for symbol in &graph.symbols {
2409 insert_symbol.execute(params![
2410 symbol.path,
2411 symbol.language.as_deref(),
2412 symbol.name,
2413 symbol.kind.to_string(),
2414 symbol.signature,
2415 symbol.exported,
2416 symbol.documentation.as_deref(),
2417 usize_to_i64(symbol.line_start),
2418 usize_to_i64(symbol.line_end),
2419 symbol.parent.as_deref(),
2420 symbol.parser.to_string(),
2421 symbol.detail.as_deref(),
2422 ])?;
2423 }
2424
2425 let mut insert_relation = savepoint.prepare_cached(
2426 "
2427 INSERT INTO symbol_relations(
2428 path,
2429 source_name,
2430 target_name,
2431 kind,
2432 line,
2433 context,
2434 parser
2435 )
2436 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)
2437 ",
2438 )?;
2439 for relation in &graph.relations {
2440 insert_relation.execute(params![
2441 relation.path,
2442 relation.source_name,
2443 relation.target_name,
2444 relation.kind.to_string(),
2445 usize_to_i64(relation.line),
2446 relation.context,
2447 relation.parser.to_string(),
2448 ])?;
2449 }
2450 node_id
2451 };
2452 if let Some(node_id) = node_id {
2453 replace_symbol_search_summary(
2454 &savepoint,
2455 node_id,
2456 symbol_search_summary(graph).as_deref(),
2457 )?;
2458 }
2459 savepoint.commit()?;
2460 Ok(())
2461 }
2462
2463 pub fn clear_source_index_for_path(&self, path: &str) -> DbResult<()> {
2472 self.with_validated_write(|connection| {
2473 let node_id = self.node_id_for_path(path)?;
2474 connection
2475 .prepare_cached("DELETE FROM symbols WHERE path = ?1")?
2476 .execute([path])?;
2477 connection
2478 .prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?
2479 .execute([path])?;
2480 connection
2481 .prepare_cached("DELETE FROM source_parse_metadata WHERE path = ?1")?
2482 .execute([path])?;
2483 connection
2484 .prepare_cached(
2485 "
2486 DELETE FROM summaries
2487 WHERE node_id = ?1
2488 AND (
2489 (summary_level = 'node' AND subject = '')
2490 OR (summary_level = 'search' AND subject = 'symbols')
2491 )
2492 ",
2493 )?
2494 .execute([node_id])?;
2495 Ok(())
2496 })
2497 }
2498
2499 pub fn clear_symbol_graph_for_path(&self, path: &str) -> DbResult<()> {
2505 self.with_validated_write(|connection| {
2506 let node_id = self.node_id_for_path(path)?;
2507 connection
2508 .prepare_cached("DELETE FROM symbols WHERE path = ?1")?
2509 .execute([path])?;
2510 connection
2511 .prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?
2512 .execute([path])?;
2513 connection
2514 .prepare_cached("DELETE FROM source_parse_metadata WHERE path = ?1")?
2515 .execute([path])?;
2516 connection
2517 .prepare_cached(
2518 "
2519 DELETE FROM summaries
2520 WHERE node_id = ?1
2521 AND summary_level = 'search'
2522 AND subject = 'symbols'
2523 ",
2524 )?
2525 .execute([node_id])?;
2526 Ok(())
2527 })
2528 }
2529
2530 pub fn set_node_summary(&self, path: &str, summary: &str) -> DbResult<()> {
2536 self.with_validated_write(|connection| {
2537 let node_id = self.node_id_for_path(path)?;
2538 connection
2539 .prepare_cached(
2540 "
2541 INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
2542 VALUES(?1, 'node', '', ?2, CURRENT_TIMESTAMP)
2543 ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
2544 summary_level = 'node',
2545 subject = '',
2546 summary = excluded.summary,
2547 updated_at = CURRENT_TIMESTAMP
2548 ",
2549 )?
2550 .execute(params![node_id, summary])?;
2551 Ok(())
2552 })
2553 }
2554
2555 pub fn clear_node_summary(&self, path: &str) -> DbResult<()> {
2561 self.with_validated_write(|connection| {
2562 let node_id = self.node_id_for_path(path)?;
2563 connection
2564 .prepare_cached(
2565 "
2566 DELETE FROM summaries
2567 WHERE node_id = ?1
2568 AND summary_level = 'node'
2569 AND subject = ''
2570 ",
2571 )?
2572 .execute([node_id])?;
2573 Ok(())
2574 })
2575 }
2576
2577 pub fn load_symbols(
2583 &self,
2584 file: Option<&str>,
2585 query: Option<&str>,
2586 limit: usize,
2587 ) -> DbResult<Vec<CodeSymbol>> {
2588 let max_rows = usize_to_i64(limit.max(1));
2589 match (file, query) {
2590 (Some(file), Some(query)) => self.query_symbols(
2591 "
2592 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation
2593 FROM symbols
2594 WHERE path = ?1 AND (name LIKE ?2 OR signature LIKE ?2 OR documentation LIKE ?2)
2595 ORDER BY path, line_start, name
2596 LIMIT ?3
2597 ",
2598 params![file, like_query(query), max_rows],
2599 ),
2600 (Some(file), None) => self.query_symbols(
2601 "
2602 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation
2603 FROM symbols
2604 WHERE path = ?1
2605 ORDER BY path, line_start, name
2606 LIMIT ?2
2607 ",
2608 params![file, max_rows],
2609 ),
2610 (None, Some(query)) => self.query_symbols(
2611 "
2612 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation
2613 FROM symbols
2614 WHERE name LIKE ?1 OR signature LIKE ?1 OR documentation LIKE ?1 OR path LIKE ?1
2615 ORDER BY path, line_start, name
2616 LIMIT ?2
2617 ",
2618 params![like_query(query), max_rows],
2619 ),
2620 (None, None) => self.query_symbols(
2621 "
2622 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation
2623 FROM symbols
2624 ORDER BY path, line_start, name
2625 LIMIT ?1
2626 ",
2627 params![max_rows],
2628 ),
2629 }
2630 }
2631
2632 pub fn load_symbols_for_paths_bounded(
2644 &self,
2645 paths: &[String],
2646 budget: SymbolBatchReadBudget,
2647 control: Option<&IndexWorkControl>,
2648 ) -> DbResult<SymbolBatchRead> {
2649 let path_limit =
2650 usize::try_from(budget.paths()).map_err(|source| DbError::InvalidCount {
2651 field: "symbol batch paths",
2652 value: i64::from(budget.paths()),
2653 source,
2654 })?;
2655 let mut exact_paths = BTreeSet::new();
2656 let mut reached_limit = None;
2657 for path in paths {
2658 if let Some(control) = control {
2659 control.check(IndexWorkStage::RepositoryTraversal)?;
2660 }
2661 exact_paths.insert(path.clone());
2662 if exact_paths.len() > path_limit {
2663 exact_paths.pop_last();
2664 reached_limit.get_or_insert(SymbolBatchReadLimit::Paths);
2665 }
2666 }
2667 if exact_paths.is_empty() {
2668 return Ok(SymbolBatchRead::default());
2669 }
2670 let exact_paths = exact_paths.into_iter().collect::<Vec<_>>();
2671 let row_limit = usize::try_from(budget.rows()).map_err(|source| DbError::InvalidCount {
2672 field: "symbol batch rows",
2673 value: i64::from(budget.rows()),
2674 source,
2675 })?;
2676 let mut symbols = Vec::new();
2677 let mut decoded_bytes = 0_u64;
2678 'chunks: for chunk in exact_paths.chunks(SYMBOL_BATCH_BIND_PATHS) {
2679 if let Some(control) = control {
2680 control.check(IndexWorkStage::RepositoryTraversal)?;
2681 }
2682 let remaining_rows = row_limit.saturating_sub(symbols.len());
2683 if remaining_rows == 0 {
2684 reached_limit.get_or_insert(SymbolBatchReadLimit::Rows);
2685 break;
2686 }
2687 let placeholders = numbered_placeholders(1, chunk.len());
2688 let limit_parameter = chunk.len().saturating_add(1);
2689 let sql = format!(
2690 "
2691 SELECT
2692 path,
2693 language,
2694 name,
2695 kind,
2696 signature,
2697 line_start,
2698 line_end,
2699 parent,
2700 parser,
2701 detail,
2702 exported,
2703 documentation,
2704 length(CAST(path AS BLOB))
2705 + COALESCE(length(CAST(language AS BLOB)), 0)
2706 + length(CAST(name AS BLOB))
2707 + length(CAST(signature AS BLOB))
2708 + COALESCE(length(CAST(parent AS BLOB)), 0)
2709 + COALESCE(length(CAST(detail AS BLOB)), 0)
2710 + COALESCE(length(CAST(documentation AS BLOB)), 0)
2711 FROM symbols
2712 WHERE path IN ({placeholders})
2713 ORDER BY path, line_start, name
2714 LIMIT ?{limit_parameter}
2715 "
2716 );
2717 let mut bindings = chunk.iter().cloned().map(Value::Text).collect::<Vec<_>>();
2718 bindings.push(Value::Integer(usize_to_i64(
2719 remaining_rows.saturating_add(1),
2720 )));
2721 with_sqlite_read_progress(
2722 &self.connection,
2723 control,
2724 IndexWorkStage::RepositoryTraversal,
2725 || {
2726 let mut statement = self.connection.prepare(&sql)?;
2727 let mut rows = statement.query(params_from_iter(bindings.iter()))?;
2728 while let Some(row) = rows.next()? {
2729 if let Some(control) = control {
2730 control.check(IndexWorkStage::RepositoryTraversal)?;
2731 }
2732 if symbols.len() >= row_limit {
2733 reached_limit.get_or_insert(SymbolBatchReadLimit::Rows);
2734 break;
2735 }
2736 let preflight_bytes = code_symbol_preflight_bytes(row)?;
2737 if decoded_bytes.saturating_add(preflight_bytes) > budget.decoded_bytes() {
2738 reached_limit.get_or_insert(SymbolBatchReadLimit::DecodedBytes);
2739 break;
2740 }
2741 let symbol = code_symbol_from_row(row)?;
2742 let row_bytes = code_symbol_decoded_bytes(&symbol)?;
2743 if decoded_bytes.saturating_add(row_bytes) > budget.decoded_bytes() {
2744 reached_limit.get_or_insert(SymbolBatchReadLimit::DecodedBytes);
2745 break;
2746 }
2747 decoded_bytes = decoded_bytes.saturating_add(row_bytes);
2748 symbols.push(symbol);
2749 }
2750 Ok(())
2751 },
2752 )?;
2753 if matches!(
2754 reached_limit,
2755 Some(SymbolBatchReadLimit::Rows | SymbolBatchReadLimit::DecodedBytes)
2756 ) {
2757 break 'chunks;
2758 }
2759 }
2760 symbols.sort_by(|left, right| {
2761 (&left.path, left.line_start, &left.name, &left.signature).cmp(&(
2762 &right.path,
2763 right.line_start,
2764 &right.name,
2765 &right.signature,
2766 ))
2767 });
2768 let symbols = symbols.into_boxed_slice().into_vec();
2769 Ok(SymbolBatchRead {
2770 work: SymbolBatchReadWork {
2771 requested_paths: u32::try_from(exact_paths.len()).unwrap_or(u32::MAX),
2772 returned_rows: u32::try_from(symbols.len()).unwrap_or(u32::MAX),
2773 decoded_bytes,
2774 },
2775 rows: symbols,
2776 truncated: reached_limit.is_some(),
2777 reached_limit,
2778 })
2779 }
2780
2781 pub fn load_symbols_by_kinds(
2787 &self,
2788 file: &str,
2789 kinds: &[SymbolKind],
2790 limit: usize,
2791 ) -> DbResult<Vec<CodeSymbol>> {
2792 if kinds.is_empty() {
2793 return Ok(Vec::new());
2794 }
2795 let max_rows = usize_to_i64(limit.max(1));
2796 let placeholders = numbered_placeholders(2, kinds.len());
2797 let sql = format!(
2798 "
2799 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation
2800 FROM symbols INDEXED BY idx_symbols_path
2801 WHERE path = ?1 AND kind IN ({placeholders})
2802 ORDER BY path, line_start, name
2803 LIMIT {max_rows}
2804 "
2805 );
2806 let mut values = Vec::with_capacity(kinds.len() + 1);
2807 values.push(file.to_string());
2808 values.extend(kinds.iter().map(ToString::to_string));
2809 self.query_symbols(&sql, params_from_iter(values.iter()))
2810 }
2811
2812 pub fn count_symbols_by_kinds(&self, file: &str, kinds: &[SymbolKind]) -> DbResult<usize> {
2818 if kinds.is_empty() {
2819 return Ok(0);
2820 }
2821 let placeholders = numbered_placeholders(2, kinds.len());
2822 let sql = format!(
2823 "SELECT COUNT(*) FROM symbols INDEXED BY idx_symbols_path
2824 WHERE path = ?1 AND kind IN ({placeholders})"
2825 );
2826 let mut values = Vec::with_capacity(kinds.len() + 1);
2827 values.push(file.to_string());
2828 values.extend(kinds.iter().map(ToString::to_string));
2829 let count = self
2830 .connection
2831 .query_row(&sql, params_from_iter(values.iter()), |row| {
2832 row.get::<_, i64>(0)
2833 })?;
2834 Ok(i64_to_usize(count))
2835 }
2836
2837 pub fn symbol_name_counts(&self, names: &[String]) -> DbResult<HashMap<String, usize>> {
2843 if names.is_empty() {
2844 return Ok(HashMap::new());
2845 }
2846 let placeholders = numbered_placeholders(1, names.len());
2847 let sql = format!(
2848 "SELECT name, COUNT(*) FROM symbols WHERE name IN ({placeholders}) GROUP BY name"
2849 );
2850 let mut statement = self.connection.prepare(&sql)?;
2851 let rows = statement.query_map(params_from_iter(names.iter()), |row| {
2852 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
2853 })?;
2854 let mut counts = HashMap::new();
2855 for row in rows {
2856 let (name, count) = row?;
2857 counts.insert(name, i64_to_usize(count));
2858 }
2859 Ok(counts)
2860 }
2861
2862 pub fn load_symbols_by_names(&self, names: &[String]) -> DbResult<Vec<CodeSymbol>> {
2868 if names.is_empty() {
2869 return Ok(Vec::new());
2870 }
2871 let placeholders = numbered_placeholders(1, names.len());
2872 let sql = format!(
2873 "
2874 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation
2875 FROM symbols
2876 WHERE name IN ({placeholders})
2877 ORDER BY path, line_start, name
2878 "
2879 );
2880 self.query_symbols(&sql, params_from_iter(names.iter()))
2881 }
2882
2883 pub fn load_exported_symbol_names_for_path(
2889 &self,
2890 file: &str,
2891 limit: usize,
2892 ) -> DbResult<Vec<String>> {
2893 let max_rows = usize_to_i64(limit.max(1));
2894 let mut statement = self.connection.prepare(
2895 "
2896 SELECT DISTINCT name
2897 FROM symbols
2898 WHERE path = ?1 AND exported = 1
2899 ORDER BY name
2900 LIMIT ?2
2901 ",
2902 )?;
2903 let rows = statement.query_map(params![file, max_rows], |row| row.get::<_, String>(0))?;
2904 let mut names = Vec::new();
2905 for row in rows {
2906 names.push(row?);
2907 }
2908 Ok(names)
2909 }
2910
2911 pub fn exported_symbol_count_for_path(&self, file: &str) -> DbResult<usize> {
2917 let count = self.connection.query_row(
2918 "SELECT COUNT(DISTINCT name) FROM symbols WHERE path = ?1 AND exported = 1",
2919 [file],
2920 |row| row.get::<_, i64>(0),
2921 )?;
2922 Ok(i64_to_usize(count))
2923 }
2924
2925 pub fn load_symbol_by_name(&self, file: &str, name: &str) -> DbResult<Option<CodeSymbol>> {
2931 let mut symbols = self.load_symbols(Some(file), Some(name), 100)?;
2932 symbols.retain(|symbol| symbol.name == name);
2933 Ok(symbols.into_iter().next())
2934 }
2935
2936 pub fn load_symbols_by_exact_file_and_name(
2942 &self,
2943 file: &str,
2944 name: &str,
2945 ) -> DbResult<Vec<CodeSymbol>> {
2946 self.query_symbols(
2947 "
2948 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation
2949 FROM symbols
2950 WHERE path = ?1 AND name = ?2
2951 ORDER BY line_start, line_end, kind, parent
2952 ",
2953 params![file, name],
2954 )
2955 }
2956
2957 pub fn load_node_by_path(&self, path: &str) -> DbResult<Option<IndexedNode>> {
2963 let mut statement = self.connection.prepare(
2964 "
2965 SELECT
2966 n.path,
2967 n.kind,
2968 n.parent_path,
2969 n.extension,
2970 n.language,
2971 n.size_bytes,
2972 n.mtime_ns,
2973 n.content_hash,
2974 p.purpose,
2975 p.source,
2976 p.status,
2977 s.summary
2978 FROM nodes n
2979 JOIN purposes p ON p.node_id = n.id
2980 LEFT JOIN summaries s ON s.node_id = n.id
2981 AND s.summary_level = 'node'
2982 AND s.subject = ''
2983 WHERE n.exists_now = 1 AND n.path = ?1
2984 ",
2985 )?;
2986 let row = statement
2987 .query_row([path], |row| {
2988 let kind_value: String = row.get(1)?;
2989 let source_value: String = row.get(9)?;
2990 let status_value: String = row.get(10)?;
2991 Ok((
2992 row.get::<_, String>(0)?,
2993 kind_value,
2994 row.get::<_, Option<String>>(2)?,
2995 row.get::<_, Option<String>>(3)?,
2996 row.get::<_, Option<String>>(4)?,
2997 row.get::<_, Option<u64>>(5)?,
2998 row.get::<_, Option<i64>>(6)?,
2999 row.get::<_, Option<String>>(7)?,
3000 row.get::<_, Option<String>>(8)?,
3001 source_value,
3002 status_value,
3003 row.get::<_, Option<String>>(11)?,
3004 ))
3005 })
3006 .optional()?;
3007 row.map(indexed_node_from_parts).transpose()
3008 }
3009
3010 pub fn load_nodes_by_paths(&self, paths: &[String]) -> DbResult<Vec<IndexedNode>> {
3016 self.load_nodes_by_paths_controlled(paths, None)
3017 }
3018
3019 pub fn load_nodes_by_paths_controlled(
3025 &self,
3026 paths: &[String],
3027 control: Option<&IndexWorkControl>,
3028 ) -> DbResult<Vec<IndexedNode>> {
3029 let mut unique_paths = paths.to_vec();
3030 unique_paths.sort();
3031 unique_paths.dedup();
3032 if unique_paths.is_empty() {
3033 return Ok(Vec::new());
3034 }
3035 let mut nodes = Vec::new();
3036 for chunk in unique_paths.chunks(MAX_PURPOSE_CURATION_BATCH_ROWS) {
3037 let hydrated = with_sqlite_read_progress(
3038 &self.connection,
3039 control,
3040 IndexWorkStage::RepositoryTraversal,
3041 || {
3042 let sql = load_nodes_by_paths_sql(chunk.len());
3043 let mut statement = self.connection.prepare(&sql)?;
3044 let rows = statement.query_map(params_from_iter(chunk), |row| {
3045 let kind_value: String = row.get(1)?;
3046 let source_value: String = row.get(9)?;
3047 let status_value: String = row.get(10)?;
3048 Ok((
3049 row.get::<_, String>(0)?,
3050 kind_value,
3051 row.get::<_, Option<String>>(2)?,
3052 row.get::<_, Option<String>>(3)?,
3053 row.get::<_, Option<String>>(4)?,
3054 row.get::<_, Option<u64>>(5)?,
3055 row.get::<_, Option<i64>>(6)?,
3056 row.get::<_, Option<String>>(7)?,
3057 row.get::<_, Option<String>>(8)?,
3058 source_value,
3059 status_value,
3060 row.get::<_, Option<String>>(11)?,
3061 ))
3062 })?;
3063 let mut selected = Vec::new();
3064 for row in rows {
3065 selected.push(indexed_node_from_parts(row?)?);
3066 }
3067 Ok(selected)
3068 },
3069 )?;
3070 nodes.extend(hydrated);
3071 }
3072 Ok(nodes)
3073 }
3074
3075 pub fn load_purpose_owner_nodes_by_paths_controlled(
3090 &self,
3091 project: ProjectInstanceId,
3092 generation: IndexGeneration,
3093 paths: &[String],
3094 budget: RepositoryGraphReadBudget,
3095 control: Option<&IndexWorkControl>,
3096 ) -> DbResult<RepositoryGraphReadBatch<IndexedNode>> {
3097 if paths
3098 .iter()
3099 .map(String::as_str)
3100 .collect::<HashSet<_>>()
3101 .len()
3102 != paths.len()
3103 {
3104 return Err(GraphContractError::InvalidLimits {
3105 reason: "purpose-owner hydration paths must be unique",
3106 }
3107 .into());
3108 }
3109 self.require_repository_graph_snapshot(project, generation)?;
3110 let mut meter = repository_graph::RepositoryGraphReadMeter::new(budget, paths.len())?;
3111 let path_count =
3112 u32::try_from(paths.len()).map_err(|_source| GraphContractError::InvalidLimits {
3113 reason: "purpose-owner hydration path count overflowed",
3114 })?;
3115 if path_count > budget.returned_rows() {
3116 return Err(GraphContractError::InvalidLimits {
3117 reason: "purpose-owner paths exceed the return budget",
3118 }
3119 .into());
3120 }
3121 if path_count > budget.hydrated_paths() {
3122 return Err(GraphContractError::InvalidLimits {
3123 reason: "purpose-owner paths exceed the hydration budget",
3124 }
3125 .into());
3126 }
3127 if paths.is_empty() {
3128 return Ok(RepositoryGraphReadBatch {
3129 rows: Vec::new(),
3130 work: meter.finish(0)?,
3131 });
3132 }
3133
3134 let mut selected = HashMap::with_capacity(paths.len());
3135 for chunk in paths.chunks(MAX_PURPOSE_CURATION_BATCH_ROWS) {
3136 let hydrated = with_sqlite_read_progress(
3137 &self.connection,
3138 control,
3139 IndexWorkStage::RepositoryTraversal,
3140 || {
3141 let sql = load_nodes_by_paths_sql(chunk.len());
3142 let mut statement = self.connection.prepare(&sql)?;
3143 let mut rows = statement.query(params_from_iter(chunk))?;
3144 let mut nodes = Vec::new();
3145 while let Some(row) = rows.next()? {
3146 let parts = indexed_node_parts_from_sql_row(row)?;
3147 meter.record_decoded_bytes(indexed_node_parts_decoded_bytes(&parts)?)?;
3148 let node = indexed_node_from_parts(parts)?;
3149 meter.record_hydrated_path(&node.node.path)?;
3150 nodes.push(node);
3151 }
3152 Ok(nodes)
3153 },
3154 )?;
3155 for node in hydrated {
3156 let path = node.node.path.clone();
3157 if selected.insert(path, node).is_some() {
3158 return Err(DbError::GraphRowShape {
3159 table: "nodes",
3160 reason: "purpose-owner hydration returned a duplicate path",
3161 });
3162 }
3163 }
3164 }
3165
3166 let mut ordered = Vec::with_capacity(paths.len());
3167 for path in paths {
3168 if let Some(node) = selected.remove(path) {
3169 ordered.push(node);
3170 }
3171 }
3172 let work = meter.finish(ordered.len())?;
3173 Ok(RepositoryGraphReadBatch {
3174 rows: ordered,
3175 work,
3176 })
3177 }
3178
3179 pub fn load_purpose_curation_batch(
3189 &self,
3190 task: &str,
3191 paths: &[String],
3192 ) -> DbResult<PurposeCurationBatch> {
3193 let task = normalize_purpose_curation_task(task)?;
3194 let mut unique_paths = paths.to_vec();
3195 unique_paths.sort();
3196 unique_paths.dedup();
3197 if unique_paths.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
3198 return Err(DbError::PurposeCurationBatchTooLarge {
3199 requested: unique_paths.len(),
3200 maximum: MAX_PURPOSE_CURATION_BATCH_ROWS,
3201 });
3202 }
3203 let (project_instance_id, active_generation) =
3204 self.purpose_curation_context(&self.connection)?;
3205 let items = self
3206 .load_nodes_by_paths(&unique_paths)?
3207 .into_iter()
3208 .filter(|node| {
3209 matches!(
3210 node.purpose.status,
3211 PurposeStatus::Missing | PurposeStatus::Suggested
3212 )
3213 })
3214 .map(|node| {
3215 purpose_curation_candidate(project_instance_id, active_generation, &task, node)
3216 })
3217 .collect::<Vec<_>>();
3218 let work_key =
3219 purpose_curation_batch_work_key(project_instance_id, active_generation, &task, &items);
3220 Ok(PurposeCurationBatch {
3221 project_instance_id,
3222 active_generation,
3223 task,
3224 work_key,
3225 items,
3226 })
3227 }
3228
3229 pub fn load_symbol_relations(
3235 &self,
3236 file: Option<&str>,
3237 query: Option<&str>,
3238 limit: usize,
3239 ) -> DbResult<Vec<SymbolRelation>> {
3240 let max_rows = usize_to_i64(limit.max(1));
3241 match (file, query) {
3242 (Some(file), Some(query)) => self.query_relations(
3243 "
3244 SELECT path, source_name, target_name, kind, line, context, parser
3245 FROM symbol_relations
3246 WHERE path = ?1 AND (source_name LIKE ?2 OR target_name LIKE ?2 OR context LIKE ?2)
3247 ORDER BY path, line, source_name, target_name
3248 LIMIT ?3
3249 ",
3250 params![file, like_query(query), max_rows],
3251 ),
3252 (Some(file), None) => self.query_relations(
3253 "
3254 SELECT path, source_name, target_name, kind, line, context, parser
3255 FROM symbol_relations
3256 WHERE path = ?1
3257 ORDER BY path, line, source_name, target_name
3258 LIMIT ?2
3259 ",
3260 params![file, max_rows],
3261 ),
3262 (None, Some(query)) => self.query_relations(
3263 "
3264 SELECT path, source_name, target_name, kind, line, context, parser
3265 FROM symbol_relations
3266 WHERE source_name LIKE ?1 OR target_name LIKE ?1 OR context LIKE ?1 OR path LIKE ?1
3267 ORDER BY path, line, source_name, target_name
3268 LIMIT ?2
3269 ",
3270 params![like_query(query), max_rows],
3271 ),
3272 (None, None) => self.query_relations(
3273 "
3274 SELECT path, source_name, target_name, kind, line, context, parser
3275 FROM symbol_relations
3276 ORDER BY path, line, source_name, target_name
3277 LIMIT ?1
3278 ",
3279 params![max_rows],
3280 ),
3281 }
3282 }
3283
3284 pub fn load_symbol_relations_by_kind(
3290 &self,
3291 file: &str,
3292 kind: RelationKind,
3293 limit: usize,
3294 ) -> DbResult<Vec<SymbolRelation>> {
3295 let max_rows = usize_to_i64(limit.max(1));
3296 self.query_relations(
3297 "
3298 SELECT path, source_name, target_name, kind, line, context, parser
3299 FROM symbol_relations
3300 WHERE path = ?1 AND kind = ?2
3301 ORDER BY path, line, source_name, target_name
3302 LIMIT ?3
3303 ",
3304 params![file, kind.to_string(), max_rows],
3305 )
3306 }
3307
3308 pub fn count_symbol_relations_by_kind(
3314 &self,
3315 file: &str,
3316 kind: RelationKind,
3317 ) -> DbResult<usize> {
3318 let count = self.connection.query_row(
3319 "SELECT COUNT(*) FROM symbol_relations WHERE path = ?1 AND kind = ?2",
3320 params![file, kind.to_string()],
3321 |row| row.get::<_, i64>(0),
3322 )?;
3323 Ok(i64_to_usize(count))
3324 }
3325
3326 pub fn load_distinct_relation_targets_by_kind(
3332 &self,
3333 file: &str,
3334 kind: RelationKind,
3335 limit: usize,
3336 ) -> DbResult<Vec<String>> {
3337 let max_rows = usize_to_i64(limit.max(1));
3338 let mut statement = self.connection.prepare(
3339 "
3340 SELECT DISTINCT target_name
3341 FROM symbol_relations
3342 WHERE path = ?1 AND kind = ?2
3343 ORDER BY target_name
3344 LIMIT ?3
3345 ",
3346 )?;
3347 let rows = statement.query_map(params![file, kind.to_string(), max_rows], |row| {
3348 row.get::<_, String>(0)
3349 })?;
3350 let mut targets = Vec::new();
3351 for row in rows {
3352 targets.push(row?);
3353 }
3354 Ok(targets)
3355 }
3356
3357 pub fn count_distinct_relation_targets_by_kind(
3363 &self,
3364 file: &str,
3365 kind: RelationKind,
3366 ) -> DbResult<usize> {
3367 let count = self.connection.query_row(
3368 "SELECT COUNT(DISTINCT target_name) FROM symbol_relations WHERE path = ?1 AND kind = ?2",
3369 params![file, kind.to_string()],
3370 |row| row.get::<_, i64>(0),
3371 )?;
3372 Ok(i64_to_usize(count))
3373 }
3374
3375 pub fn load_call_relations_to_targets(
3381 &self,
3382 target_names: &[String],
3383 limit_per_target: usize,
3384 ) -> DbResult<Vec<SymbolRelation>> {
3385 if target_names.is_empty() {
3386 return Ok(Vec::new());
3387 }
3388 let placeholders = numbered_placeholders(1, target_names.len());
3389 let limit_placeholder = target_names.len() + 1;
3390 let sql = format!(
3391 "
3392 SELECT path, source_name, target_name, kind, line, context, parser
3393 FROM (
3394 SELECT
3395 path,
3396 source_name,
3397 target_name,
3398 kind,
3399 line,
3400 context,
3401 parser,
3402 ROW_NUMBER() OVER (
3403 PARTITION BY target_name
3404 ORDER BY path, line, source_name, target_name
3405 ) AS target_row
3406 FROM symbol_relations INDEXED BY idx_symbol_relations_target
3407 WHERE kind = 'calls' AND target_name IN ({placeholders})
3408 )
3409 WHERE target_row <= ?{limit_placeholder}
3410 ORDER BY path, line, source_name, target_name
3411 "
3412 );
3413 let mut values = target_names
3414 .iter()
3415 .map(|target| Value::Text(target.clone()))
3416 .collect::<Vec<_>>();
3417 values.push(Value::Integer(usize_to_i64(limit_per_target.max(1))));
3418 let mut relations = self.query_relations(&sql, params_from_iter(values.iter()))?;
3419 relations.sort_by(|left, right| {
3420 left.path
3421 .cmp(&right.path)
3422 .then_with(|| left.line.cmp(&right.line))
3423 .then_with(|| left.source_name.cmp(&right.source_name))
3424 .then_with(|| left.target_name.cmp(&right.target_name))
3425 });
3426 relations.dedup_by(|left, right| {
3427 left.path == right.path
3428 && left.source_name == right.source_name
3429 && left.target_name == right.target_name
3430 && left.kind == right.kind
3431 && left.line == right.line
3432 });
3433 Ok(relations)
3434 }
3435
3436 pub fn load_import_relations_matching_targets(
3442 &self,
3443 terms: &[String],
3444 limit_per_term: usize,
3445 ) -> DbResult<Vec<StoredImportRelation>> {
3446 let mut unique_terms = terms.to_vec();
3447 unique_terms.sort();
3448 unique_terms.dedup();
3449 let mut relations = Vec::new();
3450 for term in unique_terms.iter().filter(|term| !term.trim().is_empty()) {
3451 let mut statement = self.connection.prepare_cached(
3452 "
3453 SELECT path, source_name, target_name, line
3454 FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
3455 WHERE kind = 'imports' AND target_name LIKE ?1 ESCAPE '\\'
3456 ORDER BY path, line, source_name, target_name
3457 LIMIT ?2
3458 ",
3459 )?;
3460 let rows = statement.query_map(
3461 params![
3462 sqlite_like_pattern(term),
3463 usize_to_i64(limit_per_term.max(1))
3464 ],
3465 stored_import_relation_from_row,
3466 )?;
3467 for row in rows {
3468 relations.push(row?);
3469 }
3470 }
3471 relations.sort_by(|left, right| {
3472 left.path
3473 .cmp(&right.path)
3474 .then_with(|| left.line.cmp(&right.line))
3475 .then_with(|| left.source_name.cmp(&right.source_name))
3476 .then_with(|| left.target_name.cmp(&right.target_name))
3477 });
3478 relations.dedup_by(|left, right| {
3479 left.path == right.path
3480 && left.source_name == right.source_name
3481 && left.target_name == right.target_name
3482 && left.line == right.line
3483 });
3484 Ok(relations)
3485 }
3486
3487 pub fn load_import_relations_for_path(
3493 &self,
3494 path: &str,
3495 limit: usize,
3496 ) -> DbResult<Vec<StoredImportRelation>> {
3497 let mut statement = self.connection.prepare_cached(
3498 "
3499 SELECT path, source_name, target_name, line
3500 FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
3501 WHERE kind = 'imports' AND path = ?1
3502 ORDER BY path, line, source_name, target_name
3503 LIMIT ?2
3504 ",
3505 )?;
3506 let rows = statement.query_map(
3507 params![path, usize_to_i64(limit.max(1))],
3508 stored_import_relation_from_row,
3509 )?;
3510 let mut relations = Vec::new();
3511 for row in rows {
3512 relations.push(row?);
3513 }
3514 Ok(relations)
3515 }
3516
3517 pub fn symbol_count(&self) -> DbResult<usize> {
3523 let count = self
3524 .connection
3525 .query_row("SELECT COUNT(*) FROM symbols", [], |row| {
3526 row.get::<_, i64>(0)
3527 })?;
3528 Ok(i64_to_usize(count))
3529 }
3530
3531 pub fn symbol_relation_count(&self) -> DbResult<usize> {
3537 let count =
3538 self.connection
3539 .query_row("SELECT COUNT(*) FROM symbol_relations", [], |row| {
3540 row.get::<_, i64>(0)
3541 })?;
3542 count_to_usize("symbol_relations", count)
3543 }
3544
3545 pub fn symbol_count_for_path(&self, path: &str) -> DbResult<usize> {
3551 let count = self.connection.query_row(
3552 "SELECT COUNT(*) FROM symbols WHERE path = ?1",
3553 [path],
3554 |row| row.get::<_, i64>(0),
3555 )?;
3556 Ok(i64_to_usize(count))
3557 }
3558
3559 pub fn symbol_counts_for_paths(&self, paths: &[String]) -> DbResult<HashMap<String, usize>> {
3567 let mut counts = HashMap::new();
3568 for chunk in paths.chunks(900) {
3569 if chunk.is_empty() {
3570 continue;
3571 }
3572 let placeholders = vec!["?"; chunk.len()].join(",");
3573 let sql = format!(
3574 "SELECT path, COUNT(*) FROM symbols WHERE path IN ({placeholders}) GROUP BY path"
3575 );
3576 let mut statement = self.connection.prepare(&sql)?;
3577 let rows = statement.query_map(params_from_iter(chunk.iter()), |row| {
3578 let path = row.get::<_, String>(0)?;
3579 let count = row.get::<_, i64>(1)?;
3580 Ok((path, i64_to_usize(count)))
3581 })?;
3582 for row in rows {
3583 let (path, count) = row?;
3584 counts.insert(path, count);
3585 }
3586 }
3587 Ok(counts)
3588 }
3589
3590 pub fn source_parse_metadata_paths_for_paths(
3596 &self,
3597 paths: &[String],
3598 ) -> DbResult<HashSet<String>> {
3599 let mut indexed = HashSet::new();
3600 for chunk in paths.chunks(900) {
3601 if chunk.is_empty() {
3602 continue;
3603 }
3604 let placeholders = numbered_placeholders(1, chunk.len());
3605 let sql = format!(
3606 "SELECT path FROM source_parse_metadata
3607 WHERE path IN ({placeholders})
3608 ORDER BY path"
3609 );
3610 let mut statement = self.connection.prepare(&sql)?;
3611 let rows = statement.query_map(params_from_iter(chunk.iter()), |row| {
3612 row.get::<_, String>(0)
3613 })?;
3614 for row in rows {
3615 indexed.insert(row?);
3616 }
3617 }
3618 Ok(indexed)
3619 }
3620
3621 pub fn symbol_parser_kinds_for_path(&self, path: &str) -> DbResult<Vec<ParserKind>> {
3627 let mut statement = self.connection.prepare(
3628 "
3629 SELECT DISTINCT parser
3630 FROM symbols
3631 WHERE path = ?1
3632 ORDER BY parser
3633 ",
3634 )?;
3635 let rows = statement.query_map([path], |row| {
3636 Ok(ParserKind::from_db(&row.get::<_, String>(0)?))
3637 })?;
3638 let mut parsers = Vec::new();
3639 for row in rows {
3640 parsers.push(row?);
3641 }
3642 Ok(parsers)
3643 }
3644
3645 pub fn load_symbol_graphs_for_paths(&self, paths: &[String]) -> DbResult<Vec<SymbolGraph>> {
3656 const PATHS_PER_QUERY: usize = 900;
3657
3658 let mut paths = paths.to_vec();
3659 paths.sort();
3660 paths.dedup();
3661 let mut graphs = Vec::with_capacity(paths.len());
3662 for chunk in paths.chunks(PATHS_PER_QUERY) {
3663 let placeholders = numbered_placeholders(1, chunk.len());
3664 let metadata_sql = format!(
3665 "SELECT path, language, source_parser, fact_parser, symbol_count, relation_count
3666 FROM source_parse_metadata
3667 WHERE path IN ({placeholders})
3668 ORDER BY path"
3669 );
3670 let mut metadata_statement = self.connection.prepare(&metadata_sql)?;
3671 let metadata_rows =
3672 metadata_statement.query_map(params_from_iter(chunk.iter()), |row| {
3673 Ok((
3674 row.get::<_, String>(0)?,
3675 row.get::<_, Option<String>>(1)?,
3676 row.get::<_, String>(2)?,
3677 row.get::<_, String>(3)?,
3678 row.get::<_, i64>(4)?,
3679 row.get::<_, i64>(5)?,
3680 ))
3681 })?;
3682 let mut staged = BTreeMap::new();
3683 for row in metadata_rows {
3684 let (path, language, source_parser, fact_parser, symbol_count, relation_count) =
3685 row?;
3686 staged.insert(
3687 path.clone(),
3688 (
3689 SourceParseMetadata {
3690 path,
3691 language,
3692 parser: ParserKind::from_db(&source_parser),
3693 symbol_count: count_to_usize(
3694 "source_parse_metadata.symbol_count",
3695 symbol_count,
3696 )?,
3697 relation_count: count_to_usize(
3698 "source_parse_metadata.relation_count",
3699 relation_count,
3700 )?,
3701 },
3702 ParserKind::from_db(&fact_parser),
3703 Vec::new(),
3704 Vec::new(),
3705 ),
3706 );
3707 }
3708
3709 let symbol_sql = format!(
3710 "SELECT path, language, name, kind, signature, line_start, line_end,
3711 parent, parser, detail, exported, documentation
3712 FROM symbols
3713 WHERE path IN ({placeholders})
3714 ORDER BY path, line_start, line_end, name, kind"
3715 );
3716 for symbol in self.query_symbols(&symbol_sql, params_from_iter(chunk.iter()))? {
3717 let path = symbol.path.clone();
3718 let Some((_, _, symbols, _)) = staged.get_mut(&path) else {
3719 return Err(DbError::SymbolGraphRowShape {
3720 path,
3721 reason: "symbol rows require matching parser metadata",
3722 });
3723 };
3724 symbols.push(symbol);
3725 }
3726
3727 let relation_sql = format!(
3728 "SELECT path, source_name, target_name, kind, line, context, parser
3729 FROM symbol_relations
3730 WHERE path IN ({placeholders})
3731 ORDER BY path, line, source_name, target_name, kind"
3732 );
3733 for relation in self.query_relations(&relation_sql, params_from_iter(chunk.iter()))? {
3734 let path = relation.path.clone();
3735 let Some((_, _, _, relations)) = staged.get_mut(&path) else {
3736 return Err(DbError::SymbolGraphRowShape {
3737 path,
3738 reason: "relation rows require matching parser metadata",
3739 });
3740 };
3741 relations.push(relation);
3742 }
3743
3744 for (path, (metadata, fact_parser, symbols, relations)) in staged {
3745 if metadata.symbol_count != symbols.len()
3746 || metadata.relation_count != relations.len()
3747 {
3748 return Err(DbError::SymbolGraphRowShape {
3749 path,
3750 reason: "parser metadata counts do not match persisted rows",
3751 });
3752 }
3753 graphs.push(SymbolGraph {
3754 path: metadata.path,
3755 language: metadata.language,
3756 parser: fact_parser,
3757 symbols,
3758 relations,
3759 });
3760 }
3761 }
3762 Ok(graphs)
3763 }
3764
3765 pub fn load_source_parse_metadata(&self, path: &str) -> DbResult<Option<SourceParseMetadata>> {
3771 self.connection
3772 .query_row(
3773 "
3774 SELECT path, language, source_parser, symbol_count, relation_count
3775 FROM source_parse_metadata
3776 WHERE path = ?1
3777 ",
3778 [path],
3779 |row| {
3780 let symbol_count = row.get::<_, i64>(3)?;
3781 let relation_count = row.get::<_, i64>(4)?;
3782 Ok(SourceParseMetadata {
3783 path: row.get(0)?,
3784 language: row.get(1)?,
3785 parser: ParserKind::from_db(&row.get::<_, String>(2)?),
3786 symbol_count: i64_to_usize(symbol_count),
3787 relation_count: i64_to_usize(relation_count),
3788 })
3789 },
3790 )
3791 .optional()
3792 .map_err(Into::into)
3793 }
3794
3795 pub fn max_symbol_end_line_for_path(&self, path: &str) -> DbResult<usize> {
3801 let line = self.connection.query_row(
3802 "SELECT COALESCE(MAX(line_end), 0) FROM symbols WHERE path = ?1",
3803 [path],
3804 |row| row.get::<_, i64>(0),
3805 )?;
3806 Ok(i64_to_usize(line))
3807 }
3808
3809 fn query_symbols<P>(&self, sql: &str, params: P) -> DbResult<Vec<CodeSymbol>>
3811 where
3812 P: rusqlite::Params,
3813 {
3814 let mut statement = self.connection.prepare(sql)?;
3815 let rows = statement.query_map(params, code_symbol_from_row)?;
3816 let mut symbols = Vec::new();
3817 for row in rows {
3818 symbols.push(row?);
3819 }
3820 Ok(symbols)
3821 }
3822
3823 fn query_relations<P>(&self, sql: &str, params: P) -> DbResult<Vec<SymbolRelation>>
3825 where
3826 P: rusqlite::Params,
3827 {
3828 let mut statement = self.connection.prepare(sql)?;
3829 let rows = statement.query_map(params, |row| {
3830 let kind_value: String = row.get(3)?;
3831 let relation_kind = RelationKind::from_db(&kind_value).ok_or_else(|| {
3832 rusqlite::Error::FromSqlConversionFailure(
3833 3,
3834 rusqlite::types::Type::Text,
3835 Box::new(std::io::Error::other(format!(
3836 "invalid relation kind {kind_value}"
3837 ))),
3838 )
3839 })?;
3840 Ok(SymbolRelation {
3841 path: row.get(0)?,
3842 source_name: row.get(1)?,
3843 target_name: row.get(2)?,
3844 kind: relation_kind,
3845 line: i64_to_usize(row.get::<_, i64>(4)?),
3846 context: row.get(5)?,
3847 parser: ParserKind::from_db(&row.get::<_, String>(6)?),
3848 })
3849 })?;
3850 let mut relations = Vec::new();
3851 for row in rows {
3852 relations.push(row?);
3853 }
3854 Ok(relations)
3855 }
3856
3857 pub fn has_agent_approved_purpose(&self) -> DbResult<bool> {
3863 self.connection
3864 .query_row(
3865 "SELECT EXISTS(
3866 SELECT 1
3867 FROM purposes INDEXED BY idx_purposes_status
3868 WHERE status = 'approved' AND source = 'agent'
3869 LIMIT 1
3870 )",
3871 [],
3872 |row| row.get(0),
3873 )
3874 .map_err(Into::into)
3875 }
3876
3877 pub fn set_purpose(&self, path: &str, purpose: &str, source: PurposeSource) -> DbResult<()> {
3883 self.with_validated_write(|connection| {
3884 let node_id = self.node_id_for_path(path)?;
3885 let changed = connection
3886 .prepare_cached(
3887 "
3888 INSERT INTO purposes(node_id, purpose, source, status, updated_at)
3889 VALUES(?1, ?2, ?3, 'approved', CURRENT_TIMESTAMP)
3890 ON CONFLICT(node_id) DO UPDATE SET
3891 purpose = excluded.purpose,
3892 source = excluded.source,
3893 status = 'approved',
3894 updated_at = CURRENT_TIMESTAMP
3895 WHERE purposes.purpose <> excluded.purpose
3896 OR purposes.source <> excluded.source
3897 OR purposes.status <> 'approved'
3898 ",
3899 )?
3900 .execute(params![node_id, purpose, source.to_string()])?;
3901 if changed > 0 {
3902 advance_authored_purpose_revision(connection)?;
3903 }
3904 Ok(())
3905 })
3906 }
3907
3908 pub fn authored_purpose_revision(&self) -> DbResult<u64> {
3917 load_authored_purpose_revision(&self.connection)
3918 }
3919
3920 pub fn conditionally_set_purpose(
3931 &self,
3932 task: &str,
3933 path: &str,
3934 work_key: &str,
3935 state_token: &str,
3936 purpose: &str,
3937 ) -> DbResult<PurposeConditionalApplyState> {
3938 let request = PurposeConditionalApplyRequest {
3939 task: task.to_string(),
3940 path: path.to_string(),
3941 work_key: work_key.to_string(),
3942 state_token: state_token.to_string(),
3943 purpose: purpose.to_string(),
3944 };
3945 let mut results = self.conditionally_set_purposes(&[request])?;
3946 Ok(results
3947 .pop()
3948 .map_or(PurposeConditionalApplyState::PathUnavailable, |result| {
3949 result.state
3950 }))
3951 }
3952
3953 pub fn conditionally_set_purposes(
3965 &self,
3966 requests: &[PurposeConditionalApplyRequest],
3967 ) -> DbResult<Vec<PurposeConditionalApplyResult>> {
3968 if requests.is_empty() {
3969 return Ok(Vec::new());
3970 }
3971 if requests.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
3972 return Err(DbError::PurposeCurationBatchTooLarge {
3973 requested: requests.len(),
3974 maximum: MAX_PURPOSE_CURATION_BATCH_ROWS,
3975 });
3976 }
3977 let normalized = requests
3978 .iter()
3979 .map(|request| {
3980 normalize_purpose_curation_task(&request.task).map(|task| {
3981 PurposeConditionalApplyRequest {
3982 task,
3983 path: request.path.clone(),
3984 work_key: request.work_key.clone(),
3985 state_token: request.state_token.clone(),
3986 purpose: request.purpose.clone(),
3987 }
3988 })
3989 })
3990 .collect::<DbResult<Vec<_>>>()?;
3991 self.with_validated_write(|connection| {
3992 let (project_instance_id, active_generation) =
3993 self.purpose_curation_context(connection)?;
3994 let results = normalized
3995 .iter()
3996 .map(|request| {
3997 apply_conditional_purpose(
3998 connection,
3999 project_instance_id,
4000 active_generation,
4001 request,
4002 )
4003 })
4004 .collect::<DbResult<Vec<_>>>()?;
4005 if results
4006 .iter()
4007 .any(|result| matches!(result.state, PurposeConditionalApplyState::Applied))
4008 {
4009 advance_authored_purpose_revision(connection)?;
4010 }
4011 Ok(results)
4012 })
4013 }
4014
4015 pub fn set_suggested_purpose(&self, path: &str, purpose: &str) -> DbResult<()> {
4021 self.with_validated_write(|connection| {
4022 let node_id = self.node_id_for_path(path)?;
4023 connection
4024 .prepare_cached(
4025 "
4026 INSERT INTO purposes(node_id, purpose, source, status, updated_at)
4027 VALUES(?1, ?2, ?3, ?4, CURRENT_TIMESTAMP)
4028 ON CONFLICT(node_id) DO UPDATE SET
4029 purpose = excluded.purpose,
4030 source = excluded.source,
4031 status = excluded.status,
4032 updated_at = CURRENT_TIMESTAMP
4033 WHERE purposes.status IN (?5, ?6)
4034 ",
4035 )?
4036 .execute(params![
4037 node_id,
4038 purpose,
4039 PurposeSource::Generated.to_string(),
4040 PurposeStatus::Suggested.as_str(),
4041 PurposeStatus::Missing.as_str(),
4042 PurposeStatus::Suggested.as_str(),
4043 ])?;
4044 Ok(())
4045 })
4046 }
4047
4048 fn node_id_for_path(&self, path: &str) -> DbResult<i64> {
4050 self.connection
4051 .prepare_cached("SELECT id FROM nodes WHERE path = ?1 AND exists_now = 1")?
4052 .query_row([path], |row| row.get::<_, i64>(0))
4053 .optional()?
4054 .ok_or_else(|| DbError::PathNotIndexed {
4055 path: path.to_string(),
4056 })
4057 }
4058
4059 pub fn load_nodes(&self) -> DbResult<Vec<IndexedNode>> {
4065 let mut statement = self.connection.prepare(
4066 "
4067 SELECT
4068 n.path,
4069 n.kind,
4070 n.parent_path,
4071 n.extension,
4072 n.language,
4073 n.size_bytes,
4074 n.mtime_ns,
4075 n.content_hash,
4076 p.purpose,
4077 p.source,
4078 p.status,
4079 s.summary
4080 FROM nodes n
4081 JOIN purposes p ON p.node_id = n.id
4082 LEFT JOIN summaries s ON s.node_id = n.id
4083 AND s.summary_level = 'node'
4084 AND s.subject = ''
4085 WHERE n.exists_now = 1
4086 ORDER BY n.path
4087 ",
4088 )?;
4089 let rows = statement.query_map([], |row| {
4090 let kind_value: String = row.get(1)?;
4091 let source_value: String = row.get(9)?;
4092 let status_value: String = row.get(10)?;
4093 Ok((
4094 row.get::<_, String>(0)?,
4095 kind_value,
4096 row.get::<_, Option<String>>(2)?,
4097 row.get::<_, Option<String>>(3)?,
4098 row.get::<_, Option<String>>(4)?,
4099 row.get::<_, Option<u64>>(5)?,
4100 row.get::<_, Option<i64>>(6)?,
4101 row.get::<_, Option<String>>(7)?,
4102 row.get::<_, Option<String>>(8)?,
4103 source_value,
4104 status_value,
4105 row.get::<_, Option<String>>(11)?,
4106 ))
4107 })?;
4108 let mut nodes = Vec::new();
4109 for row in rows {
4110 let (
4111 path,
4112 kind_value,
4113 parent_path,
4114 extension,
4115 language,
4116 size_bytes,
4117 mtime_ns,
4118 content_hash,
4119 purpose,
4120 source_value,
4121 status_value,
4122 summary,
4123 ) = row?;
4124 let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
4125 field: "kind",
4126 value: kind_value,
4127 })?;
4128 let source = parse_source(&source_value)?;
4129 let status =
4130 PurposeStatus::from_db(&status_value).ok_or_else(|| DbError::InvalidEnum {
4131 field: "status",
4132 value: status_value,
4133 })?;
4134 nodes.push(IndexedNode {
4135 node: Node {
4136 path: path.clone(),
4137 kind,
4138 parent_path,
4139 extension,
4140 language,
4141 size_bytes,
4142 mtime_ns,
4143 content_hash,
4144 },
4145 purpose: Purpose {
4146 path,
4147 purpose,
4148 source,
4149 status,
4150 },
4151 summary,
4152 });
4153 }
4154 Ok(nodes)
4155 }
4156
4157 pub fn load_ranked_nodes(
4167 &self,
4168 query: &str,
4169 kind: NodeKind,
4170 folder: Option<&str>,
4171 limit: usize,
4172 offset: usize,
4173 ) -> DbResult<Vec<IndexedNode>> {
4174 let terms = normalize_query_terms(query);
4175 let exact_query = normalize_exact_ranked_query(query);
4176 let exact_name_enabled = !exact_query.is_empty() && !exact_query.contains('/');
4177 let exact_name_pattern = format!("%/{}", sqlite_like_escape(&exact_query));
4178 let reviewed_match_expression = reviewed_purpose_match_expression(terms.len());
4179 let score_expression = ranked_score_expression(terms.len());
4180 let mut sql = format!(
4181 "
4182 SELECT path, kind, parent_path, extension, language, size_bytes, mtime_ns,
4183 content_hash, purpose, source, status, summary
4184 FROM (
4185 SELECT
4186 n.path,
4187 n.kind,
4188 n.parent_path,
4189 n.extension,
4190 n.language,
4191 n.size_bytes,
4192 n.mtime_ns,
4193 n.content_hash,
4194 p.purpose,
4195 p.source,
4196 p.status,
4197 s.summary,
4198 CASE WHEN lower(n.path) = ? THEN 1 ELSE 0 END AS exact_path,
4199 CASE WHEN ? = 1
4200 AND (lower(n.path) = ? OR lower(n.path) LIKE ? ESCAPE '\\')
4201 THEN 1 ELSE 0 END AS exact_name,
4202 {reviewed_match_expression} AS reviewed_purpose,
4203 {score_expression} AS score
4204 FROM nodes n
4205 JOIN purposes p ON p.node_id = n.id
4206 LEFT JOIN summaries s ON s.node_id = n.id
4207 AND s.summary_level = 'node'
4208 AND s.subject = ''
4209 LEFT JOIN summaries symbol_summaries ON symbol_summaries.node_id = n.id
4210 AND symbol_summaries.summary_level = 'search'
4211 AND symbol_summaries.subject = 'symbols'
4212 WHERE n.exists_now = 1
4213 AND n.kind = ?
4214 "
4215 );
4216 let mut values = vec![
4217 Value::from(exact_query.clone()),
4218 Value::from(i64::from(exact_name_enabled)),
4219 Value::from(exact_query),
4220 Value::from(exact_name_pattern),
4221 ];
4222 for term in &terms {
4223 values.push(Value::from(sqlite_like_pattern(term)));
4224 }
4225 for term in &terms {
4226 let pattern = sqlite_like_pattern(term);
4227 values.push(Value::from(pattern.clone()));
4228 values.push(Value::from(pattern.clone()));
4229 values.push(Value::from(pattern.clone()));
4230 values.push(Value::from(pattern.clone()));
4231 values.push(Value::from(pattern));
4232 }
4233 values.push(Value::from(kind.to_string()));
4234 if kind == NodeKind::File
4235 && let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".")
4236 {
4237 sql.push_str(" AND (n.parent_path = ? OR n.parent_path LIKE ? ESCAPE '\\')");
4238 values.push(Value::from(folder.to_string()));
4239 values.push(Value::from(sqlite_descendant_pattern(folder)));
4240 }
4241 sql.push_str(
4242 "
4243 )
4244 WHERE score > 0 OR exact_path > 0 OR exact_name > 0 OR reviewed_purpose > 0
4245 ORDER BY exact_path DESC, exact_name DESC, reviewed_purpose DESC, score DESC, path
4246 LIMIT ?
4247 OFFSET ?
4248 ",
4249 );
4250 values.push(Value::from(usize_to_i64(limit.max(1))));
4251 values.push(Value::from(usize_to_i64(offset)));
4252
4253 let mut statement = self.connection.prepare(&sql)?;
4254 let mut rows = statement.query(params_from_iter(values))?;
4255 let mut nodes = Vec::new();
4256 while let Some(row) = rows.next()? {
4257 nodes.push(indexed_node_from_sql_row(row)?);
4258 }
4259 Ok(nodes)
4260 }
4261
4262 pub fn source_file_byte_count(&self, folder: Option<&str>) -> DbResult<usize> {
4268 let mut sql = String::from(
4269 "
4270 SELECT COALESCE(SUM(COALESCE(size_bytes, 0)), 0)
4271 FROM nodes
4272 WHERE exists_now = 1
4273 AND kind = 'file'
4274 ",
4275 );
4276 let mut values = Vec::new();
4277 if let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".") {
4278 sql.push_str(" AND (parent_path = ? OR parent_path LIKE ? ESCAPE '\\')");
4279 values.push(Value::from(folder.to_string()));
4280 values.push(Value::from(sqlite_descendant_pattern(folder)));
4281 }
4282 let count = self
4283 .connection
4284 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
4285 count_to_usize("source_file_bytes", count)
4286 }
4287
4288 pub fn visit_file_token_estimates<F>(
4295 &self,
4296 folder: Option<&str>,
4297 mut visitor: F,
4298 ) -> DbResult<()>
4299 where
4300 F: FnMut(String, Option<u64>) -> DbResult<bool>,
4301 {
4302 let mut sql = String::from(
4303 "
4304 SELECT path, size_bytes
4305 FROM nodes
4306 WHERE exists_now = 1
4307 AND kind = 'file'
4308 ",
4309 );
4310 let mut values = Vec::new();
4311 if let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".") {
4312 sql.push_str(" AND (parent_path = ? OR parent_path LIKE ? ESCAPE '\\')");
4313 values.push(Value::from(folder.to_string()));
4314 values.push(Value::from(sqlite_descendant_pattern(folder)));
4315 }
4316 sql.push_str(" ORDER BY path");
4317 let mut statement = self.connection.prepare(&sql)?;
4318 let mut rows = statement.query(params_from_iter(values))?;
4319 while let Some(row) = rows.next()? {
4320 if !visitor(row.get::<_, String>(0)?, row.get::<_, Option<u64>>(1)?)? {
4321 return Ok(());
4322 }
4323 }
4324 Ok(())
4325 }
4326
4327 pub fn unresolved_health_findings(
4333 &self,
4334 resolved_ids: &[String],
4335 ) -> DbResult<Vec<HealthFinding>> {
4336 let mut findings = Vec::new();
4337 self.visit_unresolved_health_findings(resolved_ids, |finding| {
4338 findings.push(finding);
4339 Ok(true)
4340 })?;
4341 Ok(findings)
4342 }
4343
4344 pub fn unresolved_health_findings_page(
4350 &self,
4351 resolved_ids: &[String],
4352 query: &HealthQuery,
4353 ) -> DbResult<HealthFindingsPage> {
4354 self.unresolved_health_findings_page_with_filter(
4355 HealthResolutionFilter::Explicit(resolved_ids),
4356 query,
4357 )
4358 }
4359
4360 pub fn unresolved_health_findings_page_current(
4366 &self,
4367 query: &HealthQuery,
4368 ) -> DbResult<HealthFindingsPage> {
4369 self.unresolved_health_findings_page_with_filter(HealthResolutionFilter::Stored, query)
4370 }
4371
4372 pub fn purpose_curation_findings_page_current(
4381 &self,
4382 query: &HealthQuery,
4383 ) -> DbResult<HealthFindingsPage> {
4384 let specs = &PURPOSE_HEALTH_SPECS[..2];
4385 let unfiltered_total = specs.iter().try_fold(0_usize, |total, spec| {
4386 self.count_purpose_status_findings(
4387 *spec,
4388 None,
4389 HealthResolutionFilter::Stored,
4390 HealthScope::all(),
4391 )
4392 .map(|count| total + count)
4393 })?;
4394 if query
4395 .severity
4396 .is_some_and(|severity| severity != Severity::Warning)
4397 {
4398 return Ok(HealthFindingsPage {
4399 total: 0,
4400 unfiltered_total,
4401 returned: 0,
4402 start_index: query.start_index,
4403 limit: query.limit,
4404 findings: Vec::new(),
4405 });
4406 }
4407
4408 let matching_specs = query.category.as_deref().map_or(specs, |category| {
4409 specs
4410 .iter()
4411 .find(|spec| spec.category == category)
4412 .map_or(&[][..], std::slice::from_ref)
4413 });
4414 let total = self.count_purpose_lifecycle_findings(
4415 matching_specs,
4416 query.path_prefix.as_deref(),
4417 HealthResolutionFilter::Stored,
4418 query.scope,
4419 )?;
4420 let findings = if query.summary_only {
4421 Vec::new()
4422 } else {
4423 self.load_purpose_lifecycle_findings_page(
4424 matching_specs,
4425 query.path_prefix.as_deref(),
4426 HealthResolutionFilter::Stored,
4427 query.scope,
4428 query.start_index,
4429 query.limit,
4430 )?
4431 };
4432 Ok(HealthFindingsPage {
4433 total,
4434 unfiltered_total,
4435 returned: findings.len(),
4436 start_index: query.start_index,
4437 limit: query.limit,
4438 findings,
4439 })
4440 }
4441
4442 pub fn unresolved_health_finding_count_current(&self) -> DbResult<usize> {
4448 self.unresolved_health_findings_page_current(&HealthQuery {
4449 start_index: 0,
4450 limit: 0,
4451 category: None,
4452 severity: None,
4453 path_prefix: None,
4454 summary_only: true,
4455 scope: HealthScope::all(),
4456 })
4457 .map(|page| page.total)
4458 }
4459
4460 fn unresolved_health_findings_page_with_filter(
4462 &self,
4463 resolution_filter: HealthResolutionFilter<'_>,
4464 query: &HealthQuery,
4465 ) -> DbResult<HealthFindingsPage> {
4466 let mut unfiltered_total = 0_usize;
4467 let mut total = 0_usize;
4468 let mut findings = Vec::new();
4469
4470 for spec in PURPOSE_HEALTH_SPECS {
4471 unfiltered_total += self.count_purpose_status_findings(
4472 spec,
4473 None,
4474 resolution_filter,
4475 HealthScope::all(),
4476 )?;
4477 }
4478
4479 let scope = query.scope;
4480 if scope.high_impact_queue() && query.category.is_none() {
4481 let matching_count = if query
4482 .severity
4483 .is_none_or(|severity| severity == Severity::Warning)
4484 {
4485 self.count_purpose_lifecycle_findings(
4486 &PURPOSE_HEALTH_SPECS,
4487 query.path_prefix.as_deref(),
4488 resolution_filter,
4489 scope,
4490 )?
4491 } else {
4492 0
4493 };
4494 if !query.summary_only
4495 && findings.len() < query.limit
4496 && total + matching_count > query.start_index
4497 {
4498 let local_start = query.start_index.saturating_sub(total);
4499 let local_limit = query.limit - findings.len();
4500 findings.extend(self.load_purpose_lifecycle_findings_page(
4501 &PURPOSE_HEALTH_SPECS,
4502 query.path_prefix.as_deref(),
4503 resolution_filter,
4504 scope,
4505 local_start,
4506 local_limit,
4507 )?);
4508 }
4509 total += matching_count;
4510 } else {
4511 for spec in PURPOSE_HEALTH_SPECS {
4512 if !purpose_health_spec_matches_query(spec, query) {
4513 continue;
4514 }
4515
4516 let matching_count = self.count_purpose_status_findings(
4517 spec,
4518 query.path_prefix.as_deref(),
4519 resolution_filter,
4520 scope,
4521 )?;
4522 if !query.summary_only
4523 && findings.len() < query.limit
4524 && total + matching_count > query.start_index
4525 {
4526 let local_start = query.start_index.saturating_sub(total);
4527 let local_limit = query.limit - findings.len();
4528 findings.extend(self.load_purpose_status_findings_page(
4529 spec,
4530 query.path_prefix.as_deref(),
4531 resolution_filter,
4532 scope,
4533 local_start,
4534 local_limit,
4535 )?);
4536 }
4537 total += matching_count;
4538 }
4539 }
4540
4541 for category in STRUCTURAL_HEALTH_CATEGORIES {
4542 let unfiltered_scope = if category == CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED {
4543 HealthScope::purpose_strict()
4544 } else {
4545 HealthScope::all()
4546 };
4547 let unfiltered_count = self.count_structural_health_findings(
4548 category,
4549 None,
4550 resolution_filter,
4551 unfiltered_scope,
4552 )?;
4553 unfiltered_total += unfiltered_count;
4554 if !health_category_matches_query(category, Severity::Warning, query) {
4555 continue;
4556 }
4557 let matching_count = self.count_structural_health_findings(
4558 category,
4559 query.path_prefix.as_deref(),
4560 resolution_filter,
4561 scope,
4562 )?;
4563 if !query.summary_only
4564 && findings.len() < query.limit
4565 && total + matching_count > query.start_index
4566 {
4567 let local_start = query.start_index.saturating_sub(total);
4568 let local_limit = query.limit - findings.len();
4569 findings.extend(self.load_structural_health_findings_page(
4570 category,
4571 query.path_prefix.as_deref(),
4572 resolution_filter,
4573 scope,
4574 local_start,
4575 local_limit,
4576 )?);
4577 }
4578 total += matching_count;
4579 }
4580 Ok(HealthFindingsPage {
4581 total,
4582 unfiltered_total,
4583 returned: findings.len(),
4584 start_index: query.start_index,
4585 limit: query.limit,
4586 findings,
4587 })
4588 }
4589
4590 fn visit_unresolved_health_findings<F>(
4592 &self,
4593 resolved_ids: &[String],
4594 mut visitor: F,
4595 ) -> DbResult<()>
4596 where
4597 F: FnMut(HealthFinding) -> DbResult<bool>,
4598 {
4599 let resolved = resolved_ids.iter().cloned().collect::<HashSet<_>>();
4600 if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[0], &resolved, &mut visitor)? {
4601 return Ok(());
4602 }
4603 if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[1], &resolved, &mut visitor)? {
4604 return Ok(());
4605 }
4606 if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[2], &resolved, &mut visitor)? {
4607 return Ok(());
4608 }
4609 if !self.visit_agent_review_required_findings(&resolved, &mut visitor)? {
4610 return Ok(());
4611 }
4612 self.visit_structural_health_findings(&resolved, &mut visitor)
4613 }
4614
4615 fn visit_structural_health_findings<F>(
4617 &self,
4618 resolved_ids: &HashSet<String>,
4619 mut visitor: F,
4620 ) -> DbResult<()>
4621 where
4622 F: FnMut(HealthFinding) -> DbResult<bool>,
4623 {
4624 if !self.visit_duplicate_purpose_findings(resolved_ids, &mut visitor)? {
4625 return Ok(());
4626 }
4627 if !self.visit_repeated_temp_folder_findings(resolved_ids, &mut visitor)? {
4628 return Ok(());
4629 }
4630 Ok(())
4631 }
4632
4633 fn visit_purpose_status_findings<F>(
4635 &self,
4636 spec: PurposeHealthSpec,
4637 resolved_ids: &HashSet<String>,
4638 visitor: &mut F,
4639 ) -> DbResult<bool>
4640 where
4641 F: FnMut(HealthFinding) -> DbResult<bool>,
4642 {
4643 let mut statement = self.connection.prepare(
4644 "
4645 SELECT n.path
4646 FROM nodes n
4647 JOIN purposes p ON p.node_id = n.id
4648 WHERE n.exists_now = 1
4649 AND p.status = ?1
4650 ORDER BY n.path
4651 ",
4652 )?;
4653 let rows = statement.query_map([spec.status], |row| row.get::<_, String>(0))?;
4654 for row in rows {
4655 let path = row?;
4656 let finding = HealthFinding {
4657 id: finding_id(spec.category, &path, None),
4658 severity: Severity::Warning,
4659 category: spec.category.to_string(),
4660 path,
4661 related_path: None,
4662 message: spec.message.to_string(),
4663 recommendation: spec.recommendation.to_string(),
4664 };
4665 if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
4666 return Ok(false);
4667 }
4668 }
4669 Ok(true)
4670 }
4671
4672 fn count_purpose_lifecycle_findings(
4674 &self,
4675 specs: &[PurposeHealthSpec],
4676 path_prefix: Option<&str>,
4677 resolution_filter: HealthResolutionFilter<'_>,
4678 scope: HealthScope,
4679 ) -> DbResult<usize> {
4680 if specs.is_empty() {
4681 return Ok(0);
4682 }
4683 let (where_clause, values) =
4684 purpose_lifecycle_where_clause(specs, path_prefix, resolution_filter, scope);
4685 let sql = format!(
4686 "
4687 SELECT COUNT(*)
4688 FROM nodes n
4689 JOIN purposes p ON p.node_id = n.id
4690 WHERE {where_clause}
4691 "
4692 );
4693 let count = self
4694 .connection
4695 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
4696 count_to_usize("health_purpose_lifecycle_count", count)
4697 }
4698
4699 fn load_purpose_lifecycle_findings_page(
4701 &self,
4702 specs: &[PurposeHealthSpec],
4703 path_prefix: Option<&str>,
4704 resolution_filter: HealthResolutionFilter<'_>,
4705 scope: HealthScope,
4706 start_index: usize,
4707 limit: usize,
4708 ) -> DbResult<Vec<HealthFinding>> {
4709 if limit == 0 || specs.is_empty() {
4710 return Ok(Vec::new());
4711 }
4712 let (where_clause, mut values) =
4713 purpose_lifecycle_where_clause(specs, path_prefix, resolution_filter, scope);
4714 let limit_placeholder = values.len() + 1;
4715 let offset_placeholder = values.len() + 2;
4716 values.push(Value::from(usize_to_i64(limit)));
4717 values.push(Value::from(usize_to_i64(start_index)));
4718 let order_by = purpose_default_queue_order_expression("n", "p");
4719 let sql = format!(
4720 "
4721 SELECT n.path, p.status
4722 FROM nodes n
4723 JOIN purposes p ON p.node_id = n.id
4724 WHERE {where_clause}
4725 ORDER BY {order_by}
4726 LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
4727 "
4728 );
4729 let mut statement = self.connection.prepare(&sql)?;
4730 let rows = statement.query_map(params_from_iter(values), |row| {
4731 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
4732 })?;
4733 let mut findings = Vec::new();
4734 for row in rows {
4735 let (path, status) = row?;
4736 let spec = purpose_health_spec_for_status(&status)?;
4737 findings.push(HealthFinding {
4738 id: finding_id(spec.category, &path, None),
4739 severity: Severity::Warning,
4740 category: spec.category.to_string(),
4741 path,
4742 related_path: None,
4743 message: spec.message.to_string(),
4744 recommendation: spec.recommendation.to_string(),
4745 });
4746 }
4747 Ok(findings)
4748 }
4749
4750 fn count_purpose_status_findings(
4752 &self,
4753 spec: PurposeHealthSpec,
4754 path_prefix: Option<&str>,
4755 resolution_filter: HealthResolutionFilter<'_>,
4756 scope: HealthScope,
4757 ) -> DbResult<usize> {
4758 let (where_clause, values) =
4759 purpose_status_where_clause(spec, path_prefix, resolution_filter, scope);
4760 let sql = format!(
4761 "
4762 SELECT COUNT(*)
4763 FROM nodes n
4764 JOIN purposes p ON p.node_id = n.id
4765 WHERE {where_clause}
4766 "
4767 );
4768 let count = self
4769 .connection
4770 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
4771 count_to_usize("health_purpose_status_count", count)
4772 }
4773
4774 fn load_purpose_status_findings_page(
4776 &self,
4777 spec: PurposeHealthSpec,
4778 path_prefix: Option<&str>,
4779 resolution_filter: HealthResolutionFilter<'_>,
4780 scope: HealthScope,
4781 start_index: usize,
4782 limit: usize,
4783 ) -> DbResult<Vec<HealthFinding>> {
4784 if limit == 0 {
4785 return Ok(Vec::new());
4786 }
4787 let (where_clause, mut values) =
4788 purpose_status_where_clause(spec, path_prefix, resolution_filter, scope);
4789 let limit_placeholder = values.len() + 1;
4790 let offset_placeholder = values.len() + 2;
4791 values.push(Value::from(usize_to_i64(limit)));
4792 values.push(Value::from(usize_to_i64(start_index)));
4793 let order_by = if scope.high_impact_queue() {
4794 purpose_default_queue_order_expression("n", "p")
4795 } else {
4796 "n.path".to_string()
4797 };
4798 let sql = format!(
4799 "
4800 SELECT n.path
4801 FROM nodes n
4802 JOIN purposes p ON p.node_id = n.id
4803 WHERE {where_clause}
4804 ORDER BY {order_by}
4805 LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
4806 "
4807 );
4808 let mut statement = self.connection.prepare(&sql)?;
4809 let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(0))?;
4810 let mut findings = Vec::new();
4811 for row in rows {
4812 let path = row?;
4813 findings.push(HealthFinding {
4814 id: finding_id(spec.category, &path, None),
4815 severity: Severity::Warning,
4816 category: spec.category.to_string(),
4817 path,
4818 related_path: None,
4819 message: spec.message.to_string(),
4820 recommendation: spec.recommendation.to_string(),
4821 });
4822 }
4823 Ok(findings)
4824 }
4825
4826 fn count_structural_health_findings(
4828 &self,
4829 category: &str,
4830 path_prefix: Option<&str>,
4831 resolution_filter: HealthResolutionFilter<'_>,
4832 scope: HealthScope,
4833 ) -> DbResult<usize> {
4834 match category {
4835 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => {
4836 self.count_agent_review_required_findings(path_prefix, resolution_filter, scope)
4837 }
4838 CATEGORY_DUPLICATE_PURPOSE => {
4839 self.count_duplicate_purpose_findings(path_prefix, resolution_filter, scope)
4840 }
4841 CATEGORY_REPEATED_TEMPORARY_FOLDER => {
4842 self.count_repeated_temp_folder_findings(path_prefix, resolution_filter, scope)
4843 }
4844 _ => Ok(0),
4845 }
4846 }
4847
4848 fn load_structural_health_findings_page(
4850 &self,
4851 category: &str,
4852 path_prefix: Option<&str>,
4853 resolution_filter: HealthResolutionFilter<'_>,
4854 scope: HealthScope,
4855 start_index: usize,
4856 limit: usize,
4857 ) -> DbResult<Vec<HealthFinding>> {
4858 match category {
4859 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => self
4860 .load_agent_review_required_findings_page(
4861 path_prefix,
4862 resolution_filter,
4863 scope,
4864 start_index,
4865 limit,
4866 ),
4867 CATEGORY_DUPLICATE_PURPOSE => self.load_duplicate_purpose_findings_page(
4868 path_prefix,
4869 resolution_filter,
4870 scope,
4871 start_index,
4872 limit,
4873 ),
4874 CATEGORY_REPEATED_TEMPORARY_FOLDER => self.load_repeated_temp_folder_findings_page(
4875 path_prefix,
4876 resolution_filter,
4877 scope,
4878 start_index,
4879 limit,
4880 ),
4881 _ => Ok(Vec::new()),
4882 }
4883 }
4884
4885 fn visit_agent_review_required_findings<F>(
4887 &self,
4888 resolved_ids: &HashSet<String>,
4889 visitor: &mut F,
4890 ) -> DbResult<bool>
4891 where
4892 F: FnMut(HealthFinding) -> DbResult<bool>,
4893 {
4894 let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
4895 let high_impact = high_impact_file_path_expression("lower(n.path)");
4896 let approved_status = PurposeStatus::Approved.as_str();
4897 let sql = format!(
4898 "
4899 SELECT n.path
4900 FROM nodes n
4901 JOIN purposes p ON p.node_id = n.id
4902 WHERE n.exists_now = 1
4903 AND p.status = '{approved_status}'
4904 AND p.source NOT IN ({reviewed_sources})
4905 AND (n.kind = 'folder' OR (n.kind = 'file' AND {high_impact}))
4906 ORDER BY CASE WHEN n.kind = 'folder' THEN 0 ELSE 1 END, n.path
4907 "
4908 );
4909 let mut statement = self.connection.prepare(&sql)?;
4910 let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
4911 for row in rows {
4912 let finding = agent_review_required_finding(row?);
4913 if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
4914 return Ok(false);
4915 }
4916 }
4917 Ok(true)
4918 }
4919
4920 fn count_agent_review_required_findings(
4922 &self,
4923 path_prefix: Option<&str>,
4924 resolution_filter: HealthResolutionFilter<'_>,
4925 scope: HealthScope,
4926 ) -> DbResult<usize> {
4927 let (where_clause, values) = structural_finding_where_clause(
4928 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
4929 path_prefix,
4930 resolution_filter,
4931 scope,
4932 1,
4933 );
4934 let source_relevant = source_relevant_node_expression("n");
4935 let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
4936 let review_candidate = purpose_review_candidate_expression("n", scope);
4937 let approved_status = PurposeStatus::Approved.as_str();
4938 let sql = format!(
4939 "
4940 WITH findings AS (
4941 SELECT n.path,
4942 n.kind,
4943 n.language,
4944 '' AS related_path,
4945 {source_relevant} AS source_relevant
4946 FROM nodes n
4947 JOIN purposes p ON p.node_id = n.id
4948 WHERE n.exists_now = 1
4949 AND p.status = '{approved_status}'
4950 AND p.source NOT IN ({reviewed_sources})
4951 AND {review_candidate}
4952 )
4953 SELECT COUNT(*)
4954 FROM findings
4955 {where_clause}
4956 "
4957 );
4958 let count = self
4959 .connection
4960 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
4961 count_to_usize("health_agent_review_required_count", count)
4962 }
4963
4964 fn load_agent_review_required_findings_page(
4966 &self,
4967 path_prefix: Option<&str>,
4968 resolution_filter: HealthResolutionFilter<'_>,
4969 scope: HealthScope,
4970 start_index: usize,
4971 limit: usize,
4972 ) -> DbResult<Vec<HealthFinding>> {
4973 if limit == 0 {
4974 return Ok(Vec::new());
4975 }
4976 let (where_clause, mut values) = structural_finding_where_clause(
4977 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
4978 path_prefix,
4979 resolution_filter,
4980 scope,
4981 1,
4982 );
4983 let source_relevant = source_relevant_node_expression("n");
4984 let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
4985 let review_candidate = purpose_review_candidate_expression("n", scope);
4986 let approved_status = PurposeStatus::Approved.as_str();
4987 let limit_placeholder = values.len() + 1;
4988 let offset_placeholder = values.len() + 2;
4989 values.push(Value::from(usize_to_i64(limit)));
4990 values.push(Value::from(usize_to_i64(start_index)));
4991 let sql = format!(
4992 "
4993 WITH findings AS (
4994 SELECT n.path,
4995 n.kind,
4996 n.language,
4997 '' AS related_path,
4998 {source_relevant} AS source_relevant
4999 FROM nodes n
5000 JOIN purposes p ON p.node_id = n.id
5001 WHERE n.exists_now = 1
5002 AND p.status = '{approved_status}'
5003 AND p.source NOT IN ({reviewed_sources})
5004 AND {review_candidate}
5005 )
5006 SELECT path
5007 FROM findings
5008 {where_clause}
5009 ORDER BY CASE WHEN kind = 'folder' THEN 0 ELSE 1 END, path
5010 LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5011 "
5012 );
5013 let mut statement = self.connection.prepare(&sql)?;
5014 let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(0))?;
5015 let mut findings = Vec::new();
5016 for row in rows {
5017 findings.push(agent_review_required_finding(row?));
5018 }
5019 Ok(findings)
5020 }
5021
5022 fn count_duplicate_purpose_findings(
5024 &self,
5025 path_prefix: Option<&str>,
5026 resolution_filter: HealthResolutionFilter<'_>,
5027 scope: HealthScope,
5028 ) -> DbResult<usize> {
5029 let (where_clause, values) = structural_finding_where_clause(
5030 CATEGORY_DUPLICATE_PURPOSE,
5031 path_prefix,
5032 resolution_filter,
5033 scope,
5034 1,
5035 );
5036 let source_relevant = source_relevant_node_expression("n");
5037 let duplicate_scope =
5038 "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5039 let approved_status = PurposeStatus::Approved.as_str();
5040 let sql = format!(
5041 "
5042 WITH duplicate_rows AS (
5043 SELECT n.path,
5044 n.kind,
5045 n.language,
5046 p.purpose,
5047 {source_relevant} AS source_relevant,
5048 FIRST_VALUE(n.path) OVER (
5049 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5050 ORDER BY n.path
5051 ) AS related_path,
5052 ROW_NUMBER() OVER (
5053 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5054 ORDER BY n.path
5055 ) AS duplicate_rank,
5056 COUNT(*) OVER (
5057 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5058 ) AS duplicate_count
5059 FROM nodes n
5060 JOIN purposes p ON p.node_id = n.id
5061 WHERE n.exists_now = 1
5062 AND p.status = '{approved_status}'
5063 AND p.purpose IS NOT NULL
5064 ),
5065 findings AS (
5066 SELECT path, kind, language, purpose, related_path, source_relevant
5067 FROM duplicate_rows
5068 WHERE duplicate_count > 1
5069 AND duplicate_rank > 1
5070 )
5071 SELECT COUNT(*)
5072 FROM findings
5073 {where_clause}
5074 "
5075 );
5076 let count = self
5077 .connection
5078 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5079 count_to_usize("health_duplicate_purpose_count", count)
5080 }
5081
5082 fn load_duplicate_purpose_findings_page(
5084 &self,
5085 path_prefix: Option<&str>,
5086 resolution_filter: HealthResolutionFilter<'_>,
5087 scope: HealthScope,
5088 start_index: usize,
5089 limit: usize,
5090 ) -> DbResult<Vec<HealthFinding>> {
5091 if limit == 0 {
5092 return Ok(Vec::new());
5093 }
5094 let (where_clause, mut values) = structural_finding_where_clause(
5095 CATEGORY_DUPLICATE_PURPOSE,
5096 path_prefix,
5097 resolution_filter,
5098 scope,
5099 1,
5100 );
5101 let source_relevant = source_relevant_node_expression("n");
5102 let duplicate_scope =
5103 "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5104 let approved_status = PurposeStatus::Approved.as_str();
5105 let limit_placeholder = values.len() + 1;
5106 let offset_placeholder = values.len() + 2;
5107 values.push(Value::from(usize_to_i64(limit)));
5108 values.push(Value::from(usize_to_i64(start_index)));
5109 let sql = format!(
5110 "
5111 WITH duplicate_rows AS (
5112 SELECT n.path,
5113 n.kind,
5114 n.language,
5115 p.purpose,
5116 {source_relevant} AS source_relevant,
5117 FIRST_VALUE(n.path) OVER (
5118 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5119 ORDER BY n.path
5120 ) AS related_path,
5121 ROW_NUMBER() OVER (
5122 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5123 ORDER BY n.path
5124 ) AS duplicate_rank,
5125 COUNT(*) OVER (
5126 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5127 ) AS duplicate_count
5128 FROM nodes n
5129 JOIN purposes p ON p.node_id = n.id
5130 WHERE n.exists_now = 1
5131 AND p.status = '{approved_status}'
5132 AND p.purpose IS NOT NULL
5133 ),
5134 findings AS (
5135 SELECT path, kind, language, purpose, related_path, source_relevant
5136 FROM duplicate_rows
5137 WHERE duplicate_count > 1
5138 AND duplicate_rank > 1
5139 )
5140 SELECT path, kind, related_path
5141 FROM findings
5142 {where_clause}
5143 ORDER BY kind, lower(purpose), path
5144 LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5145 "
5146 );
5147 let mut statement = self.connection.prepare(&sql)?;
5148 let rows = statement.query_map(params_from_iter(values), |row| {
5149 Ok((
5150 row.get::<_, String>(0)?,
5151 row.get::<_, String>(1)?,
5152 row.get::<_, String>(2)?,
5153 ))
5154 })?;
5155 let mut findings = Vec::new();
5156 for row in rows {
5157 let (path, kind_value, related_path) = row?;
5158 let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
5159 field: "kind",
5160 value: kind_value,
5161 })?;
5162 findings.push(HealthFinding {
5163 id: finding_id(CATEGORY_DUPLICATE_PURPOSE, &path, Some(&related_path)),
5164 severity: Severity::Warning,
5165 category: CATEGORY_DUPLICATE_PURPOSE.to_string(),
5166 path,
5167 related_path: Some(related_path),
5168 message: format!("Multiple {kind} nodes share the same purpose."),
5169 recommendation: RECOMMENDATION_DUPLICATE_PURPOSE.to_string(),
5170 });
5171 }
5172 Ok(findings)
5173 }
5174
5175 fn count_repeated_temp_folder_findings(
5177 &self,
5178 path_prefix: Option<&str>,
5179 resolution_filter: HealthResolutionFilter<'_>,
5180 scope: HealthScope,
5181 ) -> DbResult<usize> {
5182 let mut total = 0_usize;
5183 for bucket in TEMP_FOLDER_BUCKETS {
5184 total += self.count_repeated_temp_folder_bucket_findings(
5185 bucket,
5186 path_prefix,
5187 resolution_filter,
5188 scope,
5189 )?;
5190 }
5191 Ok(total)
5192 }
5193
5194 fn count_repeated_temp_folder_bucket_findings(
5196 &self,
5197 bucket: &str,
5198 path_prefix: Option<&str>,
5199 resolution_filter: HealthResolutionFilter<'_>,
5200 scope: HealthScope,
5201 ) -> DbResult<usize> {
5202 let exact = bucket.to_string();
5203 let suffix = format!("%/{bucket}");
5204 let (where_clause, mut filter_values) = structural_finding_where_clause(
5205 CATEGORY_REPEATED_TEMPORARY_FOLDER,
5206 path_prefix,
5207 resolution_filter,
5208 scope,
5209 3,
5210 );
5211 let mut values = vec![Value::from(exact), Value::from(suffix)];
5212 values.append(&mut filter_values);
5213 let source_relevant = source_relevant_node_expression("n");
5214 let sql = format!(
5215 "
5216 WITH bucket_rows AS (
5217 SELECT n.path,
5218 n.kind,
5219 n.language,
5220 {source_relevant} AS source_relevant,
5221 FIRST_VALUE(n.path) OVER (ORDER BY n.path) AS related_path,
5222 ROW_NUMBER() OVER (ORDER BY path) AS duplicate_rank,
5223 COUNT(*) OVER () AS duplicate_count
5224 FROM nodes n
5225 WHERE n.exists_now = 1
5226 AND n.kind = 'folder'
5227 AND (lower(n.path) = ?1 OR lower(n.path) LIKE ?2)
5228 ),
5229 findings AS (
5230 SELECT path, kind, language, related_path, source_relevant
5231 FROM bucket_rows
5232 WHERE duplicate_count > 1
5233 AND duplicate_rank > 1
5234 )
5235 SELECT COUNT(*)
5236 FROM findings
5237 {where_clause}
5238 "
5239 );
5240 let count = self
5241 .connection
5242 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5243 count_to_usize("health_repeated_temp_count", count)
5244 }
5245
5246 fn load_repeated_temp_folder_findings_page(
5248 &self,
5249 path_prefix: Option<&str>,
5250 resolution_filter: HealthResolutionFilter<'_>,
5251 scope: HealthScope,
5252 start_index: usize,
5253 limit: usize,
5254 ) -> DbResult<Vec<HealthFinding>> {
5255 if limit == 0 {
5256 return Ok(Vec::new());
5257 }
5258 let mut total = 0_usize;
5259 let mut findings = Vec::new();
5260 for bucket in TEMP_FOLDER_BUCKETS {
5261 let matching_count = self.count_repeated_temp_folder_bucket_findings(
5262 bucket,
5263 path_prefix,
5264 resolution_filter,
5265 scope,
5266 )?;
5267 if findings.len() < limit && total + matching_count > start_index {
5268 let local_start = start_index.saturating_sub(total);
5269 let local_limit = limit - findings.len();
5270 findings.extend(self.load_repeated_temp_folder_bucket_findings_page(
5271 bucket,
5272 path_prefix,
5273 resolution_filter,
5274 scope,
5275 local_start,
5276 local_limit,
5277 )?);
5278 }
5279 total += matching_count;
5280 if findings.len() >= limit {
5281 break;
5282 }
5283 }
5284 Ok(findings)
5285 }
5286
5287 fn load_repeated_temp_folder_bucket_findings_page(
5289 &self,
5290 bucket: &str,
5291 path_prefix: Option<&str>,
5292 resolution_filter: HealthResolutionFilter<'_>,
5293 scope: HealthScope,
5294 start_index: usize,
5295 limit: usize,
5296 ) -> DbResult<Vec<HealthFinding>> {
5297 if limit == 0 {
5298 return Ok(Vec::new());
5299 }
5300 let exact = bucket.to_string();
5301 let suffix = format!("%/{bucket}");
5302 let (where_clause, mut filter_values) = structural_finding_where_clause(
5303 CATEGORY_REPEATED_TEMPORARY_FOLDER,
5304 path_prefix,
5305 resolution_filter,
5306 scope,
5307 3,
5308 );
5309 let mut values = vec![Value::from(exact), Value::from(suffix)];
5310 values.append(&mut filter_values);
5311 let source_relevant = source_relevant_node_expression("n");
5312 let limit_placeholder = values.len() + 1;
5313 let offset_placeholder = values.len() + 2;
5314 values.push(Value::from(usize_to_i64(limit)));
5315 values.push(Value::from(usize_to_i64(start_index)));
5316 let sql = format!(
5317 "
5318 WITH bucket_rows AS (
5319 SELECT n.path,
5320 n.kind,
5321 n.language,
5322 {source_relevant} AS source_relevant,
5323 FIRST_VALUE(n.path) OVER (ORDER BY n.path) AS related_path,
5324 ROW_NUMBER() OVER (ORDER BY n.path) AS duplicate_rank,
5325 COUNT(*) OVER () AS duplicate_count
5326 FROM nodes n
5327 WHERE n.exists_now = 1
5328 AND n.kind = 'folder'
5329 AND (lower(n.path) = ?1 OR lower(n.path) LIKE ?2)
5330 ),
5331 findings AS (
5332 SELECT path, kind, language, related_path, source_relevant
5333 FROM bucket_rows
5334 WHERE duplicate_count > 1
5335 AND duplicate_rank > 1
5336 )
5337 SELECT path, related_path
5338 FROM findings
5339 {where_clause}
5340 ORDER BY path
5341 LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5342 "
5343 );
5344 let mut statement = self.connection.prepare(&sql)?;
5345 let rows = statement.query_map(params_from_iter(values), |row| {
5346 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5347 })?;
5348 let mut findings = Vec::new();
5349 for row in rows {
5350 let (path, related_path) = row?;
5351 findings.push(HealthFinding {
5352 id: finding_id(
5353 CATEGORY_REPEATED_TEMPORARY_FOLDER,
5354 &path,
5355 Some(&related_path),
5356 ),
5357 severity: Severity::Warning,
5358 category: CATEGORY_REPEATED_TEMPORARY_FOLDER.to_string(),
5359 path,
5360 related_path: Some(related_path),
5361 message: format!("Repeated temporary/generated folder name `{bucket}` found."),
5362 recommendation: RECOMMENDATION_REPEATED_TEMPORARY_FOLDER.to_string(),
5363 });
5364 }
5365 Ok(findings)
5366 }
5367
5368 fn visit_duplicate_purpose_findings<F>(
5370 &self,
5371 resolved_ids: &HashSet<String>,
5372 visitor: &mut F,
5373 ) -> DbResult<bool>
5374 where
5375 F: FnMut(HealthFinding) -> DbResult<bool>,
5376 {
5377 let duplicate_scope =
5378 "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5379 let approved_status = PurposeStatus::Approved.as_str();
5380 let sql = format!(
5381 "
5382 WITH duplicate_rows AS (
5383 SELECT n.path,
5384 n.kind,
5385 p.purpose,
5386 FIRST_VALUE(n.path) OVER (
5387 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5388 ORDER BY n.path
5389 ) AS related_path,
5390 ROW_NUMBER() OVER (
5391 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5392 ORDER BY n.path
5393 ) AS duplicate_rank,
5394 COUNT(*) OVER (
5395 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5396 ) AS duplicate_count
5397 FROM nodes n
5398 JOIN purposes p ON p.node_id = n.id
5399 WHERE n.exists_now = 1
5400 AND p.status = '{approved_status}'
5401 AND p.purpose IS NOT NULL
5402 )
5403 SELECT path, kind, purpose, related_path
5404 FROM duplicate_rows
5405 WHERE duplicate_count > 1
5406 AND duplicate_rank > 1
5407 ORDER BY kind, lower(purpose), path
5408 "
5409 );
5410 let mut statement = self.connection.prepare(&sql)?;
5411 let rows = statement.query_map([], |row| {
5412 Ok((
5413 row.get::<_, String>(0)?,
5414 row.get::<_, String>(1)?,
5415 row.get::<_, String>(2)?,
5416 row.get::<_, String>(3)?,
5417 ))
5418 })?;
5419 for row in rows {
5420 let (path, kind_value, _purpose, related_path) = row?;
5421 let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
5422 field: "kind",
5423 value: kind_value.clone(),
5424 })?;
5425 let finding = HealthFinding {
5426 id: finding_id(CATEGORY_DUPLICATE_PURPOSE, &path, Some(&related_path)),
5427 severity: Severity::Warning,
5428 category: CATEGORY_DUPLICATE_PURPOSE.to_string(),
5429 path,
5430 related_path: Some(related_path),
5431 message: format!("Multiple {kind} nodes share the same purpose."),
5432 recommendation: RECOMMENDATION_DUPLICATE_PURPOSE.to_string(),
5433 };
5434 if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
5435 return Ok(false);
5436 }
5437 }
5438 Ok(true)
5439 }
5440
5441 fn visit_repeated_temp_folder_findings<F>(
5443 &self,
5444 resolved_ids: &HashSet<String>,
5445 visitor: &mut F,
5446 ) -> DbResult<bool>
5447 where
5448 F: FnMut(HealthFinding) -> DbResult<bool>,
5449 {
5450 for bucket in TEMP_FOLDER_BUCKETS {
5451 let exact = bucket.to_string();
5452 let suffix = format!("%/{bucket}");
5453 let mut statement = self.connection.prepare(
5454 "
5455 SELECT path
5456 FROM nodes
5457 WHERE exists_now = 1
5458 AND kind = 'folder'
5459 AND (lower(path) = ?1 OR lower(path) LIKE ?2)
5460 ORDER BY path
5461 ",
5462 )?;
5463 let rows =
5464 statement.query_map(params![exact, suffix], |row| row.get::<_, String>(0))?;
5465 let mut first_path = None;
5466 for row in rows {
5467 let path = row?;
5468 let Some(first_path) = first_path.as_ref() else {
5469 first_path = Some(path);
5470 continue;
5471 };
5472 let finding = HealthFinding {
5473 id: finding_id(
5474 CATEGORY_REPEATED_TEMPORARY_FOLDER,
5475 &path,
5476 Some(first_path.as_str()),
5477 ),
5478 severity: Severity::Warning,
5479 category: CATEGORY_REPEATED_TEMPORARY_FOLDER.to_string(),
5480 path,
5481 related_path: Some(first_path.clone()),
5482 message: format!("Repeated temporary/generated folder name `{bucket}` found."),
5483 recommendation: RECOMMENDATION_REPEATED_TEMPORARY_FOLDER.to_string(),
5484 };
5485 if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
5486 return Ok(false);
5487 }
5488 }
5489 }
5490 Ok(true)
5491 }
5492
5493 pub fn overview(&self) -> DbResult<Overview> {
5499 let missing_status = PurposeStatus::Missing.as_str();
5500 let stale_status = PurposeStatus::Stale.as_str();
5501 let approved_status = PurposeStatus::Approved.as_str();
5502 let suggested_status = PurposeStatus::Suggested.as_str();
5503 let sql = format!(
5504 "
5505 SELECT
5506 COALESCE(SUM(CASE WHEN n.kind = 'file' THEN 1 ELSE 0 END), 0),
5507 COALESCE(SUM(CASE WHEN n.kind = 'folder' THEN 1 ELSE 0 END), 0),
5508 COALESCE(SUM(CASE WHEN p.status = '{missing_status}' THEN 1 ELSE 0 END), 0),
5509 COALESCE(SUM(CASE WHEN p.status = '{stale_status}' THEN 1 ELSE 0 END), 0),
5510 COALESCE(SUM(CASE WHEN p.status = '{approved_status}' THEN 1 ELSE 0 END), 0),
5511 COALESCE(SUM(CASE WHEN p.status = '{suggested_status}' THEN 1 ELSE 0 END), 0)
5512 FROM nodes n
5513 JOIN purposes p ON p.node_id = n.id
5514 WHERE n.exists_now = 1
5515 "
5516 );
5517 let counts = self.connection.query_row(&sql, [], |row| {
5518 Ok((
5519 row.get::<_, i64>(0)?,
5520 row.get::<_, i64>(1)?,
5521 row.get::<_, i64>(2)?,
5522 row.get::<_, i64>(3)?,
5523 row.get::<_, i64>(4)?,
5524 row.get::<_, i64>(5)?,
5525 ))
5526 })?;
5527 Ok(Overview {
5528 files: count_to_usize("files", counts.0)?,
5529 folders: count_to_usize("folders", counts.1)?,
5530 missing_purposes: count_to_usize("missing_purposes", counts.2)?,
5531 stale_purposes: count_to_usize("stale_purposes", counts.3)?,
5532 approved_purposes: count_to_usize("approved_purposes", counts.4)?,
5533 suggested_purposes: count_to_usize("suggested_purposes", counts.5)?,
5534 })
5535 }
5536
5537 pub fn record_usage(&self, event: &UsageEvent) -> DbResult<()> {
5543 telemetry::validate_event(event, TelemetryRetentionPolicy::default())?;
5544 let instance_id = self.library_usage_instance(&event.session_id, false)?;
5545 match self.record_usage_for_instance(
5546 instance_id,
5547 UsageInstanceOwner::LibraryHandle,
5548 event,
5549 false,
5550 ) {
5551 Err(DbError::TelemetryInstanceInactive) => {
5552 let replacement = self.library_usage_instance(&event.session_id, true)?;
5553 self.record_usage_for_instance(
5554 replacement,
5555 UsageInstanceOwner::LibraryHandle,
5556 event,
5557 false,
5558 )
5559 }
5560 Err(DbError::TelemetryBaselineCapacity) => {
5561 let replacement = telemetry::generate_usage_instance_id()?;
5562 self.seal_usage_instance(instance_id)?;
5563 self.library_usage_instances
5564 .borrow_mut()
5565 .insert(event.session_id.clone(), Some(replacement));
5566 self.record_usage_for_instance(
5567 replacement,
5568 UsageInstanceOwner::LibraryHandle,
5569 event,
5570 false,
5571 )
5572 }
5573 result => result,
5574 }
5575 }
5576
5577 fn library_usage_instance(
5579 &self,
5580 caller_label: &str,
5581 rotate: bool,
5582 ) -> DbResult<UsageInstanceId> {
5583 let mut instances = self.library_usage_instances.borrow_mut();
5584 if !rotate && let Some(instance) = instances.get(caller_label) {
5585 return instance.ok_or(DbError::TelemetryIdentityUnavailable);
5586 }
5587 if !instances.contains_key(caller_label)
5588 && instances.len() >= TelemetryRetentionPolicy::default().max_retained_labels
5589 {
5590 return Err(DbError::TelemetryInstanceCapacity);
5591 }
5592 let instance = telemetry::generate_usage_instance_id().ok();
5593 instances.insert(caller_label.to_string(), instance);
5594 instance.ok_or(DbError::TelemetryIdentityUnavailable)
5595 }
5596
5597 pub fn record_usage_for_instance(
5608 &self,
5609 instance_id: UsageInstanceId,
5610 owner: UsageInstanceOwner,
5611 event: &UsageEvent,
5612 seal_after_record: bool,
5613 ) -> DbResult<()> {
5614 let policy = TelemetryRetentionPolicy::default();
5615 let project_instance_id = self
5616 .validated_project_instance_id
5617 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
5618 self.with_telemetry_connection(|connection| {
5619 let transaction =
5620 rusqlite::Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?;
5621 let operation = schema::validate_active_binding(
5622 &transaction,
5623 self.validated_project_root.as_deref(),
5624 Some(project_instance_id),
5625 )
5626 .and_then(|()| {
5627 telemetry::record_usage_for_project(
5628 &transaction,
5629 project_instance_id,
5630 instance_id,
5631 owner,
5632 event,
5633 policy,
5634 seal_after_record,
5635 )
5636 });
5637 match operation {
5638 Ok(()) => transaction.commit().map_err(DbError::from),
5639 Err(operation) => match transaction.rollback() {
5640 Ok(()) => Err(operation),
5641 Err(rollback) => Err(DbError::TransactionRollback {
5642 operation: Box::new(operation),
5643 rollback,
5644 }),
5645 },
5646 }?;
5647 drop(telemetry::maintain_after_commit_for_project(
5651 connection,
5652 project_instance_id,
5653 policy,
5654 ));
5655 Ok(())
5656 })
5657 }
5658
5659 pub fn seal_usage_instance(&self, instance_id: UsageInstanceId) -> DbResult<()> {
5666 self.with_telemetry_connection(|connection| {
5667 let transaction =
5668 rusqlite::Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?;
5669 let operation = schema::validate_active_binding(
5670 &transaction,
5671 self.validated_project_root.as_deref(),
5672 self.validated_project_instance_id,
5673 )
5674 .and_then(|()| telemetry::seal_usage_instance(&transaction, instance_id));
5675 match operation {
5676 Ok(()) => transaction.commit().map_err(Into::into),
5677 Err(operation) => match transaction.rollback() {
5678 Ok(()) => Err(operation),
5679 Err(rollback) => Err(DbError::TransactionRollback {
5680 operation: Box::new(operation),
5681 rollback,
5682 }),
5683 },
5684 }
5685 })
5686 }
5687
5688 pub fn telemetry_retention_state(&self) -> DbResult<TelemetryRetentionState> {
5694 telemetry::retention_state(&self.connection)
5695 }
5696
5697 pub fn usage_events(&self, session_id: Option<&str>) -> DbResult<Vec<UsageEvent>> {
5703 telemetry::usage_events(&self.connection, session_id)
5704 }
5705
5706 pub fn token_overview(&self, session_id: Option<&str>) -> DbResult<TokenOverview> {
5712 telemetry::token_overview(&self.connection, session_id)
5713 }
5714
5715 pub fn token_trends(
5721 &self,
5722 session_id: Option<&str>,
5723 window: TokenTrendWindow,
5724 ) -> DbResult<TokenTrendReport> {
5725 telemetry::token_trends(&self.connection, session_id, window)
5726 }
5727
5728 pub fn resolve_health_finding(&self, resolution: &HealthResolution) -> DbResult<()> {
5734 self.with_validated_write(|connection| {
5735 if !self.active_health_finding_matches(resolution)? {
5736 return Err(DbError::HealthFindingNotActive {
5737 finding_id: resolution.finding_id.clone(),
5738 category: resolution.category.clone(),
5739 path: resolution.path.clone(),
5740 });
5741 }
5742 connection.execute(
5743 "
5744 INSERT INTO health_resolutions(
5745 finding_id,
5746 category,
5747 path,
5748 related_path,
5749 rationale,
5750 resolved_by,
5751 resolved_at
5752 )
5753 VALUES(?1, ?2, ?3, ?4, ?5, 'agent', CURRENT_TIMESTAMP)
5754 ON CONFLICT(finding_id) DO UPDATE SET
5755 category = excluded.category,
5756 path = excluded.path,
5757 related_path = excluded.related_path,
5758 rationale = excluded.rationale,
5759 resolved_by = 'agent',
5760 resolved_at = CURRENT_TIMESTAMP
5761 ",
5762 params![
5763 resolution.finding_id,
5764 resolution.category,
5765 resolution.path,
5766 resolution.related_path,
5767 resolution.rationale,
5768 ],
5769 )?;
5770 Ok(())
5771 })
5772 }
5773
5774 fn active_health_finding_matches(&self, resolution: &HealthResolution) -> DbResult<bool> {
5776 const PAGE_SIZE: usize = 256;
5777 let mut start_index = 0_usize;
5778 loop {
5779 let page = self.unresolved_health_findings_page_current(&HealthQuery {
5780 start_index,
5781 limit: PAGE_SIZE,
5782 category: Some(resolution.category.clone()),
5783 severity: Some(Severity::Warning),
5784 path_prefix: Some(resolution.path.clone()),
5785 summary_only: false,
5786 scope: HealthScope::all(),
5787 })?;
5788 if page.findings.iter().any(|finding| {
5789 finding.id == resolution.finding_id
5790 && finding.category == resolution.category
5791 && finding.path == resolution.path
5792 && finding.related_path == resolution.related_path
5793 }) {
5794 return Ok(true);
5795 }
5796 if page.returned == 0 || start_index + page.returned >= page.total {
5797 return Ok(false);
5798 }
5799 start_index += page.returned;
5800 }
5801 }
5802
5803 pub fn resolved_health_ids(&self) -> DbResult<Vec<String>> {
5809 let mut statement = self
5810 .connection
5811 .prepare("SELECT finding_id FROM health_resolutions ORDER BY finding_id")?;
5812 let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
5813 let mut ids = Vec::new();
5814 for row in rows {
5815 ids.push(row?);
5816 }
5817 Ok(ids)
5818 }
5819}
5820
5821fn begin_immediate_publication(connection: &Connection) -> DbResult<()> {
5823 connection.busy_timeout(SQLITE_PUBLICATION_ACQUIRE_TIMEOUT)?;
5824 let begin_result = connection.execute_batch("BEGIN IMMEDIATE");
5825 let restore_result = connection.busy_timeout(SQLITE_BUSY_TIMEOUT);
5826 match (begin_result, restore_result) {
5827 (Ok(()), Ok(())) => Ok(()),
5828 (Err(error), Ok(())) => Err(error.into()),
5829 (Ok(()), Err(error)) => Err(schema::rollback_after_error(connection, error.into())),
5830 (Err(operation), Err(restore)) => Err(DbError::PublicationAcquirePolicyRestore {
5831 operation: Box::new(operation),
5832 restore: Box::new(restore),
5833 }),
5834 }
5835}
5836
5837pub fn read_project_root_read_only(path: &Path) -> DbResult<Option<String>> {
5843 schema::read_project_root(path)
5844}
5845
5846pub fn verify_project_database(path: &Path, project_root: &Path) -> DbResult<()> {
5853 schema::verify_current_integrity(path, Some(&normalize_native_path_display(project_root)))
5854}
5855
5856fn normalize_metadata_path(path: &Path) -> String {
5858 normalize_native_path_display(path)
5859}
5860
5861fn set_metadata(connection: &Connection, key: &str, value: &str) -> DbResult<()> {
5863 connection.execute(
5864 "
5865 INSERT INTO metadata(key, value)
5866 VALUES(?1, ?2)
5867 ON CONFLICT(key) DO UPDATE SET value = excluded.value
5868 ",
5869 [key, value],
5870 )?;
5871 Ok(())
5872}
5873
5874fn load_authored_purpose_revision(connection: &Connection) -> DbResult<u64> {
5876 let value = connection
5877 .prepare_cached("SELECT value FROM metadata WHERE key = ?1")?
5878 .query_row([AUTHORED_PURPOSE_REVISION_KEY], |row| {
5879 row.get::<_, String>(0)
5880 })
5881 .optional()?;
5882 value.map_or(Ok(0), |value| {
5883 value
5884 .parse::<u64>()
5885 .map_err(|source| DbError::InvalidInteger {
5886 field: AUTHORED_PURPOSE_REVISION_KEY,
5887 value,
5888 source,
5889 })
5890 })
5891}
5892
5893fn advance_authored_purpose_revision(connection: &Connection) -> DbResult<u64> {
5895 let current = load_authored_purpose_revision(connection)?;
5896 let revision = current
5897 .checked_add(1)
5898 .ok_or(DbError::IntegerMetadataOverflow {
5899 field: AUTHORED_PURPOSE_REVISION_KEY,
5900 value: current,
5901 })?;
5902 set_metadata(
5903 connection,
5904 AUTHORED_PURPOSE_REVISION_KEY,
5905 &revision.to_string(),
5906 )?;
5907 Ok(revision)
5908}
5909
5910fn load_file_text_fts_revisions(connection: &Connection) -> DbResult<Option<(u64, u64)>> {
5912 let (source, projection) = connection.query_row(
5913 "
5914 SELECT
5915 (SELECT value FROM metadata WHERE key = ?1),
5916 (SELECT value FROM metadata WHERE key = ?2)
5917 ",
5918 params![
5919 FILE_TEXT_FTS_SOURCE_REVISION_KEY,
5920 FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
5921 ],
5922 |row| {
5923 Ok((
5924 row.get::<_, Option<String>>(0)?,
5925 row.get::<_, Option<String>>(1)?,
5926 ))
5927 },
5928 )?;
5929 if source.is_none() && projection.is_none() {
5930 return Ok(None);
5931 }
5932 let (Some(source), Some(projection)) = (source, projection) else {
5933 return Err(DbError::FileTextFtsStateInvalid {
5934 reason: "only one FTS revision is present",
5935 });
5936 };
5937 let source_revision =
5938 source
5939 .parse::<u64>()
5940 .map_err(|source_error| DbError::InvalidInteger {
5941 field: FILE_TEXT_FTS_SOURCE_REVISION_KEY,
5942 value: source,
5943 source: source_error,
5944 })?;
5945 let projection_revision =
5946 projection
5947 .parse::<u64>()
5948 .map_err(|source_error| DbError::InvalidInteger {
5949 field: FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
5950 value: projection,
5951 source: source_error,
5952 })?;
5953 Ok(Some((source_revision, projection_revision)))
5954}
5955
5956fn begin_file_text_fts_update(connection: &Connection) -> DbResult<u64> {
5958 let (source, projection) =
5959 load_file_text_fts_revisions(connection)?.ok_or(DbError::FileTextFtsStateInvalid {
5960 reason: "FTS revisions are missing",
5961 })?;
5962 if source != projection {
5963 return Err(DbError::FileTextFtsStateInvalid {
5964 reason: "an earlier FTS revision is incomplete",
5965 });
5966 }
5967 let revision = source
5968 .checked_add(1)
5969 .ok_or(DbError::FileTextFtsStateInvalid {
5970 reason: "FTS revision overflowed",
5971 })?;
5972 set_metadata(
5973 connection,
5974 FILE_TEXT_FTS_SOURCE_REVISION_KEY,
5975 &revision.to_string(),
5976 )?;
5977 Ok(revision)
5978}
5979
5980fn complete_file_text_fts_update(connection: &Connection, revision: u64) -> DbResult<()> {
5982 let (source, projection) =
5983 load_file_text_fts_revisions(connection)?.ok_or(DbError::FileTextFtsStateInvalid {
5984 reason: "FTS revisions disappeared during an update",
5985 })?;
5986 if source != revision || projection.checked_add(1) != Some(revision) {
5987 return Err(DbError::FileTextFtsStateInvalid {
5988 reason: "FTS revisions changed during an update",
5989 });
5990 }
5991 set_metadata(
5992 connection,
5993 FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
5994 &revision.to_string(),
5995 )
5996}
5997
5998#[cfg(test)]
6000fn record_released_schema_eight_usage_event(
6001 connection: &Connection,
6002 event: &UsageEvent,
6003) -> DbResult<()> {
6004 connection.execute(
6005 "
6006 INSERT INTO usage_events(
6007 session_id,
6008 command,
6009 path,
6010 query,
6011 estimated_tokens_without_projectatlas,
6012 estimated_tokens_with_projectatlas,
6013 estimated_tokens_saved,
6014 token_savings_bucket,
6015 provider,
6016 model,
6017 tokenizer_backend,
6018 accuracy,
6019 baseline_kind,
6020 confidence,
6021 calculation_trace,
6022 accounting_layer,
6023 estimate_method,
6024 denominator_kind,
6025 baseline_identity,
6026 baseline_fingerprint,
6027 dedupe_scope,
6028 created_at
6029 )
6030 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, CURRENT_TIMESTAMP)
6031 ",
6032 params![
6033 event.session_id,
6034 event.command,
6035 event.path,
6036 event.query,
6037 event.estimated_tokens_without_projectatlas,
6038 event.estimated_tokens_with_projectatlas,
6039 event.estimated_tokens_saved,
6040 event.token_savings_bucket,
6041 event.provider,
6042 event.model,
6043 event.tokenizer_backend,
6044 event.accuracy,
6045 event.baseline_kind,
6046 event.confidence,
6047 event.calculation_trace,
6048 event.accounting_layer,
6049 event.estimate_method,
6050 event.denominator_kind,
6051 event.baseline_identity,
6052 event.baseline_fingerprint,
6053 event.dedupe_scope
6054 ],
6055 )?;
6056 Ok(())
6057}
6058
6059fn load_index_publication(connection: &Connection) -> DbResult<Option<IndexPublication>> {
6061 let row = connection
6062 .query_row(
6063 "
6064 SELECT state.value, fingerprint.value, generation.value
6065 FROM metadata AS state
6066 LEFT JOIN metadata AS fingerprint ON fingerprint.key = ?2
6067 LEFT JOIN metadata AS generation ON generation.key = ?3
6068 WHERE state.key = ?1
6069 ",
6070 params![
6071 INDEX_PUBLICATION_STATE_KEY,
6072 INDEX_PUBLICATION_FINGERPRINT_KEY,
6073 INDEX_PUBLICATION_GENERATION_KEY,
6074 ],
6075 |row| {
6076 Ok((
6077 row.get::<_, String>(0)?,
6078 row.get::<_, Option<String>>(1)?,
6079 row.get::<_, Option<String>>(2)?,
6080 ))
6081 },
6082 )
6083 .optional()?;
6084 let Some((state, contract_fingerprint, generation)) = row else {
6085 return Ok(None);
6086 };
6087 let generation = generation.map_or(Ok(IndexGeneration::ZERO), |value| {
6088 value
6089 .parse::<u64>()
6090 .map(IndexGeneration::new)
6091 .map_err(|source| DbError::InvalidInteger {
6092 field: INDEX_PUBLICATION_GENERATION_KEY,
6093 value,
6094 source,
6095 })
6096 })?;
6097 Ok(Some(IndexPublication {
6098 state: IndexPublicationState::from_db(state)?,
6099 contract_fingerprint,
6100 generation,
6101 }))
6102}
6103
6104fn mark_all_scan_nodes_absent(connection: &Connection) -> DbResult<()> {
6106 connection.execute("UPDATE nodes SET exists_now = 0", [])?;
6107 Ok(())
6108}
6109
6110fn delete_absent_scan_projections(connection: &Connection) -> DbResult<()> {
6112 let fts_revision = begin_file_text_fts_update(connection)?;
6113 connection.execute(
6114 "DELETE FROM symbol_relations WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6115 [],
6116 )?;
6117 connection.execute(
6118 "DELETE FROM symbols WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6119 [],
6120 )?;
6121 connection.execute(
6122 "DELETE FROM source_parse_metadata WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6123 [],
6124 )?;
6125 connection.execute(
6126 "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
6127 SELECT 'delete', rowid, content FROM file_texts \
6128 WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6129 [],
6130 )?;
6131 connection.execute(
6132 "DELETE FROM file_texts WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6133 [],
6134 )?;
6135 complete_file_text_fts_update(connection, fts_revision)?;
6136 Ok(())
6137}
6138
6139fn upsert_nodes(connection: &Connection, nodes: &[Node]) -> DbResult<()> {
6141 let mut select_existing = connection.prepare_cached(
6142 "
6143 SELECT content_hash
6144 FROM nodes
6145 WHERE path = ?1
6146 ",
6147 )?;
6148 let mut upsert_node = connection.prepare_cached(
6149 "
6150 INSERT INTO nodes(path, kind, parent_path, extension, language, size_bytes, mtime_ns, content_hash, exists_now)
6151 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)
6152 ON CONFLICT(path) DO UPDATE SET
6153 kind = excluded.kind,
6154 parent_path = excluded.parent_path,
6155 extension = excluded.extension,
6156 language = excluded.language,
6157 size_bytes = excluded.size_bytes,
6158 mtime_ns = excluded.mtime_ns,
6159 content_hash = excluded.content_hash,
6160 exists_now = 1,
6161 last_seen_at = CURRENT_TIMESTAMP,
6162 last_indexed_at = CURRENT_TIMESTAMP
6163 ",
6164 )?;
6165 let mut select_node_id = connection.prepare_cached("SELECT id FROM nodes WHERE path = ?1")?;
6166 let mut ensure_purpose = connection.prepare_cached(
6167 "
6168 INSERT INTO purposes(node_id, purpose, source, status)
6169 VALUES(?1, NULL, 'missing', 'missing')
6170 ON CONFLICT(node_id) DO NOTHING
6171 ",
6172 )?;
6173 let mut upsert_summary = connection.prepare_cached(
6174 "
6175 INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
6176 VALUES(?1, 'node', '', ?2, CURRENT_TIMESTAMP)
6177 ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
6178 summary = CASE WHEN ?3 THEN excluded.summary ELSE summaries.summary END,
6179 updated_at = CURRENT_TIMESTAMP
6180 ",
6181 )?;
6182 for node in nodes {
6183 let existing = select_existing
6184 .query_row([&node.path], |row| row.get::<_, Option<String>>(0))
6185 .optional()?;
6186 let content_changed = existing.as_ref().is_some_and(|old_hash| {
6187 node.kind == NodeKind::File
6188 && old_hash.is_some()
6189 && node.content_hash.is_some()
6190 && old_hash != &node.content_hash
6191 });
6192 upsert_node.execute(params![
6193 node.path,
6194 node.kind.to_string(),
6195 node.parent_path,
6196 node.extension,
6197 node.language,
6198 node.size_bytes,
6199 node.mtime_ns,
6200 node.content_hash
6201 ])?;
6202 let node_id = select_node_id.query_row([&node.path], |row| row.get::<_, i64>(0))?;
6203 ensure_purpose.execute([node_id])?;
6204 upsert_summary.execute(params![
6205 node_id,
6206 generate_node_summary(node),
6207 content_changed
6208 ])?;
6209 }
6210 Ok(())
6211}
6212
6213fn file_text_from_row(row: &rusqlite::Row<'_>) -> DbResult<IndexedFileText> {
6215 let byte_count = count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?;
6216 let line_count = count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?;
6217 let text = IndexedFileText {
6218 path: row.get(0)?,
6219 content_hash: row.get(1)?,
6220 byte_count,
6221 line_count,
6222 content: row.get(4)?,
6223 };
6224 validate_indexed_file_text(&text)?;
6225 Ok(text)
6226}
6227
6228fn validate_indexed_file_text(text: &IndexedFileText) -> DbResult<()> {
6230 let actual_bytes = text.content.len();
6231 if text.byte_count != actual_bytes {
6232 return Err(DbError::FileTextMetadataMismatch {
6233 path: text.path.clone(),
6234 field: "byte_count",
6235 recorded: text.byte_count,
6236 actual: actual_bytes,
6237 });
6238 }
6239 let actual_lines = text.content.lines().count();
6240 if text.line_count != actual_lines {
6241 return Err(DbError::FileTextMetadataMismatch {
6242 path: text.path.clone(),
6243 field: "line_count",
6244 recorded: text.line_count,
6245 actual: actual_lines,
6246 });
6247 }
6248 Ok(())
6249}
6250
6251const SQLITE_READ_PROGRESS_OPS: i32 = 1_000;
6253
6254#[cfg(feature = "sqlite-progress-test-observer")]
6256pub mod sqlite_progress_test_observer {
6257 use projectatlas_core::IndexWorkStage;
6258 use std::cell::RefCell;
6259
6260 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
6262 pub enum SqliteReadProgressEvent {
6263 RepositoryRelationFamilyQueryEntered,
6265 RepositoryRelationFamilyQueryExited,
6267 CallbackEntered {
6269 stage: IndexWorkStage,
6271 },
6272 CallbackEvaluated {
6274 stage: IndexWorkStage,
6276 interrupted: bool,
6278 },
6279 }
6280
6281 type Observer = Box<dyn FnMut(SqliteReadProgressEvent)>;
6283
6284 thread_local! {
6285 static OBSERVER: RefCell<Option<Observer>> = RefCell::new(None);
6287 }
6288
6289 struct ObserverGuard {
6291 previous: Option<Observer>,
6293 }
6294
6295 impl Drop for ObserverGuard {
6296 fn drop(&mut self) {
6297 OBSERVER.with(|slot| {
6298 drop(slot.replace(self.previous.take()));
6299 });
6300 }
6301 }
6302
6303 pub fn observe_sqlite_read_progress<T>(
6305 observer: impl FnMut(SqliteReadProgressEvent) + 'static,
6306 operation: impl FnOnce() -> T,
6307 ) -> T {
6308 let previous = OBSERVER.with(|slot| slot.replace(Some(Box::new(observer))));
6309 let _guard = ObserverGuard { previous };
6310 operation()
6311 }
6312
6313 pub(crate) fn notify(event: SqliteReadProgressEvent) {
6315 OBSERVER.with(|slot| {
6316 if let Some(observer) = slot.borrow_mut().as_mut() {
6317 observer(event);
6318 }
6319 });
6320 }
6321}
6322
6323pub(crate) struct SqliteReadProgressGuard<'connection> {
6325 connection: &'connection Connection,
6327 armed: bool,
6329}
6330
6331impl<'connection> SqliteReadProgressGuard<'connection> {
6332 pub(crate) fn new(
6334 connection: &'connection Connection,
6335 control: Option<&IndexWorkControl>,
6336 stage: IndexWorkStage,
6337 ) -> DbResult<Self> {
6338 let armed = if let Some(control) = control {
6339 control.check(stage)?;
6340 let progress_control = control.clone();
6341 connection.progress_handler(
6342 SQLITE_READ_PROGRESS_OPS,
6343 Some(move || {
6344 #[cfg(feature = "sqlite-progress-test-observer")]
6345 sqlite_progress_test_observer::notify(
6346 sqlite_progress_test_observer::SqliteReadProgressEvent::CallbackEntered {
6347 stage,
6348 },
6349 );
6350 let interrupted = progress_control.check(stage).is_err();
6351 #[cfg(feature = "sqlite-progress-test-observer")]
6352 sqlite_progress_test_observer::notify(
6353 sqlite_progress_test_observer::SqliteReadProgressEvent::CallbackEvaluated {
6354 stage,
6355 interrupted,
6356 },
6357 );
6358 interrupted
6359 }),
6360 );
6361 true
6362 } else {
6363 false
6364 };
6365 Ok(Self { connection, armed })
6366 }
6367}
6368
6369impl Drop for SqliteReadProgressGuard<'_> {
6370 fn drop(&mut self) {
6371 if self.armed {
6372 self.connection.progress_handler(0, None::<fn() -> bool>);
6373 }
6374 }
6375}
6376
6377pub(crate) fn with_sqlite_read_progress<T>(
6379 connection: &Connection,
6380 control: Option<&IndexWorkControl>,
6381 stage: IndexWorkStage,
6382 operation: impl FnOnce() -> DbResult<T>,
6383) -> DbResult<T> {
6384 let guard = SqliteReadProgressGuard::new(connection, control, stage)?;
6385 let result = operation();
6386 drop(guard);
6387 if result.as_ref().is_err_and(|error| {
6388 matches!(
6389 error,
6390 DbError::Sqlite(sqlite)
6391 if sqlite.sqlite_error_code() == Some(ErrorCode::OperationInterrupted)
6392 )
6393 }) && let Some(control) = control
6394 {
6395 control.check(stage)?;
6396 }
6397 result
6398}
6399
6400fn with_file_text_progress<T>(
6402 connection: &Connection,
6403 control: Option<&IndexWorkControl>,
6404 operation: impl FnOnce() -> DbResult<T>,
6405) -> DbResult<T> {
6406 with_sqlite_read_progress(connection, control, IndexWorkStage::TextIndex, operation)
6407}
6408
6409fn validate_file_text_fts_token(token: &str) -> DbResult<()> {
6411 if token.len() < 3 {
6412 return Err(DbError::FileTextFtsTokenUnsafe {
6413 reason: "token is shorter than three ASCII bytes",
6414 });
6415 }
6416 if !token.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
6417 return Err(DbError::FileTextFtsTokenUnsafe {
6418 reason: "token is not exclusively ASCII alphanumeric",
6419 });
6420 }
6421 Ok(())
6422}
6423
6424fn normalized_file_text_path_prefix(path_prefix: Option<&str>) -> Option<&str> {
6426 path_prefix
6427 .map(|prefix| prefix.trim_end_matches('/'))
6428 .filter(|prefix| !prefix.is_empty() && *prefix != ".")
6429}
6430
6431fn file_text_descendant_range(path_prefix: &str) -> (String, String) {
6433 (format!("{path_prefix}/"), format!("{path_prefix}0"))
6434}
6435
6436fn collect_file_text_fts_candidates(
6438 rows: &mut rusqlite::Rows<'_>,
6439 control: Option<&IndexWorkControl>,
6440 candidates: &mut Vec<FileTextFtsCandidate>,
6441) -> DbResult<()> {
6442 while let Some(row) = rows.next()? {
6443 if let Some(control) = control {
6444 control.check(IndexWorkStage::TextIndex)?;
6445 }
6446 let path = row.get::<_, String>(0)?;
6447 let bm25 = row.get::<_, f64>(4)?;
6448 if !bm25.is_finite() {
6449 return Err(DbError::FileTextFtsScoreInvalid { path });
6450 }
6451 candidates.push(FileTextFtsCandidate {
6452 path,
6453 content_hash: row.get(1)?,
6454 byte_count: count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?,
6455 line_count: count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?,
6456 bm25,
6457 });
6458 }
6459 Ok(())
6460}
6461
6462fn file_text_metadata_from_row(row: &rusqlite::Row<'_>) -> DbResult<FileTextMetadata> {
6464 Ok(FileTextMetadata {
6465 path: row.get(0)?,
6466 content_hash: row.get(1)?,
6467 byte_count: count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?,
6468 line_count: count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?,
6469 })
6470}
6471
6472fn visit_file_text_fallback_rows<A, V>(
6474 rows: &mut rusqlite::Rows<'_>,
6475 content_statement: &mut rusqlite::CachedStatement<'_>,
6476 control: Option<&IndexWorkControl>,
6477 admit: &mut A,
6478 visitor: &mut V,
6479) -> DbResult<()>
6480where
6481 A: FnMut(&FileTextMetadata) -> DbResult<FileTextAdmission>,
6482 V: FnMut(IndexedFileText) -> DbResult<bool>,
6483{
6484 while let Some(row) = rows.next()? {
6485 if let Some(control) = control {
6486 control.check(IndexWorkStage::TextIndex)?;
6487 }
6488 let metadata = file_text_metadata_from_row(row)?;
6489 match admit(&metadata)? {
6490 FileTextAdmission::Skip => continue,
6491 FileTextAdmission::Stop => return Ok(()),
6492 FileTextAdmission::Read => {}
6493 }
6494 let content =
6495 content_statement.query_row([&metadata.path], |content_row| content_row.get(0))?;
6496 let text = IndexedFileText {
6497 path: metadata.path,
6498 content_hash: metadata.content_hash,
6499 byte_count: metadata.byte_count,
6500 line_count: metadata.line_count,
6501 content,
6502 };
6503 validate_indexed_file_text(&text)?;
6504 if !visitor(text)? {
6505 return Ok(());
6506 }
6507 }
6508 Ok(())
6509}
6510
6511type IndexedNodeParts = (
6513 String,
6514 String,
6515 Option<String>,
6516 Option<String>,
6517 Option<String>,
6518 Option<u64>,
6519 Option<i64>,
6520 Option<String>,
6521 Option<String>,
6522 String,
6523 String,
6524 Option<String>,
6525);
6526
6527fn indexed_node_parts_from_sql_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<IndexedNodeParts> {
6529 let kind_value: String = row.get(1)?;
6530 let source_value: String = row.get(9)?;
6531 let status_value: String = row.get(10)?;
6532 Ok((
6533 row.get::<_, String>(0)?,
6534 kind_value,
6535 row.get::<_, Option<String>>(2)?,
6536 row.get::<_, Option<String>>(3)?,
6537 row.get::<_, Option<String>>(4)?,
6538 row.get::<_, Option<u64>>(5)?,
6539 row.get::<_, Option<i64>>(6)?,
6540 row.get::<_, Option<String>>(7)?,
6541 row.get::<_, Option<String>>(8)?,
6542 source_value,
6543 status_value,
6544 row.get::<_, Option<String>>(11)?,
6545 ))
6546}
6547
6548fn indexed_node_from_sql_row(row: &rusqlite::Row<'_>) -> DbResult<IndexedNode> {
6550 indexed_node_from_parts(indexed_node_parts_from_sql_row(row)?)
6551}
6552
6553fn indexed_node_parts_decoded_bytes(row: &IndexedNodeParts) -> DbResult<u64> {
6555 let lengths = [
6556 row.0.len(),
6557 row.1.len(),
6558 row.2.as_ref().map_or(0, String::len),
6559 row.3.as_ref().map_or(0, String::len),
6560 row.4.as_ref().map_or(0, String::len),
6561 row.7.as_ref().map_or(0, String::len),
6562 row.8.as_ref().map_or(0, String::len),
6563 row.9.len(),
6564 row.10.len(),
6565 row.11.as_ref().map_or(0, String::len),
6566 ];
6567 let mut bytes = 16_u64;
6568 for length in lengths {
6569 let length =
6570 u64::try_from(length).map_err(|_source| GraphContractError::InvalidLimits {
6571 reason: "purpose-owner decoded field length overflowed",
6572 })?;
6573 bytes = bytes
6574 .checked_add(length)
6575 .ok_or(GraphContractError::InvalidLimits {
6576 reason: "purpose-owner decoded row size overflowed",
6577 })?;
6578 }
6579 Ok(bytes)
6580}
6581
6582fn indexed_node_from_parts(row: IndexedNodeParts) -> DbResult<IndexedNode> {
6584 let (
6585 path,
6586 kind_value,
6587 parent_path,
6588 extension,
6589 language,
6590 size_bytes,
6591 mtime_ns,
6592 content_hash,
6593 purpose,
6594 source_value,
6595 status_value,
6596 summary,
6597 ) = row;
6598 let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
6599 field: "kind",
6600 value: kind_value,
6601 })?;
6602 let source = parse_source(&source_value)?;
6603 let status = PurposeStatus::from_db(&status_value).ok_or_else(|| DbError::InvalidEnum {
6604 field: "status",
6605 value: status_value,
6606 })?;
6607 Ok(IndexedNode {
6608 node: Node {
6609 path: path.clone(),
6610 kind,
6611 parent_path,
6612 extension,
6613 language,
6614 size_bytes,
6615 mtime_ns,
6616 content_hash,
6617 },
6618 purpose: Purpose {
6619 path,
6620 purpose,
6621 source,
6622 status,
6623 },
6624 summary,
6625 })
6626}
6627
6628fn normalize_query_terms(query: &str) -> Vec<String> {
6630 query
6631 .split(|character: char| !character.is_alphanumeric())
6632 .filter(|term| !term.is_empty())
6633 .map(str::to_lowercase)
6634 .collect()
6635}
6636
6637fn normalize_exact_ranked_query(query: &str) -> String {
6639 query.trim().replace('\\', "/").to_lowercase()
6640}
6641
6642fn reviewed_purpose_match_expression(term_count: usize) -> String {
6644 if term_count == 0 {
6645 return "0".to_string();
6646 }
6647 let matches = std::iter::repeat_n(
6648 "lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\'",
6649 term_count,
6650 )
6651 .collect::<Vec<_>>()
6652 .join(" OR ");
6653 format!(
6654 "CASE WHEN p.status = 'approved' AND p.source IN ('agent', 'human') \
6655 AND ({matches}) THEN 1 ELSE 0 END"
6656 )
6657}
6658
6659fn ranked_score_expression(term_count: usize) -> String {
6661 if term_count == 0 {
6662 return "1".to_string();
6663 }
6664 (0..term_count)
6665 .map(|_| {
6666 "(CASE WHEN lower(n.path) LIKE ? ESCAPE '\\' THEN 20 ELSE 0 END \
6667 + CASE WHEN p.status = 'approved' AND p.source IN ('agent', 'human') \
6668 AND lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\' THEN 30 ELSE 0 END \
6669 + CASE WHEN NOT (p.status = 'approved' AND p.source IN ('agent', 'human')) \
6670 AND lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\' THEN 2 ELSE 0 END \
6671 + CASE WHEN lower(COALESCE(s.summary, '')) LIKE ? ESCAPE '\\' THEN 10 ELSE 0 END \
6672 + CASE WHEN lower(COALESCE(symbol_summaries.summary, '')) LIKE ? ESCAPE '\\' THEN 25 ELSE 0 END)"
6673 .to_string()
6674 })
6675 .collect::<Vec<_>>()
6676 .join(" + ")
6677}
6678
6679fn sqlite_like_pattern(term: &str) -> String {
6681 format!("%{}%", sqlite_like_escape(term))
6682}
6683
6684fn sqlite_descendant_pattern(path: &str) -> String {
6686 format!("{}/%", sqlite_like_escape(path))
6687}
6688
6689fn sqlite_like_escape(value: &str) -> String {
6691 value
6692 .replace('\\', "\\\\")
6693 .replace('%', "\\%")
6694 .replace('_', "\\_")
6695}
6696
6697fn replace_symbol_search_summary(
6699 connection: &Connection,
6700 node_id: i64,
6701 summary: Option<&str>,
6702) -> DbResult<()> {
6703 if let Some(summary) = summary {
6704 connection
6705 .prepare_cached(
6706 "
6707 INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
6708 VALUES(?1, 'search', 'symbols', ?2, CURRENT_TIMESTAMP)
6709 ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
6710 summary = excluded.summary,
6711 updated_at = CURRENT_TIMESTAMP
6712 ",
6713 )?
6714 .execute(params![node_id, summary])?;
6715 } else {
6716 connection
6717 .prepare_cached(
6718 "
6719 DELETE FROM summaries
6720 WHERE node_id = ?1
6721 AND summary_level = 'search'
6722 AND subject = 'symbols'
6723 ",
6724 )?
6725 .execute([node_id])?;
6726 }
6727 Ok(())
6728}
6729
6730fn symbol_search_summary(graph: &SymbolGraph) -> Option<String> {
6732 let mut names = graph
6733 .symbols
6734 .iter()
6735 .filter(|symbol| !matches!(symbol.kind, SymbolKind::Import | SymbolKind::Unknown))
6736 .map(|symbol| symbol.name.trim())
6737 .filter(|name| !name.is_empty())
6738 .map(ToString::to_string)
6739 .collect::<Vec<_>>();
6740 names.sort();
6741 names.dedup();
6742 if names.is_empty() {
6743 return None;
6744 }
6745 let summary = format!("symbols {}", names.join(" "));
6746 Some(truncate_summary_chars(
6747 &summary,
6748 MAX_SYMBOL_SEARCH_SUMMARY_CHARS,
6749 ))
6750}
6751
6752fn truncate_summary_chars(value: &str, max_chars: usize) -> String {
6754 if value.chars().count() <= max_chars {
6755 return value.to_string();
6756 }
6757 value.chars().take(max_chars).collect()
6758}
6759
6760fn parse_source(value: &str) -> DbResult<PurposeSource> {
6762 let source = match value {
6763 value if value == PurposeSource::Missing.as_str() => PurposeSource::Missing,
6764 value if value == PurposeSource::Imported.as_str() => PurposeSource::Imported,
6765 value if value == PurposeSource::Generated.as_str() => PurposeSource::Generated,
6766 value if value == PurposeSource::Agent.as_str() || value == LEGACY_HUMAN_PURPOSE_SOURCE => {
6769 PurposeSource::Agent
6770 }
6771 _ => {
6772 return Err(DbError::InvalidEnum {
6773 field: "source",
6774 value: value.to_string(),
6775 });
6776 }
6777 };
6778 Ok(source)
6779}
6780
6781fn normalize_purpose_curation_task(task: &str) -> DbResult<String> {
6783 let normalized = task.split_whitespace().collect::<Vec<_>>().join(" ");
6784 if normalized.is_empty() {
6785 return Err(DbError::PurposeCurationTaskInvalid {
6786 reason: "task must not be blank",
6787 });
6788 }
6789 if normalized.len() > MAX_PURPOSE_CURATION_TASK_BYTES {
6790 return Err(DbError::PurposeCurationTaskInvalid {
6791 reason: "task exceeds the UTF-8 byte limit",
6792 });
6793 }
6794 if normalized.chars().any(char::is_control) {
6795 return Err(DbError::PurposeCurationTaskInvalid {
6796 reason: "task contains control characters",
6797 });
6798 }
6799 Ok(normalized)
6800}
6801
6802fn load_current_purpose_state(
6804 connection: &Connection,
6805 path: &str,
6806) -> DbResult<Option<(i64, Purpose)>> {
6807 let row = connection
6808 .prepare_cached(
6809 "
6810 SELECT n.id, p.purpose, p.source, p.status
6811 FROM nodes n
6812 JOIN purposes p ON p.node_id = n.id
6813 WHERE n.exists_now = 1 AND n.path = ?1
6814 ",
6815 )?
6816 .query_row([path], |row| {
6817 Ok((
6818 row.get::<_, i64>(0)?,
6819 row.get::<_, Option<String>>(1)?,
6820 row.get::<_, String>(2)?,
6821 row.get::<_, String>(3)?,
6822 ))
6823 })
6824 .optional()?;
6825 row.map(|(node_id, purpose, source, status)| {
6826 let source = parse_source(&source)?;
6827 let status = PurposeStatus::from_db(&status).ok_or_else(|| DbError::InvalidEnum {
6828 field: "purpose_status",
6829 value: status,
6830 })?;
6831 Ok((
6832 node_id,
6833 Purpose {
6834 path: path.to_string(),
6835 purpose,
6836 source,
6837 status,
6838 },
6839 ))
6840 })
6841 .transpose()
6842}
6843
6844fn apply_conditional_purpose(
6846 connection: &Connection,
6847 project: ProjectInstanceId,
6848 generation: IndexGeneration,
6849 request: &PurposeConditionalApplyRequest,
6850) -> DbResult<PurposeConditionalApplyResult> {
6851 let current = load_current_purpose_state(connection, &request.path)?;
6852 let Some((node_id, current_purpose)) = current else {
6853 return Ok(PurposeConditionalApplyResult {
6854 path: request.path.clone(),
6855 state: PurposeConditionalApplyState::PathUnavailable,
6856 current_purpose: None,
6857 });
6858 };
6859 if !matches!(
6860 current_purpose.status,
6861 PurposeStatus::Missing | PurposeStatus::Suggested
6862 ) {
6863 return Ok(PurposeConditionalApplyResult {
6864 path: request.path.clone(),
6865 state: PurposeConditionalApplyState::Accepted,
6866 current_purpose: Some(current_purpose),
6867 });
6868 }
6869 let current_work_key =
6870 purpose_curation_item_work_key(project, generation, &request.task, &request.path);
6871 let current_state_token = purpose_curation_state_token(¤t_work_key, ¤t_purpose);
6872 if current_work_key != request.work_key || current_state_token != request.state_token {
6873 return Ok(PurposeConditionalApplyResult {
6874 path: request.path.clone(),
6875 state: PurposeConditionalApplyState::Stale,
6876 current_purpose: Some(current_purpose),
6877 });
6878 }
6879 let generation_sql =
6880 i64::try_from(generation.get()).map_err(|_source| DbError::GraphCountOverflow {
6881 field: "project_identity.active_generation",
6882 value: generation.get(),
6883 })?;
6884 let changed = connection
6885 .prepare_cached(
6886 "
6887 UPDATE purposes
6888 SET purpose = ?2,
6889 source = ?3,
6890 status = ?4,
6891 updated_at = CURRENT_TIMESTAMP
6892 WHERE node_id = ?1
6893 AND status = ?5
6894 AND source = ?6
6895 AND purpose IS ?7
6896 AND EXISTS (
6897 SELECT 1
6898 FROM nodes n
6899 JOIN project_identity pi ON pi.singleton = 1
6900 WHERE n.id = ?1
6901 AND n.exists_now = 1
6902 AND n.path = ?8
6903 AND pi.project_instance_id = ?9
6904 AND pi.active_generation = ?10
6905 )
6906 ",
6907 )?
6908 .execute(params![
6909 node_id,
6910 request.purpose,
6911 PurposeSource::Agent.as_str(),
6912 PurposeStatus::Approved.as_str(),
6913 current_purpose.status.as_str(),
6914 current_purpose.source.as_str(),
6915 current_purpose.purpose,
6916 request.path,
6917 &project.as_bytes()[..],
6918 generation_sql,
6919 ])?;
6920 if changed == 1 {
6921 Ok(PurposeConditionalApplyResult {
6922 path: request.path.clone(),
6923 state: PurposeConditionalApplyState::Applied,
6924 current_purpose: Some(Purpose {
6925 path: request.path.clone(),
6926 purpose: Some(request.purpose.clone()),
6927 source: PurposeSource::Agent,
6928 status: PurposeStatus::Approved,
6929 }),
6930 })
6931 } else {
6932 Ok(PurposeConditionalApplyResult {
6933 path: request.path.clone(),
6934 state: PurposeConditionalApplyState::Stale,
6935 current_purpose: Some(current_purpose),
6936 })
6937 }
6938}
6939
6940fn purpose_curation_candidate(
6942 project: ProjectInstanceId,
6943 generation: IndexGeneration,
6944 task: &str,
6945 node: IndexedNode,
6946) -> PurposeCurationCandidate {
6947 let work_key = purpose_curation_item_work_key(project, generation, task, &node.node.path);
6948 let state_token = purpose_curation_state_token(&work_key, &node.purpose);
6949 PurposeCurationCandidate {
6950 node,
6951 work_key,
6952 state_token,
6953 }
6954}
6955
6956fn purpose_curation_item_work_key(
6958 project: ProjectInstanceId,
6959 generation: IndexGeneration,
6960 task: &str,
6961 path: &str,
6962) -> String {
6963 digest_fields(
6964 PURPOSE_CURATION_ITEM_KEY_DOMAIN,
6965 &[
6966 &project.as_bytes(),
6967 &generation.get().to_le_bytes(),
6968 task.as_bytes(),
6969 path.as_bytes(),
6970 ],
6971 )
6972}
6973
6974fn purpose_curation_state_token(work_key: &str, purpose: &Purpose) -> String {
6976 let purpose_presence = [u8::from(purpose.purpose.is_some())];
6977 digest_fields(
6978 PURPOSE_CURATION_STATE_TOKEN_DOMAIN,
6979 &[
6980 work_key.as_bytes(),
6981 &purpose_presence,
6982 purpose.purpose.as_deref().unwrap_or_default().as_bytes(),
6983 purpose.source.as_str().as_bytes(),
6984 purpose.status.as_str().as_bytes(),
6985 ],
6986 )
6987}
6988
6989fn purpose_curation_batch_work_key(
6991 project: ProjectInstanceId,
6992 generation: IndexGeneration,
6993 task: &str,
6994 items: &[PurposeCurationCandidate],
6995) -> String {
6996 let mut hasher = Hasher::new();
6997 digest_field(&mut hasher, PURPOSE_CURATION_BATCH_KEY_DOMAIN.as_bytes());
6998 digest_field(&mut hasher, &project.as_bytes());
6999 digest_field(&mut hasher, &generation.get().to_le_bytes());
7000 digest_field(&mut hasher, task.as_bytes());
7001 for item in items {
7002 digest_field(&mut hasher, item.work_key.as_bytes());
7003 digest_field(&mut hasher, item.state_token.as_bytes());
7004 }
7005 hasher.finalize().to_hex().to_string()
7006}
7007
7008fn digest_fields(domain: &str, fields: &[&[u8]]) -> String {
7010 let mut hasher = Hasher::new();
7011 digest_field(&mut hasher, domain.as_bytes());
7012 for field in fields {
7013 digest_field(&mut hasher, field);
7014 }
7015 hasher.finalize().to_hex().to_string()
7016}
7017
7018fn digest_field(hasher: &mut Hasher, value: &[u8]) {
7020 let length = u64::try_from(value.len()).unwrap_or(u64::MAX);
7021 hasher.update(&length.to_le_bytes());
7022 hasher.update(value);
7023}
7024
7025fn count_to_usize(field: &'static str, value: i64) -> DbResult<usize> {
7027 usize::try_from(value).map_err(|source| DbError::InvalidCount {
7028 field,
7029 value,
7030 source,
7031 })
7032}
7033
7034fn usize_to_i64(value: usize) -> i64 {
7036 i64::try_from(value).unwrap_or(i64::MAX)
7037}
7038
7039fn i64_to_usize(value: i64) -> usize {
7041 usize::try_from(value.max(0)).unwrap_or(usize::MAX)
7042}
7043
7044fn code_symbol_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CodeSymbol> {
7046 Ok(CodeSymbol {
7047 path: row.get(0)?,
7048 language: row.get(1)?,
7049 name: row.get(2)?,
7050 kind: SymbolKind::from_db(row.get_ref(3)?.as_str()?),
7051 signature: row.get(4)?,
7052 line_start: i64_to_usize(row.get::<_, i64>(5)?),
7053 line_end: i64_to_usize(row.get::<_, i64>(6)?),
7054 parent: row.get(7)?,
7055 parser: ParserKind::from_db(row.get_ref(8)?.as_str()?),
7056 detail: row.get(9)?,
7057 exported: row.get::<_, i64>(10)? != 0,
7058 documentation: row.get(11)?,
7059 })
7060}
7061
7062fn code_symbol_preflight_bytes(row: &rusqlite::Row<'_>) -> DbResult<u64> {
7064 let string_bytes = row.get::<_, i64>(12)?;
7065 let string_bytes = u64::try_from(string_bytes).map_err(|source| DbError::InvalidCount {
7066 field: "symbol persisted field bytes",
7067 value: string_bytes,
7068 source,
7069 })?;
7070 let row_bytes = u64::try_from(std::mem::size_of::<CodeSymbol>()).map_err(|source| {
7071 DbError::InvalidCount {
7072 field: "symbol persisted field bytes",
7073 value: i64::MAX,
7074 source,
7075 }
7076 })?;
7077 row_bytes.checked_add(string_bytes).ok_or_else(|| {
7078 GraphContractError::InvalidLimits {
7079 reason: "symbol persisted row bytes overflowed",
7080 }
7081 .into()
7082 })
7083}
7084
7085fn stored_import_relation_from_row(
7087 row: &rusqlite::Row<'_>,
7088) -> rusqlite::Result<StoredImportRelation> {
7089 Ok(StoredImportRelation {
7090 path: row.get(0)?,
7091 source_name: row.get(1)?,
7092 target_name: row.get(2)?,
7093 line: i64_to_usize(row.get::<_, i64>(3)?),
7094 })
7095}
7096
7097fn code_symbol_decoded_bytes(symbol: &CodeSymbol) -> DbResult<u64> {
7099 let capacities = [
7100 symbol.path.capacity(),
7101 symbol.language.as_ref().map_or(0, String::capacity),
7102 symbol.name.capacity(),
7103 symbol.signature.capacity(),
7104 symbol.documentation.as_ref().map_or(0, String::capacity),
7105 symbol.parent.as_ref().map_or(0, String::capacity),
7106 symbol.detail.as_ref().map_or(0, String::capacity),
7107 ];
7108 let mut bytes = u64::try_from(std::mem::size_of::<CodeSymbol>()).map_err(|source| {
7109 DbError::InvalidCount {
7110 field: "symbol decoded field bytes",
7111 value: i64::MAX,
7112 source,
7113 }
7114 })?;
7115 for capacity in capacities {
7116 let capacity = u64::try_from(capacity).map_err(|source| DbError::InvalidCount {
7117 field: "symbol decoded field bytes",
7118 value: i64::MAX,
7119 source,
7120 })?;
7121 bytes = bytes
7122 .checked_add(capacity)
7123 .ok_or(GraphContractError::InvalidLimits {
7124 reason: "symbol decoded row bytes overflowed",
7125 })?;
7126 }
7127 Ok(bytes)
7128}
7129
7130fn like_query(query: &str) -> String {
7132 format!("%{query}%")
7133}
7134
7135fn load_nodes_by_paths_sql(path_count: usize) -> String {
7137 let placeholders = numbered_placeholders(1, path_count);
7138 format!(
7139 "
7140 SELECT
7141 n.path,
7142 n.kind,
7143 n.parent_path,
7144 n.extension,
7145 n.language,
7146 n.size_bytes,
7147 n.mtime_ns,
7148 n.content_hash,
7149 p.purpose,
7150 p.source,
7151 p.status,
7152 s.summary
7153 FROM nodes n
7154 JOIN purposes p ON p.node_id = n.id
7155 LEFT JOIN summaries s ON s.node_id = n.id
7156 AND s.summary_level = 'node'
7157 AND s.subject = ''
7158 WHERE n.exists_now = 1 AND n.path IN ({placeholders})
7159 ORDER BY n.path
7160 "
7161 )
7162}
7163
7164fn numbered_placeholders(start: usize, count: usize) -> String {
7166 (start..start + count)
7167 .map(|index| format!("?{index}"))
7168 .collect::<Vec<_>>()
7169 .join(", ")
7170}
7171
7172fn generate_node_summary(node: &Node) -> String {
7174 match node.kind {
7175 NodeKind::Folder => format!("Folder for {}", path_label(&node.path)),
7176 NodeKind::File => file_summary(node),
7177 }
7178}
7179
7180fn file_summary(node: &Node) -> String {
7182 let language = node
7183 .language
7184 .as_deref()
7185 .or(node.extension.as_deref())
7186 .unwrap_or("unknown");
7187 let size = node.size_bytes.map_or_else(
7188 || "unknown size".to_string(),
7189 |bytes| format!("{bytes} bytes"),
7190 );
7191 format!("{language} file, {size}")
7192}
7193
7194fn path_label(path: &str) -> String {
7196 if path == "." {
7197 return "repository root".to_string();
7198 }
7199 path.rsplit('/')
7200 .next()
7201 .filter(|value| !value.is_empty())
7202 .unwrap_or(path)
7203 .replace(['-', '_'], " ")
7204}
7205
7206fn emit_unresolved_finding<F>(
7208 finding: HealthFinding,
7209 resolved_ids: &HashSet<String>,
7210 visitor: &mut F,
7211) -> DbResult<bool>
7212where
7213 F: FnMut(HealthFinding) -> DbResult<bool>,
7214{
7215 if resolved_ids.contains(&finding.id) {
7216 return Ok(true);
7217 }
7218 visitor(finding)
7219}
7220
7221fn purpose_health_spec_matches_query(spec: PurposeHealthSpec, query: &HealthQuery) -> bool {
7223 health_category_matches_query(spec.category, Severity::Warning, query)
7224}
7225
7226fn health_category_matches_query(category: &str, severity: Severity, query: &HealthQuery) -> bool {
7228 query
7229 .category
7230 .as_deref()
7231 .is_none_or(|requested| category.eq_ignore_ascii_case(requested))
7232 && query.severity.is_none_or(|requested| severity == requested)
7233}
7234
7235fn purpose_health_spec_for_status(status: &str) -> DbResult<PurposeHealthSpec> {
7237 PURPOSE_HEALTH_SPECS
7238 .iter()
7239 .copied()
7240 .find(|spec| spec.status == status)
7241 .ok_or_else(|| DbError::InvalidEnum {
7242 field: "status",
7243 value: status.to_string(),
7244 })
7245}
7246
7247fn agent_review_required_finding(path: String) -> HealthFinding {
7249 HealthFinding {
7250 id: finding_id(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED, &path, None),
7251 severity: Severity::Warning,
7252 category: CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
7253 path,
7254 related_path: None,
7255 message: MESSAGE_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
7256 recommendation: RECOMMENDATION_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
7257 }
7258}
7259
7260fn purpose_lifecycle_where_clause(
7262 specs: &[PurposeHealthSpec],
7263 path_prefix: Option<&str>,
7264 resolution_filter: HealthResolutionFilter<'_>,
7265 scope: HealthScope,
7266) -> (String, Vec<Value>) {
7267 let statuses = specs
7268 .iter()
7269 .map(|spec| format!("'{}'", spec.status))
7270 .collect::<Vec<_>>()
7271 .join(", ");
7272 let mut clauses = vec![
7273 "n.exists_now = 1".to_string(),
7274 format!("p.status IN ({statuses})"),
7275 ];
7276 let mut values = Vec::new();
7277
7278 if source_filter_applies_before_queue(scope) {
7279 clauses.push(source_relevant_node_expression("n"));
7280 }
7281 if scope.high_impact_queue() {
7282 clauses.push(purpose_default_queue_node_expression("n", "p", scope));
7283 }
7284
7285 let normalized_prefix = path_prefix
7286 .map(normalize_repo_path_prefix)
7287 .filter(|prefix| prefix != ".");
7288 if let Some(prefix) = normalized_prefix {
7289 clauses.push(format!(
7290 "(n.path = ?{} OR n.path LIKE ?{} ESCAPE '\\')",
7291 values.len() + 1,
7292 values.len() + 2
7293 ));
7294 values.push(Value::from(prefix.clone()));
7295 values.push(Value::from(sqlite_descendant_pattern(&prefix)));
7296 }
7297
7298 match resolution_filter {
7299 HealthResolutionFilter::Explicit(resolved_ids) => {
7300 for spec in specs {
7301 let resolved_paths = resolved_purpose_paths(resolved_ids, spec.category);
7302 if !resolved_paths.is_empty() {
7303 clauses.push(format!(
7304 "NOT (p.status = '{}' AND n.path IN ({}))",
7305 spec.status,
7306 numbered_placeholders(values.len() + 1, resolved_paths.len())
7307 ));
7308 values.extend(resolved_paths.into_iter().map(Value::from));
7309 }
7310 }
7311 }
7312 HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(
7313 &purpose_lifecycle_finding_id_expression(specs, "n", "p"),
7314 )),
7315 }
7316
7317 (clauses.join(" AND "), values)
7318}
7319
7320fn purpose_status_where_clause(
7322 spec: PurposeHealthSpec,
7323 path_prefix: Option<&str>,
7324 resolution_filter: HealthResolutionFilter<'_>,
7325 scope: HealthScope,
7326) -> (String, Vec<Value>) {
7327 let mut clauses = vec!["n.exists_now = 1".to_string(), "p.status = ?1".to_string()];
7328 let mut values = vec![Value::from(spec.status.to_string())];
7329
7330 if source_filter_applies_before_queue(scope) {
7331 clauses.push(source_relevant_node_expression("n"));
7332 }
7333 if scope.high_impact_queue() {
7334 clauses.push(purpose_default_queue_node_expression("n", "p", scope));
7335 }
7336
7337 let normalized_prefix = path_prefix
7338 .map(normalize_repo_path_prefix)
7339 .filter(|prefix| prefix != ".");
7340 if let Some(prefix) = normalized_prefix {
7341 clauses.push(format!(
7342 "(n.path = ?{} OR n.path LIKE ?{} ESCAPE '\\')",
7343 values.len() + 1,
7344 values.len() + 2
7345 ));
7346 values.push(Value::from(prefix.clone()));
7347 values.push(Value::from(sqlite_descendant_pattern(&prefix)));
7348 }
7349
7350 match resolution_filter {
7351 HealthResolutionFilter::Explicit(resolved_ids) => {
7352 let resolved_paths = resolved_purpose_paths(resolved_ids, spec.category);
7353 if !resolved_paths.is_empty() {
7354 clauses.push(format!(
7355 "n.path NOT IN ({})",
7356 numbered_placeholders(values.len() + 1, resolved_paths.len())
7357 ));
7358 values.extend(resolved_paths.into_iter().map(Value::from));
7359 }
7360 }
7361 HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(&format!(
7362 "'{}:' || n.path || ':'",
7363 spec.category
7364 ))),
7365 }
7366
7367 (clauses.join(" AND "), values)
7368}
7369
7370fn structural_finding_where_clause(
7372 category: &str,
7373 path_prefix: Option<&str>,
7374 resolution_filter: HealthResolutionFilter<'_>,
7375 scope: HealthScope,
7376 first_placeholder: usize,
7377) -> (String, Vec<Value>) {
7378 let mut placeholder = first_placeholder;
7379 let mut clauses = Vec::new();
7380 let mut values = Vec::new();
7381
7382 if source_filter_applies_before_queue(scope) {
7383 clauses.push("source_relevant = 1".to_string());
7384 }
7385 if scope.high_impact_queue() {
7386 clauses.push(purpose_default_queue_finding_expression(scope));
7387 }
7388
7389 let normalized_prefix = path_prefix
7390 .map(normalize_repo_path_prefix)
7391 .filter(|prefix| prefix != ".");
7392 if let Some(prefix) = normalized_prefix {
7393 clauses.push(format!(
7394 "((path = ?{path_exact} OR path LIKE ?{path_descendant} ESCAPE '\\') \
7395 OR (related_path = ?{related_exact} OR related_path LIKE ?{related_descendant} ESCAPE '\\'))",
7396 path_exact = placeholder,
7397 path_descendant = placeholder + 1,
7398 related_exact = placeholder + 2,
7399 related_descendant = placeholder + 3
7400 ));
7401 values.push(Value::from(prefix.clone()));
7402 values.push(Value::from(sqlite_descendant_pattern(&prefix)));
7403 values.push(Value::from(prefix.clone()));
7404 values.push(Value::from(sqlite_descendant_pattern(&prefix)));
7405 placeholder += 4;
7406 }
7407
7408 match resolution_filter {
7409 HealthResolutionFilter::Explicit(resolved_ids) => {
7410 let resolved_ids = resolved_ids_for_category(resolved_ids, category);
7411 if !resolved_ids.is_empty() {
7412 clauses.push(format!(
7413 "('{category}:' || path || ':' || related_path) NOT IN ({})",
7414 numbered_placeholders(placeholder, resolved_ids.len())
7415 ));
7416 values.extend(resolved_ids.into_iter().map(Value::from));
7417 }
7418 }
7419 HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(&format!(
7420 "'{category}:' || path || ':' || related_path"
7421 ))),
7422 }
7423
7424 if clauses.is_empty() {
7425 (String::new(), values)
7426 } else {
7427 (format!("WHERE {}", clauses.join(" AND ")), values)
7428 }
7429}
7430
7431fn purpose_lifecycle_finding_id_expression(
7433 specs: &[PurposeHealthSpec],
7434 node_alias: &str,
7435 purpose_alias: &str,
7436) -> String {
7437 let category_cases = specs
7438 .iter()
7439 .map(|spec| format!("WHEN '{}' THEN '{}:'", spec.status, spec.category))
7440 .collect::<Vec<_>>()
7441 .join(" ");
7442 format!(
7443 "(CASE {purpose_alias}.status {category_cases} ELSE '' END || {node_alias}.path || ':')"
7444 )
7445}
7446
7447fn stored_resolution_filter_clause(finding_id_expression: &str) -> String {
7449 format!(
7450 "NOT EXISTS (SELECT 1 FROM health_resolutions hr WHERE hr.finding_id = {finding_id_expression})"
7451 )
7452}
7453
7454fn purpose_review_candidate_expression(node_alias: &str, scope: HealthScope) -> String {
7456 let scope = match scope {
7457 HealthScope::All => HealthScope::PurposeStrict,
7458 other => other,
7459 };
7460 purpose_default_queue_node_expression(node_alias, "p", scope)
7461}
7462
7463fn purpose_default_queue_node_expression(
7465 node_alias: &str,
7466 purpose_alias: &str,
7467 scope: HealthScope,
7468) -> String {
7469 let asset_clause = if scope.include_assets() {
7470 format!(
7471 " OR ({node_alias}.kind = 'file' AND NOT ({}))",
7472 source_relevant_node_expression(node_alias)
7473 )
7474 } else {
7475 String::new()
7476 };
7477 let source_file_clause = if scope.include_source_files() {
7478 format!(" OR ({node_alias}.kind = 'file' AND COALESCE({node_alias}.language, '') <> '')")
7479 } else {
7480 String::new()
7481 };
7482 let all_file_clause = if scope.include_all_files() {
7483 format!(" OR {node_alias}.kind = 'file'")
7484 } else {
7485 String::new()
7486 };
7487 let stale_queue_sources = sql_string_literals(STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES);
7488 format!(
7489 "({node_alias}.kind = 'folder' \
7490 OR ({node_alias}.kind = 'file' \
7491 AND {purpose_alias}.status = 'stale' \
7492 AND {purpose_alias}.source IN ({stale_queue_sources})) \
7493 OR ({node_alias}.kind = 'file' AND {}){source_file_clause}{all_file_clause}{asset_clause})",
7494 high_impact_file_path_expression(&format!("lower({node_alias}.path)")),
7495 )
7496}
7497
7498fn purpose_default_queue_finding_expression(scope: HealthScope) -> String {
7500 let asset_clause = if scope.include_assets() {
7501 " OR (kind = 'file' AND COALESCE(language, '') = '')"
7502 } else {
7503 ""
7504 };
7505 let source_file_clause = if scope.include_source_files() {
7506 " OR (kind = 'file' AND COALESCE(language, '') <> '')"
7507 } else {
7508 ""
7509 };
7510 let all_file_clause = if scope.include_all_files() {
7511 " OR kind = 'file'"
7512 } else {
7513 ""
7514 };
7515 format!(
7516 "(kind = 'folder' OR (kind = 'file' AND {}){source_file_clause}{all_file_clause}{asset_clause})",
7517 high_impact_file_path_expression("lower(path)")
7518 )
7519}
7520
7521fn purpose_default_queue_order_expression(node_alias: &str, purpose_alias: &str) -> String {
7523 let stale_queue_sources = sql_string_literals(STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES);
7524 format!(
7525 "CASE \
7526 WHEN {node_alias}.kind = 'folder' THEN 0 \
7527 WHEN {node_alias}.kind = 'file' \
7528 AND {purpose_alias}.status = 'stale' \
7529 AND ({purpose_alias}.source IN ({stale_queue_sources}) OR {}) THEN 1 \
7530 WHEN {node_alias}.kind = 'file' AND {} THEN 2 \
7531 ELSE 3 \
7532 END, {node_alias}.path",
7533 high_impact_file_path_expression(&format!("lower({node_alias}.path)")),
7534 high_impact_file_path_expression(&format!("lower({node_alias}.path)"))
7535 )
7536}
7537
7538const STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES: &[&str] = &["human", "imported"];
7540
7541fn source_filter_applies_before_queue(scope: HealthScope) -> bool {
7543 scope.source_only_filter() && !scope.high_impact_queue()
7544}
7545
7546fn sql_string_literals(values: &[&str]) -> String {
7548 values
7549 .iter()
7550 .map(|value| format!("'{}'", value.replace('\'', "''")))
7551 .collect::<Vec<_>>()
7552 .join(", ")
7553}
7554
7555fn high_impact_file_path_expression(lower_path: &str) -> String {
7557 let name_matches = HIGH_IMPACT_FILE_NAMES
7558 .iter()
7559 .map(|name| format!("{lower_path} = '{name}' OR {lower_path} LIKE '%/{name}'"))
7560 .collect::<Vec<_>>()
7561 .join(" OR ");
7562 let prefix_matches = HIGH_IMPACT_PATH_PREFIXES
7563 .iter()
7564 .map(|prefix| format!("{lower_path} LIKE '{prefix}%'"))
7565 .collect::<Vec<_>>()
7566 .join(" OR ");
7567 let segment_matches = HIGH_IMPACT_PATH_SEGMENTS
7568 .iter()
7569 .map(|segment| format!("{lower_path} LIKE '%{segment}%'"))
7570 .collect::<Vec<_>>()
7571 .join(" OR ");
7572 format!("({name_matches} OR {prefix_matches} OR {segment_matches})")
7573}
7574
7575fn source_relevant_node_expression(alias: &str) -> String {
7577 format!(
7578 "(({alias}.kind = 'file' AND COALESCE({alias}.language, '') <> '') \
7579 OR ({alias}.kind = 'folder' AND EXISTS (\
7580 SELECT 1 FROM nodes source_child \
7581 WHERE source_child.exists_now = 1 \
7582 AND source_child.kind = 'file' \
7583 AND COALESCE(source_child.language, '') <> '' \
7584 AND (\
7585 {alias}.path = '.' \
7586 OR source_child.parent_path = {alias}.path \
7587 OR substr(source_child.parent_path, 1, length({alias}.path) + 1) = {alias}.path || '/'\
7588 )\
7589 )))"
7590 )
7591}
7592
7593fn resolved_purpose_paths(resolved_ids: &[String], category: &str) -> Vec<String> {
7595 let prefix = format!("{category}:");
7596 resolved_ids
7597 .iter()
7598 .filter_map(|id| {
7599 id.strip_prefix(&prefix)
7600 .and_then(|rest| rest.strip_suffix(':'))
7601 .filter(|path| !path.is_empty())
7602 .map(ToOwned::to_owned)
7603 })
7604 .collect()
7605}
7606
7607fn resolved_ids_for_category(resolved_ids: &[String], category: &str) -> Vec<String> {
7609 let prefix = format!("{category}:");
7610 resolved_ids
7611 .iter()
7612 .filter(|id| id.starts_with(&prefix))
7613 .cloned()
7614 .collect()
7615}
7616
7617#[cfg(test)]
7618mod tests {
7619 use super::*;
7620 use projectatlas_core::telemetry::{
7621 READ_AVOIDANCE_CONFIDENCE_MODELED, READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED,
7622 READ_AVOIDANCE_CONFIDENCE_OBSERVED, TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
7623 TOKEN_ACCURACY_HEURISTIC, TOKEN_BASELINE_SELECTED_CANDIDATES,
7624 TOKEN_BUCKET_FULL_FILE_COMPRESSION, TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
7625 TOKEN_COMMAND_SEARCH, TOKEN_CONFIDENCE_INFERRED, TOKEN_DEDUPE_SCOPE_EVENT,
7626 usage_from_estimates, usage_from_estimates_with_accounting,
7627 usage_from_estimates_with_context, usage_from_text,
7628 };
7629 use projectatlas_core::{NodeKind, normalized_parent};
7630 use std::error::Error;
7631 use std::fmt::Debug;
7632 use std::fs;
7633 use std::io;
7634 use std::time::Instant;
7635
7636 #[test]
7637 fn stores_nodes_and_overview() -> Result<(), Box<dyn Error>> {
7638 let mut store = AtlasStore::in_memory()?;
7639 let node = Node {
7640 path: "src/main.rs".to_string(),
7641 kind: NodeKind::File,
7642 parent_path: normalized_parent("src/main.rs"),
7643 extension: Some(".rs".to_string()),
7644 language: Some("rust".to_string()),
7645 size_bytes: Some(12),
7646 mtime_ns: Some(10),
7647 content_hash: Some("abc".to_string()),
7648 };
7649 store.replace_scan(&[node])?;
7650 let overview = store.overview()?;
7651 require_eq(&overview.files, &1, "file count")?;
7652 require_eq(&overview.missing_purposes, &1, "missing purpose count")?;
7653 let nodes = store.load_nodes()?;
7654 require_eq(
7655 &nodes[0].purpose.purpose,
7656 &None,
7657 "purpose remains separate from summary",
7658 )?;
7659 require_eq(
7660 &nodes[0].summary,
7661 &Some("rust file, 12 bytes".to_string()),
7662 "node-level summary",
7663 )?;
7664 let loaded = store
7665 .load_node_by_path("src/main.rs")?
7666 .ok_or_else(|| io::Error::other("indexed node was not found by path"))?;
7667 require_eq(
7668 &loaded.node.path,
7669 &"src/main.rs".to_string(),
7670 "targeted path lookup",
7671 )?;
7672 require_eq(
7673 &store.load_node_by_path("src/missing.rs")?.is_none(),
7674 &true,
7675 "missing targeted path lookup",
7676 )?;
7677 Ok(())
7678 }
7679
7680 #[test]
7681 fn validated_existing_database_paths_are_never_recreated() -> Result<(), Box<dyn Error>> {
7682 let temp = tempfile::tempdir()?;
7683
7684 let current_path = temp.path().join("current.db");
7685 drop(AtlasStore::open(¤t_path)?);
7686 let (current, current_location) = schema::preflight(¤t_path, None)?;
7687 require_eq(
7688 ¤t.state,
7689 &SchemaState::Current,
7690 "current preflight state",
7691 )?;
7692 fs::remove_file(¤t_path)?;
7693 if Connection::open_with_flags(
7694 ¤t_path,
7695 writable_open_flags(current.state, current_location.database_exists),
7696 )
7697 .is_ok()
7698 {
7699 return Err(io::Error::other("current database path was recreated").into());
7700 }
7701 require_eq(¤t_path.exists(), &false, "current path stays absent")?;
7702
7703 let released_path = temp.path().join("released.db");
7704 let released_root = temp.path().join("released-root");
7705 write_released_schema_eight_compatibility_fixture(&released_path, &released_root)?;
7706 let (released, released_location) = schema::preflight(&released_path, None)?;
7707 require_eq(
7708 &released.state,
7709 &SchemaState::UpgradeRequired,
7710 "released preflight state",
7711 )?;
7712 fs::remove_file(&released_path)?;
7713 if Connection::open_with_flags(
7714 &released_path,
7715 writable_open_flags(released.state, released_location.database_exists),
7716 )
7717 .is_ok()
7718 {
7719 return Err(io::Error::other("released database path was recreated").into());
7720 }
7721 require_eq(
7722 &released_path.exists(),
7723 &false,
7724 "released path stays absent",
7725 )?;
7726
7727 let fresh_path = temp.path().join("fresh.db");
7728 let (fresh, _) = schema::preflight(&fresh_path, None)?;
7729 require_eq(&fresh.state, &SchemaState::Fresh, "fresh preflight state")?;
7730 drop(AtlasStore::open(&fresh_path)?);
7731 require_eq(&fresh_path.is_file(), &true, "fresh path is created")?;
7732 drop(AtlasStore::open_read_only(&fresh_path)?);
7733 Ok(())
7734 }
7735
7736 #[test]
7737 fn released_schema_layouts_upgrade_without_losing_local_state() -> Result<(), Box<dyn Error>> {
7738 let layouts: [(&str, fn(&Path, &Path) -> Result<(), Box<dyn Error>>); 2] = [
7739 (
7740 "fresh-v0.3.26",
7741 write_released_schema_eight_compatibility_fixture,
7742 ),
7743 (
7744 "evolved-v0.3.11-to-v0.3.26",
7745 write_evolved_released_schema_eight_compatibility_fixture,
7746 ),
7747 ];
7748 let mut previous_binding = None;
7749 for (label, write_fixture) in layouts {
7750 let binding =
7751 assert_released_schema_upgrade_preserves_local_state(label, write_fixture)?;
7752 if previous_binding
7753 .as_ref()
7754 .is_some_and(|previous| previous == &binding)
7755 {
7756 return Err(io::Error::other(
7757 "independent released-schema databases shared one project binding",
7758 )
7759 .into());
7760 }
7761 previous_binding = Some(binding);
7762 }
7763 Ok(())
7764 }
7765
7766 fn assert_released_schema_upgrade_preserves_local_state(
7768 label: &str,
7769 write_fixture: fn(&Path, &Path) -> Result<(), Box<dyn Error>>,
7770 ) -> Result<CapturedProjectBinding, Box<dyn Error>> {
7771 let temp = tempfile::tempdir()?;
7772 let db_path = temp.path().join("projectatlas.db");
7773 let root = temp.path().join("repository");
7774 write_fixture(&db_path, &root)?;
7775 let database_before_read = fs::read(&db_path)?;
7776
7777 let Err(read_error) = AtlasStore::open_read_only(&db_path) else {
7778 return Err(io::Error::other("schema-8 read-only open unexpectedly succeeded").into());
7779 };
7780 require_eq(
7781 &matches!(
7782 read_error,
7783 DbError::SchemaVersion {
7784 found: PREVIOUS_SCHEMA_VERSION,
7785 expected: SCHEMA_VERSION,
7786 }
7787 ),
7788 &true,
7789 "schema-8 read-only rejection",
7790 )?;
7791 require_eq(
7792 &fs::read(&db_path)?,
7793 &database_before_read,
7794 "read-only rejection leaves database unchanged",
7795 )?;
7796
7797 let store = AtlasStore::open(&db_path)?;
7798 let stored_schema = store.connection.query_row(
7799 "SELECT value FROM metadata WHERE key = ?1",
7800 [SCHEMA_VERSION_KEY],
7801 |row| row.get::<_, String>(0),
7802 )?;
7803 require_eq(
7804 &stored_schema,
7805 &SCHEMA_VERSION.to_string(),
7806 "upgraded schema version",
7807 )?;
7808 let migrated_binding = store.captured_project_binding()?;
7809 drop(store);
7810 schema::verify_current_integrity(&db_path, Some(&normalize_native_path_display(&root)))?;
7811 let mut store = AtlasStore::open_for_project(&db_path, &root)?;
7812 require_eq(
7813 &store.captured_project_binding()?,
7814 &migrated_binding,
7815 &format!("{label} identity survives reopen"),
7816 )?;
7817 require_eq(
7818 &store.project_root()?,
7819 &Some(normalize_native_path_display(&root)),
7820 "upgraded project root",
7821 )?;
7822 let node = store
7823 .load_node_by_path("src/lib.rs")?
7824 .ok_or_else(|| io::Error::other("upgraded source node missing"))?;
7825 require_eq(
7826 &node.node.content_hash,
7827 &Some("hash-legacy".to_string()),
7828 "upgraded source row",
7829 )?;
7830 require_eq(
7831 &node.purpose.purpose,
7832 &Some("Schema compatibility source".to_string()),
7833 "upgraded purpose text",
7834 )?;
7835 require_eq(
7836 &node.purpose.source,
7837 &PurposeSource::Agent,
7838 "upgraded purpose source",
7839 )?;
7840 require_eq(
7841 &node.purpose.status,
7842 &PurposeStatus::Approved,
7843 "upgraded purpose review state",
7844 )?;
7845 require_eq(
7846 &store.resolved_health_ids()?,
7847 &vec!["schema-review".to_string()],
7848 "upgraded authored review",
7849 )?;
7850 let custom_setting = store.connection.query_row(
7851 "SELECT value FROM metadata WHERE key = 'custom_setting'",
7852 [],
7853 |row| row.get::<_, String>(0),
7854 )?;
7855 require_eq(
7856 &custom_setting,
7857 &"preserved".to_string(),
7858 "upgraded compatible metadata",
7859 )?;
7860 let telemetry = store.token_overview(Some("schema-session"))?;
7861 require_eq(&telemetry.calls, &1, "upgraded telemetry call count")?;
7862 require_eq(
7863 &telemetry.estimated_saved,
7864 &80,
7865 "upgraded telemetry savings",
7866 )?;
7867 require_eq(
7868 &store.index_publication()?,
7869 &None,
7870 "untrusted publication metadata invalidated",
7871 )?;
7872 let remaining_publication_keys = store.connection.query_row(
7873 "SELECT COUNT(*) FROM metadata WHERE key IN (?1, ?2, ?3)",
7874 params![
7875 INDEX_PUBLICATION_STATE_KEY,
7876 INDEX_PUBLICATION_FINGERPRINT_KEY,
7877 INDEX_PUBLICATION_GENERATION_KEY,
7878 ],
7879 |row| row.get::<_, i64>(0),
7880 )?;
7881 require_eq(
7882 &remaining_publication_keys,
7883 &0,
7884 "all untrusted publication keys removed",
7885 )?;
7886
7887 {
7888 let mut publication = store.begin_index_publication("schema-9-contract")?;
7889 write_test_projection(&mut publication, "fresh")?;
7890 publication.complete()?;
7891 }
7892 require_test_projection(&store, 1, "fresh")?;
7893 require_eq(
7894 &store
7895 .index_publication()?
7896 .and_then(|publication| publication.contract_fingerprint),
7897 &Some("schema-9-contract".to_string()),
7898 &format!("{label} fresh publication contract"),
7899 )?;
7900 Ok(migrated_binding)
7901 }
7902
7903 #[test]
7904 fn future_schema_rejection_preserves_source_and_authored_rows() -> Result<(), Box<dyn Error>> {
7905 let temp = tempfile::tempdir()?;
7906 let db_path = temp.path().join("projectatlas.db");
7907 let root = temp.path().join("repository");
7908 let future_schema = SCHEMA_VERSION + 1;
7909 write_schema_compatibility_fixture(&db_path, &root, future_schema, "future")?;
7910 let database_before = fs::read(&db_path)?;
7911 let Err(open_error) = AtlasStore::open(&db_path) else {
7912 return Err(
7913 io::Error::other("future schema writable open unexpectedly succeeded").into(),
7914 );
7915 };
7916 require_eq(
7917 &matches!(
7918 open_error,
7919 DbError::SchemaVersion {
7920 found,
7921 expected: SCHEMA_VERSION,
7922 } if found == future_schema
7923 ),
7924 &true,
7925 "future schema rejection",
7926 )?;
7927 require_eq(
7928 &fs::read(&db_path)?,
7929 &database_before,
7930 "future schema bytes remain unchanged",
7931 )?;
7932 let connection = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
7933 let stored_schema = connection.query_row(
7934 "SELECT value FROM metadata WHERE key = ?1",
7935 [SCHEMA_VERSION_KEY],
7936 |row| row.get::<_, String>(0),
7937 )?;
7938 require_eq(
7939 &stored_schema,
7940 &future_schema.to_string(),
7941 "future schema remains unchanged",
7942 )?;
7943 let stored_root = connection.query_row(
7944 "SELECT value FROM metadata WHERE key = 'project_root'",
7945 [],
7946 |row| row.get::<_, String>(0),
7947 )?;
7948 require_eq(
7949 &stored_root,
7950 &normalize_native_path_display(&root),
7951 "future project root remains unchanged",
7952 )?;
7953 let source_state = connection.query_row(
7954 "
7955 SELECT n.content_hash, p.purpose, p.source, p.status
7956 FROM nodes AS n
7957 JOIN purposes AS p ON p.node_id = n.id
7958 WHERE n.path = 'src/lib.rs'
7959 ",
7960 [],
7961 |row| {
7962 Ok((
7963 row.get::<_, Option<String>>(0)?,
7964 row.get::<_, Option<String>>(1)?,
7965 row.get::<_, String>(2)?,
7966 row.get::<_, String>(3)?,
7967 ))
7968 },
7969 )?;
7970 require_eq(
7971 &source_state,
7972 &(
7973 Some("hash-future".to_string()),
7974 Some("Schema compatibility source".to_string()),
7975 PurposeSource::Agent.to_string(),
7976 PurposeStatus::Approved.as_str().to_string(),
7977 ),
7978 "future source and purpose rows remain unchanged",
7979 )?;
7980 let review_rationale = connection.query_row(
7981 "SELECT rationale FROM health_resolutions WHERE finding_id = 'schema-review'",
7982 [],
7983 |row| row.get::<_, String>(0),
7984 )?;
7985 require_eq(
7986 &review_rationale,
7987 &"Reviewed schema fixture".to_string(),
7988 "future authored review remains unchanged",
7989 )?;
7990 let telemetry_state = connection.query_row(
7991 "
7992 SELECT COUNT(*), SUM(e.estimated_tokens_saved)
7993 FROM usage_events AS e
7994 JOIN usage_instances AS i USING(instance_row_id)
7995 WHERE i.caller_label = 'schema-session'
7996 ",
7997 [],
7998 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<i64>>(1)?)),
7999 )?;
8000 require_eq(
8001 &telemetry_state,
8002 &(1, Some(80)),
8003 "future telemetry remains unchanged",
8004 )?;
8005 Ok(())
8006 }
8007
8008 #[test]
8009 fn projection_refresh_cannot_replace_publication_contract() -> Result<(), Box<dyn Error>> {
8010 let mut store = AtlasStore::in_memory()?;
8011 store.begin_index_publication("contract-a")?.complete()?;
8012
8013 store
8014 .begin_index_projection_refresh("contract-a")?
8015 .complete()?;
8016 require_eq(
8017 &store.index_publication()?,
8018 &Some(IndexPublication {
8019 state: IndexPublicationState::Complete,
8020 contract_fingerprint: Some("contract-a".to_string()),
8021 generation: IndexGeneration::new(2),
8022 }),
8023 "matching projection refresh",
8024 )?;
8025
8026 let publication = store.begin_index_projection_refresh("contract-a")?;
8027 set_metadata(
8028 &publication.connection,
8029 INDEX_PUBLICATION_FINGERPRINT_KEY,
8030 "contract-b",
8031 )?;
8032 let Err(mismatch) = publication.complete() else {
8033 return Err(io::Error::other(
8034 "projection refresh replaced the global publication contract",
8035 )
8036 .into());
8037 };
8038 if !matches!(mismatch, DbError::PublicationContractChanged) {
8039 return Err(io::Error::other(format!(
8040 "unexpected projection refresh mismatch: {mismatch}"
8041 ))
8042 .into());
8043 }
8044 require_eq(
8045 &store.index_publication()?,
8046 &Some(IndexPublication {
8047 state: IndexPublicationState::Complete,
8048 contract_fingerprint: Some("contract-a".to_string()),
8049 generation: IndexGeneration::new(2),
8050 }),
8051 "mismatched projection refresh rolls back",
8052 )?;
8053 Ok(())
8054 }
8055
8056 #[test]
8057 fn read_snapshots_expose_only_complete_publications() -> Result<(), Box<dyn Error>> {
8058 let temp = tempfile::tempdir()?;
8059 let db_path = temp.path().join("projectatlas.db");
8060 let independent_db_path = temp.path().join("independent.db");
8061 let mut writer_a = AtlasStore::open(&db_path)?;
8062 require_writable_connection_profile(&writer_a.connection)?;
8063 {
8064 let mut publication = writer_a.begin_index_publication("contract")?;
8065 write_test_projection(&mut publication, "old")?;
8066 publication.complete()?;
8067 }
8068 let mut writer_b = AtlasStore::open(&db_path)?;
8069 let mut independent_writer = AtlasStore::open(&independent_db_path)?;
8070 let old_reader = AtlasStore::open_read_only(&db_path)?;
8071 require_read_connection_profile(&old_reader.connection)?;
8072 if old_reader
8073 .connection
8074 .execute("DELETE FROM metadata", [])
8075 .is_ok()
8076 {
8077 return Err(io::Error::other("read-only connection accepted a mutation").into());
8078 }
8079 require_test_projection(&old_reader, 1, "old")?;
8080
8081 {
8082 let mut publication = writer_a.begin_index_publication("contract")?;
8083 write_test_projection(&mut publication, "new")?;
8084 require_test_projection(&old_reader, 1, "old")?;
8085
8086 let started = std::time::Instant::now();
8087 let Err(contention) = writer_b.begin_index_publication("contract") else {
8088 return Err(io::Error::other(
8089 "second writer entered an active publication transaction",
8090 )
8091 .into());
8092 };
8093 require_eq(
8094 &matches!(
8095 contention,
8096 DbError::Sqlite(ref error)
8097 if matches!(
8098 error.sqlite_error_code(),
8099 Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
8100 )
8101 ),
8102 &true,
8103 "same-database writer contention",
8104 )?;
8105 require_eq(
8106 &(started.elapsed() < Duration::from_secs(2)),
8107 &true,
8108 "fail-fast same-database writer acquisition",
8109 )?;
8110 let restored_busy_timeout =
8111 writer_b
8112 .connection
8113 .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, u64>(0))?;
8114 require_eq(
8115 &u128::from(restored_busy_timeout),
8116 &SQLITE_BUSY_TIMEOUT.as_millis(),
8117 "ordinary busy timeout after failed publication acquisition",
8118 )?;
8119
8120 independent_writer
8121 .begin_index_publication("independent-contract")?
8122 .complete()?;
8123 require_eq(
8124 &independent_writer
8125 .index_publication()?
8126 .ok_or_else(|| io::Error::other("independent publication missing"))?
8127 .generation,
8128 &IndexGeneration::new(1),
8129 "independent database generation",
8130 )?;
8131
8132 drop(publication);
8133 }
8134 let rolled_back_reader = AtlasStore::open_read_only(&db_path)?;
8135 require_test_projection(&rolled_back_reader, 1, "old")?;
8136 rolled_back_reader.finish_index_read_snapshot()?;
8137
8138 {
8139 let mut publication = writer_a.begin_index_publication("contract")?;
8140 write_test_projection(&mut publication, "new")?;
8141 publication.complete()?;
8142 }
8143 let restored_busy_timeout =
8144 writer_a
8145 .connection
8146 .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, u64>(0))?;
8147 require_eq(
8148 &u128::from(restored_busy_timeout),
8149 &SQLITE_BUSY_TIMEOUT.as_millis(),
8150 "ordinary busy timeout after successful publication acquisition",
8151 )?;
8152 require_test_projection(&old_reader, 1, "old")?;
8153 let new_reader = AtlasStore::open_read_only(&db_path)?;
8154 require_test_projection(&new_reader, 2, "new")?;
8155 new_reader.finish_index_read_snapshot()?;
8156 old_reader.finish_index_read_snapshot()?;
8157 Ok(())
8158 }
8159
8160 #[test]
8161 fn authored_write_waits_for_the_existing_writer_before_validation() -> Result<(), Box<dyn Error>>
8162 {
8163 let temp = tempfile::tempdir()?;
8164 let root = temp.path().join("repository");
8165 fs::create_dir_all(&root)?;
8166 let db_path = temp.path().join("projectatlas.db");
8167 let mut store = AtlasStore::open_for_project(&db_path, &root)?;
8168 store.replace_scan(&[test_file_node("src/lib.rs", "initial")])?;
8169
8170 let blocking_writer = Connection::open(&db_path)?;
8171 blocking_writer.busy_timeout(SQLITE_BUSY_TIMEOUT)?;
8172 blocking_writer.execute_batch("BEGIN IMMEDIATE")?;
8173 let release = std::thread::spawn(move || {
8174 std::thread::sleep(Duration::from_millis(1_250));
8175 blocking_writer.execute_batch("ROLLBACK")
8176 });
8177
8178 let started = Instant::now();
8179 store.set_purpose(
8180 "src/lib.rs",
8181 "Own the library source.",
8182 PurposeSource::Agent,
8183 )?;
8184 let elapsed = started.elapsed();
8185 release
8186 .join()
8187 .map_err(|_panic| io::Error::other("blocking writer thread panicked"))??;
8188
8189 require_eq(
8190 &(elapsed >= Duration::from_secs(1)),
8191 &true,
8192 "authored write waited for the existing writer",
8193 )?;
8194 require_eq(
8195 &(elapsed < SQLITE_BUSY_TIMEOUT),
8196 &true,
8197 "authored write stayed within the ordinary busy timeout",
8198 )?;
8199 require_eq(
8200 &store
8201 .load_node_by_path("src/lib.rs")?
8202 .ok_or_else(|| io::Error::other("purpose node missing"))?
8203 .purpose
8204 .purpose,
8205 &Some("Own the library source.".to_string()),
8206 "purpose after bounded writer contention",
8207 )
8208 }
8209
8210 #[test]
8211 fn writable_mutators_reject_an_active_read_snapshot() -> Result<(), Box<dyn Error>> {
8212 let temp = tempfile::tempdir()?;
8213 let db_path = temp.path().join("projectatlas.db");
8214 let mut store = AtlasStore::open(&db_path)?;
8215 store.replace_scan(&[test_file_node("src/lib.rs", "initial")])?;
8216 store.begin_index_read_snapshot()?;
8217
8218 let Err(purpose_error) = store.set_purpose(
8219 "src/lib.rs",
8220 "Must not be written through a read snapshot.",
8221 PurposeSource::Agent,
8222 ) else {
8223 return Err(io::Error::other("purpose wrote through a read snapshot").into());
8224 };
8225 require_eq(
8226 &matches!(purpose_error, DbError::IndexReadSnapshotActive),
8227 &true,
8228 "read-snapshot purpose rejection",
8229 )?;
8230
8231 let Err(scan_error) = store.replace_scan(&[]) else {
8232 return Err(io::Error::other("scan wrote through a read snapshot").into());
8233 };
8234 require_eq(
8235 &matches!(scan_error, DbError::IndexReadSnapshotActive),
8236 &true,
8237 "read-snapshot scan rejection",
8238 )?;
8239
8240 store.finish_index_read_snapshot()?;
8241 require_eq(
8242 &store.load_node_by_path("src/lib.rs")?.is_some(),
8243 &true,
8244 "read-snapshot rejected writes preserved indexed state",
8245 )?;
8246 Ok(())
8247 }
8248
8249 #[test]
8250 fn stale_publication_base_is_rejected_before_batch_mutation() -> Result<(), Box<dyn Error>> {
8251 let temp = tempfile::tempdir()?;
8252 let db_path = temp.path().join("projectatlas.db");
8253 let mut writer_a = AtlasStore::open(&db_path)?;
8254 {
8255 let mut publication =
8256 writer_a.begin_index_publication_from("contract", IndexGeneration::ZERO)?;
8257 write_test_projection(&mut publication, "base")?;
8258 publication.complete()?;
8259 }
8260 let prepared_base = writer_a
8261 .index_publication()?
8262 .ok_or_else(|| io::Error::other("prepared base publication missing"))?
8263 .generation;
8264
8265 let incomplete_result = {
8266 let mut publication =
8267 writer_a.begin_index_publication_from("contract", prepared_base)?;
8268 publication.begin_scan_replacement()?;
8269 publication.complete()
8270 };
8271 if !matches!(incomplete_result, Err(DbError::ScanReplacementIncomplete)) {
8272 return Err(io::Error::other(
8273 "unfinished scan replacement was allowed to complete publication",
8274 )
8275 .into());
8276 }
8277 require_test_projection(&writer_a, 1, "base")?;
8278
8279 let mut writer_b = AtlasStore::open(&db_path)?;
8280 {
8281 let mut publication = writer_b.begin_index_publication("contract")?;
8282 write_test_projection(&mut publication, "winner")?;
8283 publication.complete()?;
8284 }
8285
8286 let Err(conflict) = writer_a.begin_index_publication_from("contract", prepared_base) else {
8287 return Err(io::Error::other("stale prepared publication was accepted").into());
8288 };
8289 require_eq(
8290 &matches!(
8291 conflict,
8292 DbError::PublicationBaseGenerationChanged { expected, found }
8293 if expected == IndexGeneration::new(1)
8294 && found == IndexGeneration::new(2)
8295 ),
8296 &true,
8297 "stale publication base conflict",
8298 )?;
8299 require_test_projection(&writer_a, 2, "winner")?;
8300 require_eq(
8301 &writer_a.load_symbols(Some("src/lib.rs"), None, 10)?.len(),
8302 &1,
8303 "rejected batch symbol row count",
8304 )?;
8305 require_eq(
8306 &writer_a
8307 .load_symbol_relations(Some("src/lib.rs"), None, 10)?
8308 .len(),
8309 &1,
8310 "rejected batch relation row count",
8311 )?;
8312
8313 let Err(zero_conflict) =
8314 writer_a.begin_index_publication_from("contract", IndexGeneration::ZERO)
8315 else {
8316 return Err(io::Error::other("zero base matched an initialized store").into());
8317 };
8318 require_eq(
8319 &matches!(
8320 zero_conflict,
8321 DbError::PublicationBaseGenerationChanged { expected, found }
8322 if expected == IndexGeneration::ZERO
8323 && found == IndexGeneration::new(2)
8324 ),
8325 &true,
8326 "zero publication base conflict",
8327 )?;
8328 require_test_projection(&writer_a, 2, "winner")?;
8329 Ok(())
8330 }
8331
8332 #[test]
8333 fn records_token_overview() -> Result<(), Box<dyn Error>> {
8334 let project = tempfile::tempdir()?;
8335 let mut store = AtlasStore::in_memory()?;
8336 store.set_project_root(project.path())?;
8337 let mut session_event = usage_from_estimates(
8338 "session",
8339 "outline",
8340 Some("src/main.rs".to_string()),
8341 None,
8342 100,
8343 20,
8344 );
8345 session_event.estimated_tokens_saved = Some(1);
8346 store.record_usage(&session_event)?;
8347 let mut unknown_event = usage_from_estimates("session", "unknown", None, None, 0, 0);
8348 unknown_event.estimated_tokens_without_projectatlas = None;
8349 unknown_event.estimated_tokens_with_projectatlas = None;
8350 unknown_event.estimated_tokens_saved = None;
8351 store.record_usage(&unknown_event)?;
8352 store.record_usage(&usage_from_estimates(
8353 "other-session",
8354 "outline",
8355 Some("src/lib.rs".to_string()),
8356 None,
8357 200,
8358 50,
8359 ))?;
8360 let overview = store.token_overview(Some("session"))?;
8361 require_eq(&overview.calls, &1, "usage call count")?;
8362 require_eq(&overview.estimated_saved, &80, "saved token count")?;
8363 require_eq(&overview.buckets.len(), &1, "usage bucket count")?;
8364 require_eq(
8365 &overview.buckets[0].accuracy,
8366 &TOKEN_ACCURACY_HEURISTIC.to_string(),
8367 "usage bucket accuracy",
8368 )?;
8369 let all_sessions = store.token_overview(None)?;
8370 require_eq(&all_sessions.calls, &2, "all-session usage call count")?;
8371 require_eq(
8372 &all_sessions.estimated_without_projectatlas,
8373 &300,
8374 "all-session baseline tokens",
8375 )?;
8376 require_eq(
8377 &all_sessions.estimated_with_projectatlas,
8378 &70,
8379 "all-session atlas tokens",
8380 )?;
8381 require_eq(
8382 &all_sessions.estimated_saved,
8383 &230,
8384 "all-session saved tokens",
8385 )?;
8386 require_eq(
8387 &all_sessions.likely_file_reads_avoided,
8388 &0,
8389 "non-search estimate events do not count as avoided file reads",
8390 )?;
8391 require_eq(
8392 &all_sessions.read_avoidance_confidence,
8393 &READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED.to_string(),
8394 "non-search estimate read avoidance confidence",
8395 )?;
8396
8397 store.record_usage(&usage_from_text(
8398 "bucketed",
8399 "summary",
8400 Some("src/main.rs".to_string()),
8401 None,
8402 "abcdefghijkl",
8403 "abcd",
8404 ))?;
8405 store.record_usage(&usage_from_estimates(
8406 "bucketed", "folders", None, None, 100, 20,
8407 ))?;
8408 let bucketed = store.token_overview(Some("bucketed"))?;
8409 require_eq(&bucketed.buckets.len(), &2, "bucketed overview count")?;
8410 require_eq(
8411 &bucketed.buckets[0].token_savings_bucket,
8412 &TOKEN_BUCKET_FULL_FILE_COMPRESSION.to_string(),
8413 "source compression bucket",
8414 )?;
8415 require_eq(
8416 &bucketed.buckets[1].token_savings_bucket,
8417 &TOKEN_BUCKET_NAVIGATION_AVOIDANCE.to_string(),
8418 "navigation bucket",
8419 )?;
8420 require_eq(
8421 &bucketed.observed_file_read_replacements,
8422 &1,
8423 "observed read replacement count",
8424 )?;
8425 require_eq(
8426 &bucketed.modeled_file_reads_avoided,
8427 &0,
8428 "folder navigation does not count as modeled file-read avoidance",
8429 )?;
8430 require_eq(
8431 &bucketed.likely_file_reads_avoided,
8432 &1,
8433 "observed-only likely file reads avoided",
8434 )?;
8435 require_eq(
8436 &bucketed.read_avoidance_confidence,
8437 &READ_AVOIDANCE_CONFIDENCE_OBSERVED.to_string(),
8438 "observed-only read avoidance confidence",
8439 )?;
8440
8441 store.record_usage(&usage_from_text(
8442 "deduped",
8443 "summary",
8444 Some("src/lib.rs".to_string()),
8445 None,
8446 "abcdabcd",
8447 "ab",
8448 ))?;
8449 store.record_usage(&usage_from_estimates(
8450 "deduped",
8451 TOKEN_COMMAND_SEARCH,
8452 None,
8453 Some("token".to_string()),
8454 400,
8455 40,
8456 ))?;
8457 store.record_usage(&usage_from_estimates(
8458 "deduped",
8459 TOKEN_COMMAND_SEARCH,
8460 None,
8461 Some("token".to_string()),
8462 400,
8463 30,
8464 ))?;
8465 let deduped = store.token_overview(Some("deduped"))?;
8466 require_eq(
8467 &deduped.legacy_gross_estimated_saved,
8468 &731,
8469 "legacy gross saved tokens remains available",
8470 )?;
8471 require_eq(
8472 &deduped.measured_tokens_saved,
8473 &1,
8474 "measured saved tokens remain separate",
8475 )?;
8476 require_eq(
8477 &deduped.gross_modeled_tokens_avoided,
8478 &730,
8479 "gross modeled avoided tokens remains available",
8480 )?;
8481 require_eq(
8482 &deduped.deduped_modeled_tokens_avoided,
8483 &330,
8484 "modeled avoided tokens are deduped by baseline",
8485 )?;
8486 require_eq(
8487 &deduped.tokens_avoided,
8488 &331,
8489 "headline avoided tokens use measured plus deduped modeled",
8490 )?;
8491 require_eq(
8492 &deduped.observed_file_read_replacements,
8493 &1,
8494 "deduped observed read replacements",
8495 )?;
8496 require_eq(
8497 &deduped.modeled_file_reads_avoided,
8498 &2,
8499 "deduped raw search events remain likely file reads avoided",
8500 )?;
8501 require_eq(
8502 &deduped.likely_file_reads_avoided,
8503 &3,
8504 "deduped likely file reads avoided",
8505 )?;
8506 require_eq(
8507 &deduped.read_avoidance_confidence,
8508 &READ_AVOIDANCE_CONFIDENCE_MODELED.to_string(),
8509 "deduped read avoidance confidence",
8510 )?;
8511
8512 store.record_usage(&usage_from_estimates_with_accounting(
8513 "event-scoped",
8514 "folders",
8515 None,
8516 Some("token".to_string()),
8517 400,
8518 40,
8519 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
8520 TOKEN_BASELINE_SELECTED_CANDIDATES,
8521 TOKEN_CONFIDENCE_INFERRED,
8522 TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
8523 TOKEN_BASELINE_SELECTED_CANDIDATES,
8524 TOKEN_DEDUPE_SCOPE_EVENT,
8525 ))?;
8526 store.record_usage(&usage_from_estimates_with_accounting(
8527 "event-scoped",
8528 "folders",
8529 None,
8530 Some("token".to_string()),
8531 400,
8532 30,
8533 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
8534 TOKEN_BASELINE_SELECTED_CANDIDATES,
8535 TOKEN_CONFIDENCE_INFERRED,
8536 TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
8537 TOKEN_BASELINE_SELECTED_CANDIDATES,
8538 TOKEN_DEDUPE_SCOPE_EVENT,
8539 ))?;
8540 let event_scoped = store.token_overview(Some("event-scoped"))?;
8541 require_eq(
8542 &event_scoped.gross_modeled_tokens_avoided,
8543 &730,
8544 "event-scoped gross modeled avoided tokens",
8545 )?;
8546 require_eq(
8547 &event_scoped.deduped_modeled_tokens_avoided,
8548 &730,
8549 "event-scoped modeled events are not collapsed",
8550 )?;
8551 require_eq(
8552 &event_scoped.repeated_baselines_deduped,
8553 &0,
8554 "event-scoped modeled events do not count as deduped repeats",
8555 )?;
8556 require_eq(
8557 &event_scoped.likely_file_reads_avoided,
8558 &0,
8559 "folder navigation events do not count as likely file reads avoided",
8560 )?;
8561 require_eq(
8562 &event_scoped.read_avoidance_confidence,
8563 &READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED.to_string(),
8564 "folder navigation read avoidance confidence",
8565 )?;
8566
8567 let mut negative_event = usage_from_estimates("negative", "outline", None, None, 20, 50);
8568 negative_event.estimated_tokens_saved = Some(999);
8569 store.record_usage(&negative_event)?;
8570 let negative = store.token_overview(Some("negative"))?;
8571 require_eq(&negative.calls, &1, "negative session call count")?;
8572 require_eq(
8573 &negative.estimated_saved,
8574 &-30,
8575 "negative session recomputed delta",
8576 )?;
8577 require_eq(
8578 &negative.savings_rate,
8579 &Some(-1.5),
8580 "negative session savings rate",
8581 )?;
8582
8583 let mut zero_event = usage_from_estimates("zero-baseline", "outline", None, None, 0, 12);
8584 zero_event.estimated_tokens_saved = Some(999);
8585 store.record_usage(&zero_event)?;
8586 let zero_baseline = store.token_overview(Some("zero-baseline"))?;
8587 require_eq(&zero_baseline.calls, &1, "zero baseline call count")?;
8588 require_eq(
8589 &zero_baseline.estimated_saved,
8590 &-12,
8591 "zero baseline recomputed delta",
8592 )?;
8593 require_eq(
8594 &zero_baseline.savings_rate,
8595 &None,
8596 "zero baseline savings rate",
8597 )?;
8598
8599 let large_project = tempfile::tempdir()?;
8600 let mut large_store = AtlasStore::in_memory()?;
8601 large_store.set_project_root(large_project.path())?;
8602 let maximum_estimate = usize::try_from(i64::MAX)?;
8603 large_store.record_usage(&usage_from_estimates(
8604 "large-primary",
8605 "large",
8606 None,
8607 None,
8608 maximum_estimate,
8609 0,
8610 ))?;
8611 let Err(overflow) = large_store.record_usage(&usage_from_estimates(
8612 "large-rejected",
8613 "large",
8614 None,
8615 None,
8616 maximum_estimate,
8617 0,
8618 )) else {
8619 return Err(io::Error::other("overflowing telemetry aggregate was committed").into());
8620 };
8621 require_eq(
8622 &matches!(overflow, DbError::TelemetryIntegerOverflow { .. }),
8623 &true,
8624 "overflowing telemetry aggregate error",
8625 )?;
8626 let large = large_store.token_overview(Some("large-primary"))?;
8627 require_eq(
8628 &large.estimated_saved,
8629 &isize::MAX,
8630 "largest accepted aggregate narrows at the public boundary",
8631 )?;
8632 require_eq(
8633 &large_store.token_overview(Some("large-rejected"))?.calls,
8634 &0,
8635 "rejected aggregate left no partial report",
8636 )?;
8637 Ok(())
8638 }
8639
8640 #[test]
8641 fn direct_library_usage_rotates_at_baseline_capacity_without_reopening_the_old_scope()
8642 -> Result<(), Box<dyn Error>> {
8643 let project = tempfile::tempdir()?;
8644 let mut store = AtlasStore::in_memory()?;
8645 store.set_project_root(project.path())?;
8646 let policy = TelemetryRetentionPolicy::default();
8647 let label = "bounded-library-label";
8648
8649 for index in 0..policy.max_baselines_per_instance {
8650 store.record_usage(&usage_from_estimates(
8651 label,
8652 "search",
8653 None,
8654 Some(format!("query-{index}")),
8655 100,
8656 20,
8657 ))?;
8658 }
8659 let old_instance = store
8660 .library_usage_instances
8661 .borrow()
8662 .get(label)
8663 .copied()
8664 .flatten()
8665 .ok_or_else(|| io::Error::other("library instance was not retained"))?;
8666
8667 let boundary_event = usage_from_estimates(
8668 label,
8669 "search",
8670 None,
8671 Some("capacity-boundary".to_string()),
8672 100,
8673 20,
8674 );
8675 store.record_usage(&boundary_event)?;
8676 let replacement = store
8677 .library_usage_instances
8678 .borrow()
8679 .get(label)
8680 .copied()
8681 .flatten()
8682 .ok_or_else(|| io::Error::other("replacement library instance was not retained"))?;
8683 require_eq(
8684 &(replacement != old_instance),
8685 &true,
8686 "capacity rotation created a new internal instance",
8687 )?;
8688 require_eq(
8689 &store.connection.query_row(
8690 "SELECT COUNT(*) FROM usage_instances WHERE state = 'sealed'",
8691 [],
8692 |row| row.get::<_, i64>(0),
8693 )?,
8694 &1,
8695 "sealed predecessor instances",
8696 )?;
8697 require_eq(
8698 &store.connection.query_row(
8699 "SELECT COUNT(*) FROM usage_instances WHERE state = 'active'",
8700 [],
8701 |row| row.get::<_, i64>(0),
8702 )?,
8703 &1,
8704 "active replacement instances",
8705 )?;
8706 require_eq(
8707 &store.connection.query_row(
8708 "SELECT COUNT(*) FROM usage_instance_baselines",
8709 [],
8710 |row| row.get::<_, i64>(0),
8711 )?,
8712 &1,
8713 "only replacement baseline witnesses remain",
8714 )?;
8715 require_eq(
8716 &store.token_overview(Some(label))?.calls,
8717 &(policy.max_baselines_per_instance + 1),
8718 "caller-label report spans both bounded instances",
8719 )?;
8720 require_eq(
8721 &store.token_overview(None)?.estimated_saved,
8722 &(isize::try_from(policy.max_baselines_per_instance + 1)? * 80),
8723 "global totals remain exact across rotation",
8724 )?;
8725 require_eq(
8726 &matches!(
8727 store.record_usage_for_instance(
8728 old_instance,
8729 UsageInstanceOwner::LibraryHandle,
8730 &boundary_event,
8731 false,
8732 ),
8733 Err(DbError::TelemetryInstanceInactive)
8734 ),
8735 &true,
8736 "sealed predecessor cannot be reopened",
8737 )?;
8738 Ok(())
8739 }
8740
8741 #[test]
8742 fn rejected_library_labels_do_not_consume_the_bounded_identity_map()
8743 -> Result<(), Box<dyn Error>> {
8744 let project = tempfile::tempdir()?;
8745 let mut store = AtlasStore::in_memory()?;
8746 store.set_project_root(project.path())?;
8747 let maximum = TelemetryRetentionPolicy::default().max_label_bytes;
8748
8749 for extra in 1..=4 {
8750 let event =
8751 usage_from_estimates(&"x".repeat(maximum + extra), "summary", None, None, 100, 20);
8752 require_eq(
8753 &matches!(
8754 store.record_usage(&event),
8755 Err(DbError::TelemetryFieldTooLarge {
8756 field: "session_id",
8757 ..
8758 })
8759 ),
8760 &true,
8761 "oversized caller label rejection",
8762 )?;
8763 }
8764 require_eq(
8765 &store.library_usage_instances.borrow().len(),
8766 &0,
8767 "rejected labels retained no identity-map entries",
8768 )?;
8769 Ok(())
8770 }
8771
8772 #[test]
8773 fn token_trends_group_usage_by_period_and_bucket() -> Result<(), Box<dyn Error>> {
8774 let project = tempfile::tempdir()?;
8775 let mut store = AtlasStore::in_memory()?;
8776 store.set_project_root(project.path())?;
8777 for (session, bucket, baseline_kind, confidence, without, with) in [
8778 (
8779 "session",
8780 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
8781 "selected_candidates",
8782 "inferred",
8783 100_usize,
8784 25_usize,
8785 ),
8786 (
8787 "session",
8788 TOKEN_BUCKET_FULL_FILE_COMPRESSION,
8789 "full_file",
8790 "observed",
8791 50_usize,
8792 10_usize,
8793 ),
8794 (
8795 "session",
8796 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
8797 "selected_candidates",
8798 "inferred",
8799 80_usize,
8800 20_usize,
8801 ),
8802 (
8803 "other",
8804 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
8805 "selected_candidates",
8806 "inferred",
8807 999_usize,
8808 1_usize,
8809 ),
8810 ] {
8811 store.record_usage(&usage_from_estimates_with_context(
8812 session,
8813 "trend",
8814 None,
8815 None,
8816 without,
8817 with,
8818 bucket,
8819 baseline_kind,
8820 confidence,
8821 ))?;
8822 }
8823
8824 let trends = store.token_trends(Some("session"), TokenTrendWindow::Day)?;
8825 require_eq(&trends.periods.len(), &1, "current daily period")?;
8826 require_eq(&trends.periods[0].calls, &3, "session trend call count")?;
8827 require_eq(
8828 &trends.periods[0].estimated_saved,
8829 &175,
8830 "session trend saved tokens",
8831 )?;
8832 require_eq(
8833 &trends.periods[0].buckets.len(),
8834 &2,
8835 "trend preserves evidence buckets",
8836 )?;
8837 require_eq(
8838 &trends.periods[0].buckets[0].token_savings_bucket,
8839 &TOKEN_BUCKET_FULL_FILE_COMPRESSION.to_string(),
8840 "full-file bucket remains visible",
8841 )?;
8842 require_eq(
8843 &trends.periods[0].buckets[0].confidence,
8844 &"observed".to_string(),
8845 "bucket confidence remains visible",
8846 )?;
8847 let all_labels = store.token_trends(None, TokenTrendWindow::Day)?;
8848 require_eq(&all_labels.periods.len(), &1, "all-label daily period")?;
8849 require_eq(&all_labels.periods[0].calls, &4, "all-label trend calls")?;
8850 Ok(())
8851 }
8852
8853 #[test]
8854 fn unsupported_legacy_schema_is_refused_without_mutation() -> Result<(), Box<dyn Error>> {
8855 let temp = tempfile::tempdir()?;
8856 let db_path = temp.path().join("legacy.db");
8857 {
8858 let connection = Connection::open(&db_path)?;
8859 connection.execute_batch(
8860 "
8861 CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL);
8862 INSERT INTO metadata(key, value) VALUES('schema_version', '7');
8863 CREATE TABLE usage_events (
8864 id INTEGER PRIMARY KEY,
8865 session_id TEXT NOT NULL,
8866 command TEXT NOT NULL,
8867 path TEXT,
8868 query TEXT,
8869 estimated_tokens_without_projectatlas INTEGER,
8870 estimated_tokens_with_projectatlas INTEGER,
8871 estimated_tokens_saved INTEGER,
8872 token_savings_bucket TEXT NOT NULL DEFAULT 'navigation_avoidance',
8873 provider TEXT NOT NULL DEFAULT 'heuristic',
8874 model TEXT NOT NULL DEFAULT 'unknown',
8875 tokenizer_backend TEXT NOT NULL DEFAULT 'chars_div_4',
8876 accuracy TEXT NOT NULL DEFAULT 'heuristic_estimate',
8877 baseline_kind TEXT NOT NULL DEFAULT 'selected_candidates',
8878 confidence TEXT NOT NULL DEFAULT 'inferred',
8879 calculation_trace TEXT NOT NULL DEFAULT 'heuristic=ceil(chars_or_bytes/4)'
8880 );
8881 INSERT INTO usage_events(
8882 session_id,
8883 command,
8884 estimated_tokens_without_projectatlas,
8885 estimated_tokens_with_projectatlas,
8886 estimated_tokens_saved
8887 )
8888 VALUES('legacy-session', 'legacy', 100, 20, 80);
8889 ",
8890 )?;
8891 }
8892
8893 let database_before = fs::read(&db_path)?;
8894 let Err(open_error) = AtlasStore::open(&db_path) else {
8895 return Err(io::Error::other("unsupported schema unexpectedly opened").into());
8896 };
8897 require_eq(
8898 &matches!(
8899 open_error,
8900 DbError::SchemaVersion {
8901 found: 7,
8902 expected: SCHEMA_VERSION,
8903 }
8904 ),
8905 &true,
8906 "unsupported schema rejection",
8907 )?;
8908 require_eq(
8909 &fs::read(&db_path)?,
8910 &database_before,
8911 "unsupported schema bytes remain unchanged",
8912 )?;
8913 Ok(())
8914 }
8915
8916 #[test]
8917 fn stores_project_root_in_metadata() -> Result<(), Box<dyn Error>> {
8918 let mut store = AtlasStore::in_memory()?;
8919 store.set_project_root(Path::new("C:/workspace/example"))?;
8920 require_eq(
8921 &store.project_root()?,
8922 &Some("C:/workspace/example".to_string()),
8923 "project root metadata",
8924 )?;
8925 store.set_project_root(Path::new(r"\\?\C:\workspace\example"))?;
8926 require_eq(
8927 &store.project_root()?,
8928 &Some("C:/workspace/example".to_string()),
8929 "windows extended project root metadata",
8930 )?;
8931 let Err(rebind_error) = store.set_project_root(Path::new(r"\\?\UNC\server\share\repo"))
8932 else {
8933 return Err(io::Error::other("project identity was rebound implicitly").into());
8934 };
8935 require_eq(
8936 &matches!(rebind_error, DbError::ProjectRootMismatch { .. }),
8937 &true,
8938 "project identity rebind rejection",
8939 )?;
8940
8941 let mut unc_store = AtlasStore::in_memory()?;
8942 unc_store.set_project_root(Path::new(r"\\?\UNC\server\share\repo"))?;
8943 require_eq(
8944 &unc_store.project_root()?,
8945 &Some("//server/share/repo".to_string()),
8946 "windows unc project root metadata",
8947 )?;
8948 Ok(())
8949 }
8950
8951 #[test]
8952 fn read_project_root_read_only_does_not_change_database_bytes() -> Result<(), Box<dyn Error>> {
8953 let temp = tempfile::tempdir()?;
8954 let root = temp.path().join("repo with spaces");
8955 let atlas_dir = root.join(".projectatlas");
8956 fs::create_dir_all(&atlas_dir)?;
8957 let db_path = atlas_dir.join("projectatlas.db");
8958 {
8959 let mut store = AtlasStore::open(&db_path)?;
8960 store.set_project_root(&root)?;
8961 store
8962 .connection
8963 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
8964 }
8965 let wal_path = db_path.with_extension("db-wal");
8966 let shm_path = db_path.with_extension("db-shm");
8967 for sidecar_path in [&wal_path, &shm_path] {
8968 match fs::remove_file(sidecar_path) {
8969 Ok(()) => {}
8970 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
8971 Err(error) => return Err(error.into()),
8972 }
8973 }
8974 let database_before = fs::read(&db_path)?;
8975
8976 require_eq(
8977 &read_project_root_read_only(&db_path)?,
8978 &Some(normalize_native_path_display(&root)),
8979 "read-only project root",
8980 )?;
8981 require_eq(
8982 &fs::read(&db_path)?,
8983 &database_before,
8984 "read-only project-root database bytes",
8985 )?;
8986 Ok(())
8987 }
8988
8989 #[test]
8990 fn read_project_root_read_only_observes_active_wal_state() -> Result<(), Box<dyn Error>> {
8991 let temp = tempfile::tempdir()?;
8992 let root = temp.path().join("repo-Δ");
8993 let atlas_dir = root.join(".projectatlas");
8994 fs::create_dir_all(&atlas_dir)?;
8995 let db_path = atlas_dir.join("projectatlas.db");
8996 let mut store = AtlasStore::open(&db_path)?;
8997 store
8998 .connection
8999 .execute_batch("PRAGMA wal_autocheckpoint = 0")?;
9000 store.set_project_root(&root)?;
9001
9002 require_eq(
9003 &sqlite_sidecar_path(&db_path, "-wal").exists(),
9004 &true,
9005 "active WAL exists",
9006 )?;
9007 require_eq(
9008 &read_project_root_read_only(&db_path)?,
9009 &Some(normalize_native_path_display(&root)),
9010 "WAL-aware read-only project root",
9011 )?;
9012 Ok(())
9013 }
9014
9015 #[test]
9016 fn read_only_telemetry_revalidates_project_identity_before_write() -> Result<(), Box<dyn Error>>
9017 {
9018 let temp = tempfile::tempdir()?;
9019 let root = temp.path().join("repository");
9020 fs::create_dir_all(&root)?;
9021 let db_path = temp.path().join("projectatlas.db");
9022 drop(AtlasStore::open_for_project(&db_path, &root)?);
9023
9024 let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
9025 reader.finish_index_read_snapshot()?;
9026 AtlasStore::transition_project_root(&db_path, &root, ProjectRootTransition::Detach)?;
9027
9028 let event = usage_from_estimates(
9029 "identity-race",
9030 "summary",
9031 Some("src/lib.rs".to_string()),
9032 None,
9033 100,
9034 20,
9035 );
9036 let Err(error) = reader.record_usage(&event) else {
9037 return Err(io::Error::other("telemetry wrote through replaced identity").into());
9038 };
9039 require_eq(
9040 &matches!(error, DbError::ProjectRootTransitionChanged { .. }),
9041 &true,
9042 "telemetry project identity recheck",
9043 )?;
9044 let current = AtlasStore::open_read_only_for_project(&db_path, &root)?;
9045 require_eq(
9046 ¤t.token_overview(Some("identity-race"))?.calls,
9047 &0,
9048 "telemetry refused after identity replacement",
9049 )?;
9050 Ok(())
9051 }
9052
9053 #[test]
9054 fn telemetry_contention_uses_ancillary_busy_budget() -> Result<(), Box<dyn Error>> {
9055 let temp = tempfile::tempdir()?;
9056 let root = temp.path().join("repository");
9057 fs::create_dir_all(&root)?;
9058 let db_path = temp.path().join("projectatlas.db");
9059 drop(AtlasStore::open_for_project(&db_path, &root)?);
9060
9061 let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
9062 let writer = Connection::open(&db_path)?;
9063 writer.execute_batch("BEGIN IMMEDIATE")?;
9064 let started = Instant::now();
9065 let result = reader.record_usage(&usage_from_estimates(
9066 "contended",
9067 "overview",
9068 None,
9069 None,
9070 100,
9071 20,
9072 ));
9073 let elapsed = started.elapsed();
9074 writer.execute_batch("ROLLBACK")?;
9075
9076 let Err(error) = result else {
9077 return Err(
9078 io::Error::other("contended telemetry write unexpectedly succeeded").into(),
9079 );
9080 };
9081 require_eq(
9082 &error.is_write_unavailable(),
9083 &true,
9084 "contended telemetry error kind",
9085 )?;
9086 require_eq(
9087 &(elapsed < Duration::from_millis(500)),
9088 &true,
9089 "ancillary telemetry busy latency",
9090 )?;
9091 require_eq(
9092 &reader.token_overview(Some("contended"))?.calls,
9093 &0,
9094 "contended telemetry rollback",
9095 )?;
9096 Ok(())
9097 }
9098
9099 #[cfg(unix)]
9100 #[test]
9101 fn read_only_telemetry_does_not_recreate_a_removed_database() -> Result<(), Box<dyn Error>> {
9102 let temp = tempfile::tempdir()?;
9103 let root = temp.path().join("repository");
9104 fs::create_dir_all(&root)?;
9105 let db_path = temp.path().join("projectatlas.db");
9106 drop(AtlasStore::open_for_project(&db_path, &root)?);
9107
9108 let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
9109 fs::remove_file(&db_path)?;
9110 let event = usage_from_estimates(
9111 "removed-database",
9112 "summary",
9113 Some("src/lib.rs".to_string()),
9114 None,
9115 100,
9116 20,
9117 );
9118 if reader.record_usage(&event).is_ok() {
9119 return Err(io::Error::other("telemetry reopened a removed database").into());
9120 }
9121 require_eq(
9122 &db_path.exists(),
9123 &false,
9124 "telemetry must not recreate a removed database",
9125 )?;
9126 Ok(())
9127 }
9128
9129 #[test]
9130 fn read_only_store_opens_checkpointed_wal_without_existing_sidecars()
9131 -> Result<(), Box<dyn Error>> {
9132 let temp = tempfile::tempdir()?;
9133 let root = temp.path().join("repo with spaces");
9134 let atlas_dir = root.join(".projectatlas");
9135 fs::create_dir_all(&atlas_dir)?;
9136 let db_path = atlas_dir.join("projectatlas.db");
9137 {
9138 let mut store = AtlasStore::open(&db_path)?;
9139 store.set_project_root(&root)?;
9140 store
9141 .connection
9142 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
9143 }
9144 for sidecar_path in [
9145 sqlite_sidecar_path(&db_path, "-wal"),
9146 sqlite_sidecar_path(&db_path, "-shm"),
9147 ] {
9148 match fs::remove_file(sidecar_path) {
9149 Ok(()) => {}
9150 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9151 Err(error) => return Err(error.into()),
9152 }
9153 }
9154
9155 let store = AtlasStore::open_read_only(&db_path)?;
9156 require_eq(
9157 &store.project_root()?,
9158 &Some(normalize_native_path_display(&root)),
9159 "read-only checkpointed project root",
9160 )?;
9161 store.finish_index_read_snapshot()?;
9162 Ok(())
9163 }
9164
9165 #[test]
9166 fn partial_scan_updates_and_absents_paths() -> Result<(), Box<dyn Error>> {
9167 let mut store = AtlasStore::in_memory()?;
9168 store.replace_scan(&[
9169 test_file_node("src/a.rs", "hash-a"),
9170 test_file_node("src/b.rs", "hash-b"),
9171 ])?;
9172 store.upsert_scan_nodes(&[test_file_node("src/a.rs", "hash-a2")])?;
9173 let updated = store
9174 .load_node_by_path("src/a.rs")?
9175 .ok_or_else(|| io::Error::other("updated node missing"))?;
9176 require_eq(
9177 &updated.node.content_hash,
9178 &Some("hash-a2".to_string()),
9179 "partial content hash",
9180 )?;
9181 require_eq(
9182 &store.load_node_by_path("src/b.rs")?.is_some(),
9183 &true,
9184 "unrelated node remains indexed",
9185 )?;
9186 store.mark_paths_absent(&["src/b.rs".to_string()])?;
9187 require_eq(
9188 &store.load_node_by_path("src/b.rs")?.is_none(),
9189 &true,
9190 "absent path is no longer returned",
9191 )?;
9192 Ok(())
9193 }
9194
9195 #[test]
9196 fn approved_purpose_survives_incremental_file_hash_changes() -> Result<(), Box<dyn Error>> {
9197 let mut store = AtlasStore::in_memory()?;
9198 store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
9199 store.set_purpose(
9200 "src/main.rs",
9201 "Application entry point",
9202 PurposeSource::Agent,
9203 )?;
9204 store.upsert_scan_nodes(&[test_file_node("src/main.rs", "hash-b")])?;
9205
9206 let node = store
9207 .load_node_by_path("src/main.rs")?
9208 .ok_or_else(|| io::Error::other("changed node missing"))?;
9209 require_eq(
9210 &node.purpose.status,
9211 &PurposeStatus::Approved,
9212 "changed approved file purpose status",
9213 )?;
9214 require_eq(
9215 &node.purpose.agent_reviewed(),
9216 &true,
9217 "changed approved file remains agent reviewed",
9218 )?;
9219 Ok(())
9220 }
9221
9222 #[test]
9223 fn ranked_nodes_are_loaded_bounded_from_sql() -> Result<(), Box<dyn Error>> {
9224 let mut store = AtlasStore::in_memory()?;
9225 let mut gradle_task_node = test_file_node("build.gradle.kts", "hash-gradle");
9226 gradle_task_node.extension = Some(".kts".to_string());
9227 gradle_task_node.language = Some("kotlin".to_string());
9228 store.replace_scan(&[
9229 test_folder_node("src/auth"),
9230 test_folder_node("src/ui"),
9231 test_file_node("src/auth/login.rs", "hash-login"),
9232 test_file_node("src/ui/button.rs", "hash-button"),
9233 gradle_task_node,
9234 ])?;
9235 store.set_purpose(
9236 "src/auth",
9237 "Authentication workflow folder",
9238 PurposeSource::Agent,
9239 )?;
9240 store.set_purpose("src/ui", "User interface folder", PurposeSource::Agent)?;
9241 store.set_node_summary("src/auth/login.rs", "rust source defining login flow")?;
9242
9243 let folders = store.load_ranked_nodes("authentication", NodeKind::Folder, None, 1, 0)?;
9244 require_eq(&folders.len(), &1, "bounded folder ranking")?;
9245 require_eq(
9246 &folders[0].node.path,
9247 &"src/auth".to_string(),
9248 "semantic folder ranking",
9249 )?;
9250
9251 let files = store.load_ranked_nodes("login", NodeKind::File, Some("src/auth"), 10, 0)?;
9252 require_eq(&files.len(), &1, "folder-constrained file ranking")?;
9253 require_eq(
9254 &files[0].node.path,
9255 &"src/auth/login.rs".to_string(),
9256 "ranked file path",
9257 )?;
9258 store.replace_symbol_graph(&SymbolGraph {
9259 path: "build.gradle.kts".to_string(),
9260 language: Some("kotlin".to_string()),
9261 parser: ParserKind::TreeSitter,
9262 symbols: vec![CodeSymbol {
9263 path: "build.gradle.kts".to_string(),
9264 language: Some("kotlin".to_string()),
9265 name: "bootRunE2E".to_string(),
9266 kind: SymbolKind::Function,
9267 signature: "tasks.register<BootRun>(\"bootRunE2E\")".to_string(),
9268 exported: false,
9269 documentation: None,
9270 line_start: 1,
9271 line_end: 1,
9272 parent: None,
9273 parser: ParserKind::TreeSitter,
9274 detail: Some("gradle-kotlin-dsl-task".to_string()),
9275 }],
9276 relations: Vec::new(),
9277 })?;
9278 let gradle_files = store.load_ranked_nodes("bootRunE2E", NodeKind::File, None, 10, 0)?;
9279 require_eq(&gradle_files.len(), &1, "symbol-ranked file count")?;
9280 require_eq(
9281 &gradle_files[0].node.path,
9282 &"build.gradle.kts".to_string(),
9283 "symbol-ranked file path",
9284 )?;
9285 store.clear_symbol_graph_for_path("build.gradle.kts")?;
9286 let cleared_gradle_files =
9287 store.load_ranked_nodes("bootRunE2E", NodeKind::File, None, 10, 0)?;
9288 require_eq(
9289 &cleared_gradle_files.len(),
9290 &0,
9291 "cleared symbol-ranked file count",
9292 )?;
9293 Ok(())
9294 }
9295
9296 #[test]
9297 fn ranked_node_admission_preserves_dominant_tiers_before_candidate_cap()
9298 -> Result<(), Box<dyn Error>> {
9299 let mut store = AtlasStore::in_memory()?;
9300 let mut nodes = vec![
9301 test_file_node("needle", "hash-exact"),
9302 test_file_node("deep/needle", "hash-name"),
9303 test_file_node("reviewed.rs", "hash-reviewed"),
9304 ];
9305 nodes.extend(
9306 (0..130).map(|index| test_file_node(&format!("weak/needle-{index:03}.rs"), "hash")),
9307 );
9308 store.replace_scan(&nodes)?;
9309 store.set_purpose(
9310 "reviewed.rs",
9311 "Own needle responsibility",
9312 PurposeSource::Agent,
9313 )?;
9314 for index in 0..130 {
9315 let path = format!("weak/needle-{index:03}.rs");
9316 store.set_suggested_purpose(&path, "Generated needle suggestion")?;
9317 store.set_node_summary(&path, "Observed needle summary")?;
9318 }
9319
9320 let selected = store.load_ranked_nodes("needle", NodeKind::File, None, 100, 0)?;
9321 require_eq(&selected.len(), &100, "bounded adversarial candidate count")?;
9322 require_eq(
9323 &selected[..3]
9324 .iter()
9325 .map(|node| node.node.path.as_str())
9326 .collect::<Vec<_>>(),
9327 &vec!["needle", "deep/needle", "reviewed.rs"],
9328 "pre-cap exact path basename and reviewed-purpose admission",
9329 )?;
9330 Ok(())
9331 }
9332
9333 #[test]
9334 fn folder_like_filters_treat_wildcards_as_literal_path_text() -> Result<(), Box<dyn Error>> {
9335 let mut store = AtlasStore::in_memory()?;
9336 store.replace_scan(&[
9337 test_folder_node("src/a%b"),
9338 test_folder_node("src/axb"),
9339 test_folder_node("src/a_b"),
9340 test_folder_node("src/acb"),
9341 test_file_node("src/a%b/target.rs", "hash-percent-target"),
9342 test_file_node("src/axb/false.rs", "hash-percent-false"),
9343 test_file_node("src/a_b/target.rs", "hash-underscore-target"),
9344 test_file_node("src/acb/false.rs", "hash-underscore-false"),
9345 ])?;
9346 for path in [
9347 "src/a%b/target.rs",
9348 "src/axb/false.rs",
9349 "src/a_b/target.rs",
9350 "src/acb/false.rs",
9351 ] {
9352 store.set_node_summary(path, "needle indexed summary")?;
9353 }
9354
9355 let percent_files =
9356 store.load_ranked_nodes("needle", NodeKind::File, Some("src/a%b"), 10, 0)?;
9357 require_eq(&percent_files.len(), &1, "percent folder ranked count")?;
9358 require_eq(
9359 &percent_files[0].node.path,
9360 &"src/a%b/target.rs".to_string(),
9361 "percent folder ranked path",
9362 )?;
9363 require_eq(
9364 &store.source_file_byte_count(Some("src/a%b"))?,
9365 &12,
9366 "percent folder byte count",
9367 )?;
9368
9369 let mut visited = Vec::new();
9370 store.visit_file_token_estimates(Some("src/a_b"), |path, _size| {
9371 visited.push(path);
9372 Ok(true)
9373 })?;
9374 require_eq(
9375 &visited,
9376 &vec!["src/a_b/target.rs".to_string()],
9377 "underscore folder token paths",
9378 )?;
9379
9380 store.mark_paths_absent(&["src/a%b".to_string(), "src/a_b".to_string()])?;
9381 require_eq(
9382 &store.load_node_by_path("src/axb/false.rs")?.is_some(),
9383 &true,
9384 "percent-like sibling remains indexed",
9385 )?;
9386 require_eq(
9387 &store.load_node_by_path("src/acb/false.rs")?.is_some(),
9388 &true,
9389 "underscore-like sibling remains indexed",
9390 )?;
9391 require_eq(
9392 &store.load_node_by_path("src/a%b/target.rs")?.is_none(),
9393 &true,
9394 "percent folder target removed",
9395 )?;
9396 require_eq(
9397 &store.load_node_by_path("src/a_b/target.rs")?.is_none(),
9398 &true,
9399 "underscore folder target removed",
9400 )?;
9401 Ok(())
9402 }
9403
9404 #[test]
9405 fn sql_health_findings_match_resolution_ids() -> Result<(), Box<dyn Error>> {
9406 let mut store = AtlasStore::in_memory()?;
9407 store.replace_scan(&[
9408 test_file_node("src/a.rs", "hash-a"),
9409 test_file_node("src/b.rs", "hash-b"),
9410 ])?;
9411 store.set_purpose("src/a.rs", "Shared purpose", PurposeSource::Agent)?;
9412 store.set_purpose("src/b.rs", "Shared purpose", PurposeSource::Agent)?;
9413
9414 let findings = store.unresolved_health_findings(&[])?;
9415 let duplicate = findings
9416 .iter()
9417 .find(|finding| finding.category == "duplicate-purpose")
9418 .ok_or_else(|| io::Error::other("duplicate-purpose finding missing"))?;
9419 store.resolve_health_finding(&HealthResolution {
9420 finding_id: duplicate.id.clone(),
9421 category: duplicate.category.clone(),
9422 path: duplicate.path.clone(),
9423 related_path: duplicate.related_path.clone(),
9424 rationale: "Intentional mirror for test.".to_string(),
9425 })?;
9426 let remaining = store.unresolved_health_findings(&store.resolved_health_ids()?)?;
9427 require_eq(&remaining.is_empty(), &true, "resolved SQL health finding")?;
9428 Ok(())
9429 }
9430
9431 #[test]
9432 fn unresolved_health_findings_page_filters_and_bounds_rows() -> Result<(), Box<dyn Error>> {
9433 let mut store = AtlasStore::in_memory()?;
9434 store.replace_scan(&[
9435 test_folder_node("."),
9436 test_folder_node("src"),
9437 test_file_node("src/a.rs", "hash-a"),
9438 test_file_node("docs/a.rs", "hash-doc"),
9439 ])?;
9440 let query = HealthQuery {
9441 start_index: 1,
9442 limit: 1,
9443 category: Some("missing-purpose".to_string()),
9444 severity: Some(Severity::Warning),
9445 path_prefix: Some("src".to_string()),
9446 summary_only: false,
9447 scope: HealthScope::all(),
9448 };
9449
9450 let page = store.unresolved_health_findings_page(&[], &query)?;
9451 require_eq(&page.unfiltered_total, &4, "unfiltered health total")?;
9452 require_eq(&page.total, &2, "filtered health total")?;
9453 require_eq(&page.returned, &1, "returned health rows")?;
9454 require_eq(
9455 &page.findings[0].path,
9456 &"src/a.rs".to_string(),
9457 "paged path",
9458 )?;
9459
9460 let summary_page = store.unresolved_health_findings_page(
9461 &[],
9462 &HealthQuery {
9463 summary_only: true,
9464 ..query
9465 },
9466 )?;
9467 require_eq(&summary_page.total, &2, "summary-only total")?;
9468 require_eq(
9469 &summary_page.findings.is_empty(),
9470 &true,
9471 "summary-only rows",
9472 )?;
9473 Ok(())
9474 }
9475
9476 #[test]
9477 fn unresolved_health_findings_page_skips_resolved_lifecycle_rows_before_paging()
9478 -> Result<(), Box<dyn Error>> {
9479 let mut store = AtlasStore::in_memory()?;
9480 store.replace_scan(&[
9481 test_file_node("src/a.rs", "hash-a"),
9482 test_file_node("src/b.rs", "hash-b"),
9483 test_file_node("src/c.rs", "hash-c"),
9484 test_file_node("src/d.rs", "hash-d"),
9485 ])?;
9486 store.resolve_health_finding(&HealthResolution {
9487 finding_id: finding_id("missing-purpose", "src/b.rs", None),
9488 category: "missing-purpose".to_string(),
9489 path: "src/b.rs".to_string(),
9490 related_path: None,
9491 rationale: "Resolved for pagination regression.".to_string(),
9492 })?;
9493
9494 let page = store.unresolved_health_findings_page_current(&HealthQuery {
9495 start_index: 0,
9496 limit: 2,
9497 category: Some("missing-purpose".to_string()),
9498 severity: Some(Severity::Warning),
9499 path_prefix: Some("src".to_string()),
9500 summary_only: false,
9501 scope: HealthScope::all(),
9502 })?;
9503
9504 require_eq(&page.total, &3, "filtered unresolved missing total")?;
9505 require_eq(&page.returned, &2, "returned unresolved missing rows")?;
9506 require_eq(
9507 &page
9508 .findings
9509 .iter()
9510 .map(|finding| finding.path.as_str())
9511 .collect::<Vec<_>>(),
9512 &vec!["src/a.rs", "src/c.rs"],
9513 "resolved row skipped before limit",
9514 )?;
9515 Ok(())
9516 }
9517
9518 #[test]
9519 fn stored_health_resolution_filter_stays_indexed_and_bind_bounded() -> Result<(), Box<dyn Error>>
9520 {
9521 const HISTORICAL_RESOLUTIONS: usize = 1_500;
9522
9523 let temp = tempfile::tempdir()?;
9524 let root = temp.path().join("repository");
9525 fs::create_dir_all(root.join("src"))?;
9526 let database = temp.path().join("projectatlas.db");
9527 let mut store = AtlasStore::open_for_project(&database, &root)?;
9528 store.replace_scan(&[test_file_node("src/current.rs", "hash-current")])?;
9529 {
9530 let transaction = store.connection.transaction()?;
9531 {
9532 let mut statement = transaction.prepare(
9533 "
9534 INSERT INTO health_resolutions(
9535 finding_id, category, path, related_path, rationale, resolved_by
9536 )
9537 VALUES(?1, 'missing-purpose', ?2, NULL, 'historical', 'agent')
9538 ",
9539 )?;
9540 for index in 0..HISTORICAL_RESOLUTIONS {
9541 let path = format!("removed/{index}.rs");
9542 statement.execute(params![finding_id("missing-purpose", &path, None), path])?;
9543 }
9544 }
9545 transaction.commit()?;
9546 }
9547
9548 drop(store);
9549 let store = AtlasStore::open_read_only_for_project(&database, &root)?;
9550 let page = store.unresolved_health_findings_page_current(&HealthQuery {
9551 start_index: 0,
9552 limit: 2,
9553 category: Some("missing-purpose".to_string()),
9554 severity: Some(Severity::Warning),
9555 path_prefix: Some("src".to_string()),
9556 summary_only: false,
9557 scope: HealthScope::all(),
9558 })?;
9559 require_eq(&page.total, &1, "current unresolved total")?;
9560 require_eq(
9561 &page.findings[0].path,
9562 &"src/current.rs".to_string(),
9563 "current unresolved path",
9564 )?;
9565
9566 let (where_clause, values) = purpose_status_where_clause(
9567 PURPOSE_HEALTH_SPECS[0],
9568 None,
9569 HealthResolutionFilter::Stored,
9570 HealthScope::all(),
9571 );
9572 require_eq(&values.len(), &1, "stored filter bind count")?;
9573 let plan_sql = format!(
9574 "EXPLAIN QUERY PLAN
9575 SELECT COUNT(*)
9576 FROM nodes n
9577 JOIN purposes p ON p.node_id = n.id
9578 WHERE {where_clause}"
9579 );
9580 let mut statement = store.connection.prepare(&plan_sql)?;
9581 let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(3))?;
9582 let mut plan = Vec::new();
9583 for row in rows {
9584 plan.push(row?);
9585 }
9586 if !plan.iter().any(|detail| {
9587 detail.contains("health_resolutions")
9588 && detail.contains("finding_id")
9589 && detail.contains("SEARCH")
9590 }) {
9591 return Err(io::Error::other(format!(
9592 "stored resolution anti-lookup did not use the finding-id index: {plan:?}"
9593 ))
9594 .into());
9595 }
9596 store.finish_index_read_snapshot()?;
9597 Ok(())
9598 }
9599
9600 #[test]
9601 fn unresolved_health_findings_page_streams_duplicate_and_temp_rows()
9602 -> Result<(), Box<dyn Error>> {
9603 let mut store = AtlasStore::in_memory()?;
9604 store.replace_scan(&[
9605 test_folder_node("."),
9606 test_folder_node("tmp"),
9607 test_folder_node("src"),
9608 test_folder_node("src/tmp"),
9609 test_file_node("src/a.rs", "hash-a"),
9610 test_file_node("src/b.rs", "hash-b"),
9611 ])?;
9612 store.set_purpose(".", "Repository root", PurposeSource::Agent)?;
9613 store.set_purpose("src", "Source folder", PurposeSource::Agent)?;
9614 store.set_purpose("tmp", "Temporary output", PurposeSource::Agent)?;
9615 store.set_purpose("src/tmp", "Source temporary output", PurposeSource::Agent)?;
9616 store.set_purpose("src/a.rs", "Shared implementation", PurposeSource::Agent)?;
9617 store.set_purpose("src/b.rs", "Shared implementation", PurposeSource::Agent)?;
9618
9619 let duplicate_page = store.unresolved_health_findings_page(
9620 &[],
9621 &HealthQuery {
9622 start_index: 0,
9623 limit: 1,
9624 category: Some("duplicate-purpose".to_string()),
9625 severity: Some(Severity::Warning),
9626 path_prefix: Some("src".to_string()),
9627 summary_only: false,
9628 scope: HealthScope::all(),
9629 },
9630 )?;
9631 require_eq(&duplicate_page.total, &1, "duplicate total")?;
9632 require_eq(&duplicate_page.returned, &1, "duplicate returned")?;
9633 require_eq(
9634 &duplicate_page.findings[0].category,
9635 &"duplicate-purpose".to_string(),
9636 "duplicate category",
9637 )?;
9638
9639 let temp_page = store.unresolved_health_findings_page(
9640 &[],
9641 &HealthQuery {
9642 start_index: 0,
9643 limit: 1,
9644 category: Some("repeated-temporary-folder".to_string()),
9645 severity: Some(Severity::Warning),
9646 path_prefix: Some(".".to_string()),
9647 summary_only: false,
9648 scope: HealthScope::all(),
9649 },
9650 )?;
9651 require_eq(&temp_page.total, &1, "temp total")?;
9652 require_eq(&temp_page.returned, &1, "temp returned")?;
9653 require_eq(
9654 &temp_page.findings[0].category,
9655 &"repeated-temporary-folder".to_string(),
9656 "temp category",
9657 )?;
9658 Ok(())
9659 }
9660
9661 #[test]
9662 fn unresolved_health_findings_page_source_only_filters_asset_noise()
9663 -> Result<(), Box<dyn Error>> {
9664 let mut store = AtlasStore::in_memory()?;
9665 let asset_file = Node {
9666 path: "assets/logo.png".to_string(),
9667 kind: NodeKind::File,
9668 parent_path: Some("assets".to_string()),
9669 extension: Some(".png".to_string()),
9670 language: None,
9671 size_bytes: Some(42),
9672 mtime_ns: Some(10),
9673 content_hash: Some("hash-logo".to_string()),
9674 };
9675 store.replace_scan(&[
9676 test_folder_node("."),
9677 test_folder_node("src"),
9678 test_file_node("src/main.rs", "hash-main"),
9679 test_folder_node("assets"),
9680 asset_file,
9681 ])?;
9682
9683 let page = store.unresolved_health_findings_page(
9684 &[],
9685 &HealthQuery {
9686 start_index: 0,
9687 limit: 10,
9688 category: Some("missing-purpose".to_string()),
9689 severity: Some(Severity::Warning),
9690 path_prefix: Some(".".to_string()),
9691 summary_only: false,
9692 scope: HealthScope::source_only(),
9693 },
9694 )?;
9695
9696 require_eq(&page.unfiltered_total, &5, "all unresolved rows")?;
9697 require_eq(&page.total, &3, "source-only missing total")?;
9698 require_eq(
9699 &page
9700 .findings
9701 .iter()
9702 .map(|finding| finding.path.as_str())
9703 .collect::<Vec<_>>(),
9704 &vec![".", "src", "src/main.rs"],
9705 "source-only paths",
9706 )?;
9707 Ok(())
9708 }
9709
9710 #[test]
9711 fn high_impact_purpose_queue_filters_low_priority_files() -> Result<(), Box<dyn Error>> {
9712 let mut store = AtlasStore::in_memory()?;
9713 store.replace_scan(&[
9714 test_folder_node("."),
9715 test_folder_node("src"),
9716 test_file_node("src/main.rs", "hash-main"),
9717 test_file_node("src/helper.rs", "hash-helper"),
9718 test_file_node("build.gradle.kts", "hash-gradle"),
9719 ])?;
9720
9721 let page = store.unresolved_health_findings_page(
9722 &[],
9723 &HealthQuery {
9724 start_index: 0,
9725 limit: 10,
9726 category: Some("missing-purpose".to_string()),
9727 severity: Some(Severity::Warning),
9728 path_prefix: Some(".".to_string()),
9729 summary_only: false,
9730 scope: HealthScope::purpose_default(),
9731 },
9732 )?;
9733
9734 require_eq(&page.unfiltered_total, &5, "all missing rows")?;
9735 require_eq(&page.total, &4, "default actionable rows")?;
9736 require_eq(
9737 &page
9738 .findings
9739 .iter()
9740 .map(|finding| finding.path.as_str())
9741 .collect::<Vec<_>>(),
9742 &vec![".", "src", "build.gradle.kts", "src/main.rs"],
9743 "folder-first high-impact queue paths",
9744 )?;
9745
9746 let broad_page = store.unresolved_health_findings_page(
9747 &[],
9748 &HealthQuery {
9749 start_index: 0,
9750 limit: 10,
9751 category: Some("missing-purpose".to_string()),
9752 severity: Some(Severity::Warning),
9753 path_prefix: Some(".".to_string()),
9754 summary_only: false,
9755 scope: HealthScope::all(),
9756 },
9757 )?;
9758 require_eq(&broad_page.total, &5, "explicit broad queue rows")?;
9759 Ok(())
9760 }
9761
9762 #[test]
9763 fn high_impact_purpose_queue_keeps_asset_only_folders_without_asset_files()
9764 -> Result<(), Box<dyn Error>> {
9765 let mut store = AtlasStore::in_memory()?;
9766 let asset_file = Node {
9767 path: "assets/logo.svg".to_string(),
9768 kind: NodeKind::File,
9769 parent_path: Some("assets".to_string()),
9770 extension: Some(".svg".to_string()),
9771 language: None,
9772 size_bytes: Some(42),
9773 mtime_ns: Some(10),
9774 content_hash: Some("hash-logo".to_string()),
9775 };
9776 store.replace_scan(&[
9777 test_folder_node("."),
9778 test_folder_node("assets"),
9779 test_folder_node("src"),
9780 asset_file,
9781 test_file_node("src/helper.rs", "hash-helper"),
9782 ])?;
9783
9784 let page = store.unresolved_health_findings_page(
9785 &[],
9786 &HealthQuery {
9787 start_index: 0,
9788 limit: 10,
9789 category: Some("missing-purpose".to_string()),
9790 severity: Some(Severity::Warning),
9791 path_prefix: Some(".".to_string()),
9792 summary_only: false,
9793 scope: HealthScope::purpose_default(),
9794 },
9795 )?;
9796
9797 require_eq(&page.unfiltered_total, &5, "all missing rows")?;
9798 require_eq(&page.total, &3, "all folders without asset files")?;
9799 require_eq(
9800 &page
9801 .findings
9802 .iter()
9803 .map(|finding| finding.path.as_str())
9804 .collect::<Vec<_>>(),
9805 &vec![".", "assets", "src"],
9806 "folder-first default queue keeps asset-only folders",
9807 )?;
9808 Ok(())
9809 }
9810
9811 #[test]
9812 fn high_impact_purpose_queue_pages_folders_before_files() -> Result<(), Box<dyn Error>> {
9813 let mut store = AtlasStore::in_memory()?;
9814 store.replace_scan(&[
9815 test_folder_node("."),
9816 test_folder_node("src"),
9817 test_file_node("Cargo.toml", "hash-cargo"),
9818 test_file_node("package.json", "hash-package"),
9819 test_file_node("pyproject.toml", "hash-python"),
9820 ])?;
9821
9822 let page = store.unresolved_health_findings_page(
9823 &[],
9824 &HealthQuery {
9825 start_index: 0,
9826 limit: 2,
9827 category: Some("missing-purpose".to_string()),
9828 severity: Some(Severity::Warning),
9829 path_prefix: Some(".".to_string()),
9830 summary_only: false,
9831 scope: HealthScope::purpose_default(),
9832 },
9833 )?;
9834
9835 require_eq(&page.total, &5, "default actionable total")?;
9836 require_eq(
9837 &page
9838 .findings
9839 .iter()
9840 .map(|finding| finding.path.as_str())
9841 .collect::<Vec<_>>(),
9842 &vec![".", "src"],
9843 "small page keeps folders first",
9844 )?;
9845 Ok(())
9846 }
9847
9848 #[test]
9849 fn high_impact_purpose_queue_omits_low_value_stale_files() -> Result<(), Box<dyn Error>> {
9850 let mut store = AtlasStore::in_memory()?;
9851 store.replace_scan(&[
9852 test_file_node("src/helper.rs", "hash-a"),
9853 test_file_node("Cargo.toml", "hash-cargo"),
9854 test_file_node("package.json", "hash-package"),
9855 ])?;
9856 store.set_purpose(
9857 "src/helper.rs",
9858 "Reviewed helper implementation.",
9859 PurposeSource::Agent,
9860 )?;
9861 store.set_purpose(
9862 "Cargo.toml",
9863 "Reviewed Cargo manifest.",
9864 PurposeSource::Agent,
9865 )?;
9866 store.connection.execute(
9867 "UPDATE purposes
9868 SET status = 'stale'
9869 WHERE node_id IN (
9870 SELECT id FROM nodes WHERE path IN ('src/helper.rs', 'Cargo.toml')
9871 )",
9872 [],
9873 )?;
9874 store.replace_scan(&[
9875 test_file_node("src/helper.rs", "hash-b"),
9876 test_file_node("Cargo.toml", "hash-cargo-new"),
9877 test_file_node("package.json", "hash-package"),
9878 ])?;
9879
9880 let page = store.unresolved_health_findings_page(
9881 &[],
9882 &HealthQuery {
9883 start_index: 0,
9884 limit: 1,
9885 category: None,
9886 severity: Some(Severity::Warning),
9887 path_prefix: Some(".".to_string()),
9888 summary_only: false,
9889 scope: HealthScope::purpose_default(),
9890 },
9891 )?;
9892
9893 require_eq(&page.total, &2, "default actionable total")?;
9894 require_eq(&page.returned, &1, "small page returned")?;
9895 require_eq(
9896 &page.findings[0].category,
9897 &"stale-purpose".to_string(),
9898 "stale high-impact file is still prioritized",
9899 )?;
9900 require_eq(
9901 &page.findings[0].path,
9902 &"Cargo.toml".to_string(),
9903 "stale high-impact file path",
9904 )?;
9905
9906 let broad_page = store.unresolved_health_findings_page(
9907 &[],
9908 &HealthQuery {
9909 start_index: 0,
9910 limit: 10,
9911 category: Some(CATEGORY_STALE_PURPOSE.to_string()),
9912 severity: Some(Severity::Warning),
9913 path_prefix: Some(".".to_string()),
9914 summary_only: false,
9915 scope: HealthScope::purpose_with_source_files(),
9916 },
9917 )?;
9918 require_eq(&broad_page.total, &2, "broad source stale rows")?;
9919 require_eq(
9920 &health_paths(&broad_page),
9921 &vec!["Cargo.toml", "src/helper.rs"],
9922 "broad scope includes low-value stale source files",
9923 )?;
9924 Ok(())
9925 }
9926
9927 #[test]
9928 fn include_assets_queue_includes_asset_files_not_low_priority_source()
9929 -> Result<(), Box<dyn Error>> {
9930 let mut store = AtlasStore::in_memory()?;
9931 let asset_file = Node {
9932 path: "assets/logo.svg".to_string(),
9933 kind: NodeKind::File,
9934 parent_path: Some("assets".to_string()),
9935 extension: Some(".svg".to_string()),
9936 language: None,
9937 size_bytes: Some(42),
9938 mtime_ns: Some(10),
9939 content_hash: Some("hash-logo".to_string()),
9940 };
9941 store.replace_scan(&[
9942 test_folder_node("."),
9943 test_folder_node("assets"),
9944 test_folder_node("src"),
9945 asset_file,
9946 test_file_node("src/helper.rs", "hash-helper"),
9947 ])?;
9948
9949 let page = store.unresolved_health_findings_page(
9950 &[],
9951 &HealthQuery {
9952 start_index: 0,
9953 limit: 10,
9954 category: Some("missing-purpose".to_string()),
9955 severity: Some(Severity::Warning),
9956 path_prefix: Some(".".to_string()),
9957 summary_only: false,
9958 scope: HealthScope::purpose_with_assets(),
9959 },
9960 )?;
9961
9962 require_eq(&page.unfiltered_total, &5, "all missing rows")?;
9963 require_eq(
9964 &page.total,
9965 &4,
9966 "assets included without broad source cleanup",
9967 )?;
9968 require_eq(
9969 &page
9970 .findings
9971 .iter()
9972 .map(|finding| finding.path.as_str())
9973 .collect::<Vec<_>>(),
9974 &vec![".", "assets", "src", "assets/logo.svg"],
9975 "asset files included and low-priority source omitted",
9976 )?;
9977 Ok(())
9978 }
9979
9980 #[test]
9981 fn legacy_human_stale_files_remain_in_default_queue() -> Result<(), Box<dyn Error>> {
9982 let mut store = AtlasStore::in_memory()?;
9983 store.replace_scan(&[test_file_node("src/helper.rs", "hash-a")])?;
9984 store.set_purpose(
9985 "src/helper.rs",
9986 "Legacy reviewed helper implementation.",
9987 PurposeSource::Agent,
9988 )?;
9989 store.connection.execute(
9990 "
9991 UPDATE purposes
9992 SET source = 'human', status = 'stale'
9993 WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/helper.rs')
9994 ",
9995 [],
9996 )?;
9997 store.replace_scan(&[test_file_node("src/helper.rs", "hash-b")])?;
9998
9999 let page = store.unresolved_health_findings_page(
10000 &[],
10001 &HealthQuery {
10002 start_index: 0,
10003 limit: 10,
10004 category: Some("stale-purpose".to_string()),
10005 severity: Some(Severity::Warning),
10006 path_prefix: Some(".".to_string()),
10007 summary_only: false,
10008 scope: HealthScope::purpose_default(),
10009 },
10010 )?;
10011
10012 require_eq(&page.total, &1, "legacy reviewed stale row total")?;
10013 require_eq(
10014 &page.findings[0].path,
10015 &"src/helper.rs".to_string(),
10016 "legacy reviewed stale file",
10017 )?;
10018 Ok(())
10019 }
10020
10021 #[test]
10022 fn stale_imported_files_remain_in_default_queue() -> Result<(), Box<dyn Error>> {
10023 let mut store = AtlasStore::in_memory()?;
10024 store.replace_scan(&[
10025 test_file_node("src/imported.rs", "hash-a"),
10026 test_file_node("Cargo.toml", "hash-cargo"),
10027 ])?;
10028 store.set_purpose(
10029 "src/imported.rs",
10030 "Imported helper implementation.",
10031 PurposeSource::Imported,
10032 )?;
10033 store.connection.execute(
10034 "UPDATE purposes
10035 SET status = 'stale'
10036 WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/imported.rs')",
10037 [],
10038 )?;
10039 store.replace_scan(&[
10040 test_file_node("src/imported.rs", "hash-b"),
10041 test_file_node("Cargo.toml", "hash-cargo"),
10042 ])?;
10043
10044 let page = store.unresolved_health_findings_page(
10045 &[],
10046 &HealthQuery {
10047 start_index: 0,
10048 limit: 10,
10049 category: None,
10050 severity: Some(Severity::Warning),
10051 path_prefix: Some(".".to_string()),
10052 summary_only: false,
10053 scope: HealthScope::purpose_default(),
10054 },
10055 )?;
10056
10057 require_eq(
10058 &page.total,
10059 &2,
10060 "default queue includes stale imported file",
10061 )?;
10062 require_eq(
10063 &health_paths(&page),
10064 &vec!["src/imported.rs", "Cargo.toml"],
10065 "stale imported file is queued before high-impact files",
10066 )?;
10067 require_eq(
10068 &page.findings[0].category,
10069 &"stale-purpose".to_string(),
10070 "stale imported finding category",
10071 )?;
10072 Ok(())
10073 }
10074
10075 #[test]
10076 fn duplicate_purpose_health_is_contextual_for_folders() -> Result<(), Box<dyn Error>> {
10077 let mut store = AtlasStore::in_memory()?;
10078 store.replace_scan(&[
10079 test_folder_node("."),
10080 test_folder_node("customers"),
10081 test_folder_node("customers/service"),
10082 test_folder_node("settings"),
10083 test_folder_node("settings/service"),
10084 ])?;
10085 store.set_purpose("customers/service", "Service layer", PurposeSource::Agent)?;
10086 store.set_purpose("settings/service", "Service layer", PurposeSource::Agent)?;
10087
10088 let page = store.unresolved_health_findings_page(
10089 &[],
10090 &HealthQuery {
10091 start_index: 0,
10092 limit: 10,
10093 category: Some("duplicate-purpose".to_string()),
10094 severity: Some(Severity::Warning),
10095 path_prefix: Some(".".to_string()),
10096 summary_only: false,
10097 scope: HealthScope::all(),
10098 },
10099 )?;
10100
10101 require_eq(&page.total, &0, "folder duplicates scoped by parent")?;
10102 Ok(())
10103 }
10104
10105 #[test]
10106 fn contextual_folder_duplicate_identity_matches_unpaged_health_and_resolution()
10107 -> Result<(), Box<dyn Error>> {
10108 let mut store = AtlasStore::in_memory()?;
10109 store.replace_scan(&[
10110 test_folder_node("."),
10111 test_folder_node("customers"),
10112 test_folder_node("customers/service"),
10113 test_folder_node("settings"),
10114 test_folder_node("settings/service"),
10115 test_folder_node("settings/worker"),
10116 ])?;
10117 store.set_purpose("customers/service", "Service layer", PurposeSource::Agent)?;
10118 store.set_purpose("settings/service", "Service layer", PurposeSource::Agent)?;
10119 store.set_purpose("settings/worker", "Service layer", PurposeSource::Agent)?;
10120
10121 let page = store.unresolved_health_findings_page(
10122 &[],
10123 &HealthQuery {
10124 start_index: 0,
10125 limit: 10,
10126 category: Some("duplicate-purpose".to_string()),
10127 severity: Some(Severity::Warning),
10128 path_prefix: Some(".".to_string()),
10129 summary_only: false,
10130 scope: HealthScope::all(),
10131 },
10132 )?;
10133 require_eq(&page.total, &1, "contextual duplicate total")?;
10134 let paged_finding = page
10135 .findings
10136 .first()
10137 .ok_or_else(|| io::Error::other("paged duplicate missing"))?;
10138 require_eq(
10139 &paged_finding.path,
10140 &"settings/worker".to_string(),
10141 "paged duplicate path",
10142 )?;
10143 require_eq(
10144 &paged_finding.related_path,
10145 &Some("settings/service".to_string()),
10146 "paged related path",
10147 )?;
10148
10149 let unpaged_duplicates = store
10150 .unresolved_health_findings(&[])?
10151 .into_iter()
10152 .filter(|finding| finding.category == "duplicate-purpose")
10153 .collect::<Vec<_>>();
10154 require_eq(
10155 &unpaged_duplicates,
10156 &page.findings,
10157 "unpaged duplicate identity",
10158 )?;
10159
10160 store.resolve_health_finding(&HealthResolution {
10161 finding_id: paged_finding.id.clone(),
10162 category: paged_finding.category.clone(),
10163 path: paged_finding.path.clone(),
10164 related_path: paged_finding.related_path.clone(),
10165 rationale: "Settings service and worker intentionally share a layer purpose."
10166 .to_string(),
10167 })?;
10168 let has_remaining_duplicate = store
10169 .unresolved_health_findings(&store.resolved_health_ids()?)?
10170 .into_iter()
10171 .any(|finding| finding.category == "duplicate-purpose");
10172 require_eq(
10173 &has_remaining_duplicate,
10174 &false,
10175 "resolved contextual duplicate",
10176 )?;
10177 Ok(())
10178 }
10179
10180 #[test]
10181 fn agent_review_required_scope_expands_from_low_to_strict() -> Result<(), Box<dyn Error>> {
10182 let mut store = AtlasStore::in_memory()?;
10183 let asset_file = Node {
10184 path: "assets/logo.svg".to_string(),
10185 kind: NodeKind::File,
10186 parent_path: Some("assets".to_string()),
10187 extension: Some(".svg".to_string()),
10188 language: None,
10189 size_bytes: Some(42),
10190 mtime_ns: Some(10),
10191 content_hash: Some("hash-logo".to_string()),
10192 };
10193 store.replace_scan(&[
10194 test_folder_node("."),
10195 test_folder_node("assets"),
10196 test_folder_node("src"),
10197 test_file_node("Cargo.toml", "hash-cargo"),
10198 test_file_node("src/detail.rs", "hash-detail"),
10199 asset_file,
10200 ])?;
10201 for (path, purpose) in [
10202 (".", "Imported repository root"),
10203 ("assets", "Imported asset folder"),
10204 ("src", "Imported Rust source folder"),
10205 ("Cargo.toml", "Imported Rust manifest"),
10206 ("src/detail.rs", "Imported implementation detail"),
10207 ("assets/logo.svg", "Imported SVG brand asset"),
10208 ] {
10209 store.set_purpose(path, purpose, PurposeSource::Imported)?;
10210 }
10211
10212 let low = store.unresolved_health_findings_page(
10213 &[],
10214 &HealthQuery {
10215 start_index: 0,
10216 limit: 20,
10217 category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
10218 severity: Some(Severity::Warning),
10219 path_prefix: Some(".".to_string()),
10220 summary_only: false,
10221 scope: HealthScope::purpose_default(),
10222 },
10223 )?;
10224 require_eq(
10225 &health_paths(&low),
10226 &vec![".", "assets", "src", "Cargo.toml"],
10227 "low purpose review scope",
10228 )?;
10229 require_eq(
10230 &low.unfiltered_total,
10231 &6,
10232 "agent-review findings are counted once in unfiltered total",
10233 )?;
10234
10235 let asset_scope = store.unresolved_health_findings_page(
10236 &[],
10237 &HealthQuery {
10238 scope: HealthScope::purpose_with_assets(),
10239 ..low_query()
10240 },
10241 )?;
10242 require_eq(
10243 &health_paths(&asset_scope),
10244 &vec![".", "assets", "src", "Cargo.toml", "assets/logo.svg"],
10245 "asset purpose review scope",
10246 )?;
10247
10248 let medium = store.unresolved_health_findings_page(
10249 &[],
10250 &HealthQuery {
10251 scope: HealthScope::purpose_with_source_files(),
10252 ..low_query()
10253 },
10254 )?;
10255 require_eq(
10256 &health_paths(&medium),
10257 &vec![".", "assets", "src", "Cargo.toml", "src/detail.rs"],
10258 "medium purpose review scope",
10259 )?;
10260
10261 let strict = store.unresolved_health_findings_page(
10262 &[],
10263 &HealthQuery {
10264 scope: HealthScope::purpose_strict(),
10265 ..low_query()
10266 },
10267 )?;
10268 require_eq(
10269 &health_paths(&strict),
10270 &vec![
10271 ".",
10272 "assets",
10273 "src",
10274 "Cargo.toml",
10275 "assets/logo.svg",
10276 "src/detail.rs",
10277 ],
10278 "strict purpose review scope",
10279 )?;
10280
10281 let all = store.unresolved_health_findings_page(
10282 &[],
10283 &HealthQuery {
10284 scope: HealthScope::all(),
10285 ..low_query()
10286 },
10287 )?;
10288 require_eq(
10289 &health_paths(&all),
10290 &health_paths(&strict),
10291 "all health scope should include every purpose review candidate",
10292 )?;
10293 Ok(())
10294 }
10295
10296 #[test]
10297 fn replace_scan_preserves_curated_purposes_and_reconciles_changed_paths()
10298 -> Result<(), Box<dyn Error>> {
10299 let mut store = AtlasStore::in_memory()?;
10300 store.replace_scan(&[
10301 test_folder_node("."),
10302 test_folder_node("src"),
10303 test_file_node("src/main.rs", "hash-a"),
10304 ])?;
10305 store.set_purpose(".", "Agent-reviewed repository root", PurposeSource::Agent)?;
10306 store.set_purpose(
10307 "src",
10308 "Agent-reviewed Rust source folder",
10309 PurposeSource::Agent,
10310 )?;
10311 store.set_purpose(
10312 "src/main.rs",
10313 "Agent-reviewed Rust entry point",
10314 PurposeSource::Agent,
10315 )?;
10316
10317 store.replace_scan(&[
10318 test_folder_node("."),
10319 test_folder_node("src"),
10320 test_file_node("src/main.rs", "hash-a"),
10321 test_file_node("src/new.rs", "hash-new"),
10322 ])?;
10323 let nodes = store.load_nodes_by_paths(&[
10324 ".".to_string(),
10325 "src".to_string(),
10326 "src/main.rs".to_string(),
10327 "src/new.rs".to_string(),
10328 ])?;
10329 let by_path = nodes
10330 .iter()
10331 .map(|node| (node.node.path.as_str(), node))
10332 .collect::<HashMap<_, _>>();
10333 require_eq(
10334 &by_path["src/main.rs"].purpose.purpose,
10335 &Some("Agent-reviewed Rust entry point".to_string()),
10336 "unchanged file purpose preserved",
10337 )?;
10338 require_eq(
10339 &by_path["src/main.rs"].purpose.status,
10340 &PurposeStatus::Approved,
10341 "unchanged file purpose stays approved",
10342 )?;
10343 require_eq(
10344 &by_path["src/new.rs"].purpose.status,
10345 &PurposeStatus::Missing,
10346 "new file starts missing",
10347 )?;
10348
10349 store.replace_scan(&[
10350 test_folder_node("."),
10351 test_folder_node("src"),
10352 test_file_node("src/main.rs", "hash-b"),
10353 test_file_node("src/new.rs", "hash-new"),
10354 ])?;
10355 let changed = store
10356 .load_nodes_by_paths(&["src/main.rs".to_string()])?
10357 .pop()
10358 .ok_or_else(|| io::Error::other("changed node missing"))?;
10359 require_eq(
10360 &changed.purpose.purpose,
10361 &Some("Agent-reviewed Rust entry point".to_string()),
10362 "changed file purpose text preserved",
10363 )?;
10364 require_eq(
10365 &changed.purpose.status,
10366 &PurposeStatus::Approved,
10367 "changed file purpose stays approved",
10368 )?;
10369
10370 store.replace_scan(&[test_folder_node("."), test_folder_node("src")])?;
10371 let removed = store.load_nodes_by_paths(&["src/main.rs".to_string()])?;
10372 require_eq(&removed.is_empty(), &true, "removed file is inactive")?;
10373
10374 let dormant = store.connection.query_row(
10375 "SELECT n.exists_now, p.purpose, p.status
10376 FROM nodes AS n
10377 JOIN purposes AS p ON p.node_id = n.id
10378 WHERE n.path = 'src/main.rs'",
10379 [],
10380 |row| {
10381 Ok((
10382 row.get::<_, i64>(0)?,
10383 row.get::<_, Option<String>>(1)?,
10384 row.get::<_, String>(2)?,
10385 ))
10386 },
10387 )?;
10388 require_eq(
10389 &dormant,
10390 &(
10391 0,
10392 Some("Agent-reviewed Rust entry point".to_string()),
10393 PurposeStatus::Approved.as_str().to_string(),
10394 ),
10395 "removed file keeps a dormant approved purpose",
10396 )?;
10397
10398 store.replace_scan(&[
10399 test_folder_node("."),
10400 test_folder_node("src"),
10401 test_file_node("src/renamed.rs", "hash-b"),
10402 ])?;
10403 let renamed = store
10404 .load_node_by_path("src/renamed.rs")?
10405 .ok_or_else(|| io::Error::other("renamed path missing"))?;
10406 require_eq(
10407 &renamed.purpose.status,
10408 &PurposeStatus::Missing,
10409 "rename does not transfer approval",
10410 )?;
10411
10412 store.replace_scan(&[
10413 test_folder_node("."),
10414 test_folder_node("src"),
10415 test_file_node("src/main.rs", "hash-c"),
10416 ])?;
10417 let reactivated = store
10418 .load_node_by_path("src/main.rs")?
10419 .ok_or_else(|| io::Error::other("reactivated path missing"))?;
10420 require_eq(
10421 &reactivated.purpose.purpose,
10422 &Some("Agent-reviewed Rust entry point".to_string()),
10423 "exact-path reactivation restores the dormant purpose",
10424 )?;
10425 require_eq(
10426 &reactivated.purpose.status,
10427 &PurposeStatus::Approved,
10428 "exact-path reactivation restores approval",
10429 )?;
10430 Ok(())
10431 }
10432
10433 #[test]
10434 fn file_token_estimates_are_visited_without_loading_nodes() -> Result<(), Box<dyn Error>> {
10435 let mut store = AtlasStore::in_memory()?;
10436 store.replace_scan(&[
10437 test_file_node("src/a.rs", "hash-a"),
10438 test_file_node("tests/b.rs", "hash-b"),
10439 ])?;
10440
10441 let mut visited = Vec::new();
10442 store.visit_file_token_estimates(Some("src"), |path, size_bytes| {
10443 visited.push((path, size_bytes));
10444 Ok(true)
10445 })?;
10446 require_eq(
10447 &visited,
10448 &vec![("src/a.rs".to_string(), Some(12))],
10449 "folder-scoped token estimate rows",
10450 )?;
10451 Ok(())
10452 }
10453
10454 #[test]
10455 fn indexed_file_text_replaces_and_clears_stale_rows() -> Result<(), Box<dyn Error>> {
10456 let mut store = AtlasStore::in_memory()?;
10457 store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
10458 store.replace_file_texts_for_paths(
10459 &["src/main.rs".to_string()],
10460 &[IndexedFileText {
10461 path: "src/main.rs".to_string(),
10462 content_hash: Some("hash-a".to_string()),
10463 byte_count: "needle old\n".len(),
10464 line_count: 1,
10465 content: "needle old\n".to_string(),
10466 }],
10467 )?;
10468 let texts = store.load_file_texts_for_search(Some("needle"), true)?;
10469 require_eq(&texts.len(), &1, "indexed text row count")?;
10470
10471 store.replace_file_texts_for_paths(&["src/main.rs".to_string()], &[])?;
10472 let missing = store.load_file_text("src/main.rs")?;
10473 require_eq(&missing.is_none(), &true, "cleared stale indexed text")?;
10474 Ok(())
10475 }
10476
10477 #[test]
10478 fn indexed_file_text_search_can_stop_without_collecting_all_rows() -> Result<(), Box<dyn Error>>
10479 {
10480 let mut store = AtlasStore::in_memory()?;
10481 store.replace_scan(&[
10482 test_file_node("src/a.rs", "hash-a"),
10483 test_file_node("src/b.rs", "hash-b"),
10484 ])?;
10485 store.replace_file_texts_for_paths(
10486 &["src/a.rs".to_string(), "src/b.rs".to_string()],
10487 &[
10488 IndexedFileText {
10489 path: "src/a.rs".to_string(),
10490 content_hash: Some("hash-a".to_string()),
10491 byte_count: "needle first\n".len(),
10492 line_count: 1,
10493 content: "needle first\n".to_string(),
10494 },
10495 IndexedFileText {
10496 path: "src/b.rs".to_string(),
10497 content_hash: Some("hash-b".to_string()),
10498 byte_count: "needle second\n".len(),
10499 line_count: 1,
10500 content: "needle second\n".to_string(),
10501 },
10502 ],
10503 )?;
10504
10505 let mut visited = Vec::new();
10506 store.visit_file_texts_for_search(Some("needle"), true, |text| {
10507 visited.push(text.path);
10508 Ok(false)
10509 })?;
10510 require_eq(&visited, &vec!["src/a.rs".to_string()], "early stop rows")?;
10511 Ok(())
10512 }
10513
10514 #[test]
10515 fn file_text_fts_candidates_are_bounded_scoped_safe_and_metadata_only()
10516 -> Result<(), Box<dyn Error>> {
10517 let mut store = AtlasStore::in_memory()?;
10518 store.replace_scan(&[
10519 test_file_node("src/a.rs", "hash-a"),
10520 test_file_node("src/b.rs", "hash-b"),
10521 test_file_node("tests/c.rs", "hash-c"),
10522 ])?;
10523 store.replace_file_texts_for_paths(
10524 &[
10525 "src/a.rs".to_string(),
10526 "src/b.rs".to_string(),
10527 "tests/c.rs".to_string(),
10528 ],
10529 &[
10530 IndexedFileText {
10531 path: "src/a.rs".to_string(),
10532 content_hash: Some("hash-a".to_string()),
10533 byte_count: 13,
10534 line_count: 1,
10535 content: "needle alpha\n".to_string(),
10536 },
10537 IndexedFileText {
10538 path: "src/b.rs".to_string(),
10539 content_hash: Some("hash-b".to_string()),
10540 byte_count: 19,
10541 line_count: 1,
10542 content: "prefixneedlesuffix\n".to_string(),
10543 },
10544 IndexedFileText {
10545 path: "tests/c.rs".to_string(),
10546 content_hash: Some("hash-c".to_string()),
10547 byte_count: 32,
10548 line_count: 1,
10549 content: "needle gamma AND NOT NEAR terms\n".to_string(),
10550 },
10551 ],
10552 )?;
10553
10554 let bounded = store.query_file_text_fts_candidates(
10555 &FileTextFtsQuery {
10556 literal_token: "needle",
10557 path_prefix: None,
10558 limit: 1,
10559 },
10560 None,
10561 )?;
10562 require_eq(&bounded.candidates.len(), &1, "bounded FTS row count")?;
10563 require_eq(&bounded.overflow, &true, "bounded FTS overflow")?;
10564 require(
10565 bounded.candidates[0].bm25.is_finite(),
10566 "FTS rank was non-finite",
10567 )?;
10568 let zero_limit = store.query_file_text_fts_candidates(
10569 &FileTextFtsQuery {
10570 literal_token: "needle",
10571 path_prefix: None,
10572 limit: 0,
10573 },
10574 None,
10575 )?;
10576 require_eq(
10577 &zero_limit,
10578 &FileTextFtsPage {
10579 candidates: Vec::new(),
10580 overflow: true,
10581 },
10582 "zero-limit FTS overflow",
10583 )?;
10584
10585 let scoped = store.query_file_text_fts_candidates(
10586 &FileTextFtsQuery {
10587 literal_token: "needle",
10588 path_prefix: Some("src/"),
10589 limit: 10,
10590 },
10591 None,
10592 )?;
10593 require_eq(
10594 &scoped
10595 .candidates
10596 .iter()
10597 .map(|candidate| candidate.path.as_str())
10598 .collect::<Vec<_>>(),
10599 &vec!["src/a.rs", "src/b.rs"],
10600 "path-scoped FTS rank order",
10601 )?;
10602 let exact_path = store.query_file_text_fts_candidates(
10603 &FileTextFtsQuery {
10604 literal_token: "needle",
10605 path_prefix: Some("src/a.rs"),
10606 limit: 10,
10607 },
10608 None,
10609 )?;
10610 require_eq(
10611 &exact_path.candidates[0].path,
10612 &"src/a.rs".to_string(),
10613 "exact-path FTS scope",
10614 )?;
10615 for reserved_token in ["AND", "NOT", "NEAR"] {
10616 let reserved = store.query_file_text_fts_candidates(
10617 &FileTextFtsQuery {
10618 literal_token: reserved_token,
10619 path_prefix: Some("tests/c.rs"),
10620 limit: 10,
10621 },
10622 None,
10623 )?;
10624 require_eq(
10625 &reserved.candidates.len(),
10626 &1,
10627 "quoted FTS reserved-token candidate",
10628 )?;
10629 }
10630 let mut plan_statement = store.connection.prepare(
10631 "EXPLAIN QUERY PLAN
10632 SELECT f.path, f.content_hash, f.byte_count, f.line_count, bm25(file_text_fts)
10633 FROM file_text_fts
10634 JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
10635 WHERE file_text_fts MATCH ?1
10636 ORDER BY bm25(file_text_fts), f.path
10637 LIMIT ?2",
10638 )?;
10639 let plan_details = plan_statement
10640 .query_map(params!["needle", 11], |row| row.get::<_, String>(3))?
10641 .collect::<Result<Vec<_>, _>>()?;
10642 require(
10643 plan_details
10644 .iter()
10645 .any(|detail| detail.contains("VIRTUAL TABLE INDEX")),
10646 "FTS candidate query did not use the virtual-table index",
10647 )?;
10648 require(
10649 plan_details
10650 .iter()
10651 .any(|detail| detail.contains("INTEGER PRIMARY KEY")),
10652 "FTS candidate query did not hydrate metadata by file_texts rowid",
10653 )?;
10654
10655 for unsafe_token in ["ab", "foo_bar", "needle\" OR content:*", "néedle"] {
10656 let result = store.query_file_text_fts_candidates(
10657 &FileTextFtsQuery {
10658 literal_token: unsafe_token,
10659 path_prefix: None,
10660 limit: 10,
10661 },
10662 None,
10663 );
10664 require(
10665 matches!(result, Err(DbError::FileTextFtsTokenUnsafe { .. })),
10666 "unsafe FTS token was accepted",
10667 )?;
10668 }
10669 let over_cap = store.query_file_text_fts_candidates(
10670 &FileTextFtsQuery {
10671 literal_token: "needle",
10672 path_prefix: None,
10673 limit: MAX_FILE_TEXT_FTS_CANDIDATES + 1,
10674 },
10675 None,
10676 );
10677 require(
10678 matches!(over_cap, Err(DbError::FileTextFtsCandidateLimit { .. })),
10679 "over-cap FTS request was accepted",
10680 )?;
10681
10682 store.connection.execute(
10683 "UPDATE file_texts SET content = x'80' WHERE path = 'src/a.rs'",
10684 [],
10685 )?;
10686 let metadata_only = store.query_file_text_fts_candidates(
10687 &FileTextFtsQuery {
10688 literal_token: "needle",
10689 path_prefix: Some("src/a.rs"),
10690 limit: 1,
10691 },
10692 None,
10693 )?;
10694 require_eq(
10695 &metadata_only.candidates[0].byte_count,
10696 &13,
10697 "metadata-only candidate byte count",
10698 )?;
10699 require(
10700 store.load_file_text("src/a.rs").is_err(),
10701 "content corruption fixture did not prove candidate pre-hydration",
10702 )?;
10703 Ok(())
10704 }
10705
10706 #[test]
10707 fn file_text_fts_mutations_are_atomic_and_state_reports_identity_drift()
10708 -> Result<(), Box<dyn Error>> {
10709 let mut store = AtlasStore::in_memory()?;
10710 store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
10711 let invalid_metadata = IndexedFileText {
10712 path: "src/main.rs".to_string(),
10713 content_hash: Some("hash-invalid".to_string()),
10714 byte_count: 0,
10715 line_count: 1,
10716 content: "not empty\n".to_string(),
10717 };
10718 require(
10719 matches!(
10720 store.replace_file_texts_for_paths(
10721 std::slice::from_ref(&invalid_metadata.path),
10722 std::slice::from_ref(&invalid_metadata),
10723 ),
10724 Err(DbError::FileTextMetadataMismatch {
10725 field: "byte_count",
10726 ..
10727 })
10728 ),
10729 "invalid file-text byte metadata was accepted",
10730 )?;
10731 require_eq(
10732 &store.load_file_text(&invalid_metadata.path)?,
10733 &None,
10734 "invalid metadata write rollback",
10735 )?;
10736 let invalid_line_count = IndexedFileText {
10737 byte_count: invalid_metadata.content.len(),
10738 line_count: 0,
10739 ..invalid_metadata.clone()
10740 };
10741 require(
10742 matches!(
10743 store.replace_file_texts_for_paths(
10744 std::slice::from_ref(&invalid_line_count.path),
10745 std::slice::from_ref(&invalid_line_count),
10746 ),
10747 Err(DbError::FileTextMetadataMismatch {
10748 field: "line_count",
10749 ..
10750 })
10751 ),
10752 "invalid file-text line metadata was accepted",
10753 )?;
10754 let original = IndexedFileText {
10755 path: "src/main.rs".to_string(),
10756 content_hash: Some("hash-a".to_string()),
10757 byte_count: "needle old\n".len(),
10758 line_count: 1,
10759 content: "needle old\n".to_string(),
10760 };
10761 store.replace_file_texts_for_paths(
10762 std::slice::from_ref(&original.path),
10763 std::slice::from_ref(&original),
10764 )?;
10765 require_eq(
10766 &store.file_text_fts_state()?,
10767 &FileTextFtsState {
10768 source_rows: 1,
10769 indexed_rows: 1,
10770 synchronized: true,
10771 },
10772 "initial FTS synchronization state",
10773 )?;
10774
10775 store.connection.execute_batch(
10776 "CREATE TRIGGER fail_file_text_insert
10777 BEFORE INSERT ON file_texts
10778 BEGIN
10779 SELECT RAISE(ABORT, 'injected file-text failure');
10780 END;",
10781 )?;
10782 let replacement = IndexedFileText {
10783 path: original.path.clone(),
10784 content_hash: Some("hash-b".to_string()),
10785 byte_count: 11,
10786 line_count: 1,
10787 content: "beacon new\n".to_string(),
10788 };
10789 require(
10790 store
10791 .replace_file_texts_for_paths(
10792 std::slice::from_ref(&replacement.path),
10793 std::slice::from_ref(&replacement),
10794 )
10795 .is_err(),
10796 "fault-injected replacement unexpectedly committed",
10797 )?;
10798 store
10799 .connection
10800 .execute_batch("DROP TRIGGER fail_file_text_insert")?;
10801 require_eq(
10802 &store.load_file_text(&original.path)?,
10803 &Some(original.clone()),
10804 "rolled-back authoritative text",
10805 )?;
10806 require_eq(
10807 &store
10808 .query_file_text_fts_candidates(
10809 &FileTextFtsQuery {
10810 literal_token: "needle",
10811 path_prefix: None,
10812 limit: 10,
10813 },
10814 None,
10815 )?
10816 .candidates
10817 .len(),
10818 &1,
10819 "rolled-back FTS document",
10820 )?;
10821 require_eq(
10822 &store.file_text_fts_state()?.synchronized,
10823 &true,
10824 "rollback FTS synchronization",
10825 )?;
10826
10827 store.replace_file_texts_for_paths(
10828 std::slice::from_ref(&replacement.path),
10829 std::slice::from_ref(&replacement),
10830 )?;
10831 require_eq(
10832 &store
10833 .query_file_text_fts_candidates(
10834 &FileTextFtsQuery {
10835 literal_token: "needle",
10836 path_prefix: None,
10837 limit: 10,
10838 },
10839 None,
10840 )?
10841 .candidates
10842 .len(),
10843 &0,
10844 "replaced token removed from FTS",
10845 )?;
10846 require_eq(
10847 &store
10848 .query_file_text_fts_candidates(
10849 &FileTextFtsQuery {
10850 literal_token: "beacon",
10851 path_prefix: None,
10852 limit: 10,
10853 },
10854 None,
10855 )?
10856 .candidates
10857 .len(),
10858 &1,
10859 "replacement token added to FTS",
10860 )?;
10861
10862 store.connection.execute(
10863 "INSERT INTO file_text_fts(file_text_fts, rowid, content)
10864 SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
10865 [&replacement.path],
10866 )?;
10867 require_eq(
10868 &store.file_text_fts_state()?,
10869 &FileTextFtsState {
10870 source_rows: 1,
10871 indexed_rows: 0,
10872 synchronized: false,
10873 },
10874 "desynchronized FTS identity state",
10875 )?;
10876 store.connection.execute(
10877 "INSERT INTO file_text_fts(file_text_fts) VALUES('rebuild')",
10878 [],
10879 )?;
10880 let (source_revision, projection_revision) =
10881 load_file_text_fts_revisions(&store.connection)?.ok_or_else(|| {
10882 io::Error::other("FTS revisions disappeared after synchronized writes")
10883 })?;
10884 require_eq(
10885 &source_revision,
10886 &projection_revision,
10887 "synchronized FTS revisions",
10888 )?;
10889 set_metadata(
10890 &store.connection,
10891 FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
10892 &projection_revision.saturating_sub(1).to_string(),
10893 )?;
10894 require_eq(
10895 &store.file_text_fts_ready()?,
10896 &false,
10897 "incomplete FTS revision readiness",
10898 )?;
10899 set_metadata(
10900 &store.connection,
10901 FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
10902 &source_revision.to_string(),
10903 )?;
10904 require_eq(
10905 &store.file_text_fts_ready()?,
10906 &true,
10907 "restored FTS revision readiness",
10908 )?;
10909 store.mark_paths_absent(&["src".to_string()])?;
10910 require_eq(
10911 &store.file_text_fts_state()?,
10912 &FileTextFtsState {
10913 source_rows: 0,
10914 indexed_rows: 0,
10915 synchronized: true,
10916 },
10917 "absent-path FTS synchronization",
10918 )?;
10919
10920 store.replace_scan(&[test_file_node("src/main.rs", "hash-c")])?;
10921 let rescanned = IndexedFileText {
10922 path: "src/main.rs".to_string(),
10923 content_hash: Some("hash-c".to_string()),
10924 byte_count: 13,
10925 line_count: 1,
10926 content: "rescan token\n".to_string(),
10927 };
10928 store.replace_file_texts_for_paths(
10929 std::slice::from_ref(&rescanned.path),
10930 std::slice::from_ref(&rescanned),
10931 )?;
10932 store.replace_scan(&[])?;
10933 require_eq(
10934 &store.file_text_fts_state()?,
10935 &FileTextFtsState {
10936 source_rows: 0,
10937 indexed_rows: 0,
10938 synchronized: true,
10939 },
10940 "full-scan deletion FTS synchronization",
10941 )?;
10942 Ok(())
10943 }
10944
10945 #[test]
10946 fn file_text_fts_migration_backfills_existing_text_and_survives_reopen()
10947 -> Result<(), Box<dyn Error>> {
10948 let temp = tempfile::tempdir()?;
10949 let root = temp.path().join("project");
10950 fs::create_dir(&root)?;
10951 let database = temp.path().join("atlas.db");
10952 let mut store = AtlasStore::open_for_project(&database, &root)?;
10953 store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
10954 store.replace_file_texts_for_paths(
10955 &["src/main.rs".to_string()],
10956 &[IndexedFileText {
10957 path: "src/main.rs".to_string(),
10958 content_hash: Some("hash-a".to_string()),
10959 byte_count: 17,
10960 line_count: 1,
10961 content: "migration needle\n".to_string(),
10962 }],
10963 )?;
10964 store.connection.execute_batch("DROP TABLE file_text_fts")?;
10965 store.connection.execute(
10966 "DELETE FROM metadata WHERE key IN (?1, ?2)",
10967 params![
10968 FILE_TEXT_FTS_SOURCE_REVISION_KEY,
10969 FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
10970 ],
10971 )?;
10972 set_metadata(
10973 &store.connection,
10974 SCHEMA_VERSION_KEY,
10975 &crate::schema::COVERAGE_DISCOVERY_SCHEMA_VERSION.to_string(),
10976 )?;
10977 drop(store);
10978
10979 let migrated = AtlasStore::open_for_project(&database, &root)?;
10980 require_eq(
10981 &migrated.file_text_fts_state()?,
10982 &FileTextFtsState {
10983 source_rows: 1,
10984 indexed_rows: 1,
10985 synchronized: true,
10986 },
10987 "migrated FTS synchronization state",
10988 )?;
10989 require_eq(
10990 &migrated
10991 .query_file_text_fts_candidates(
10992 &FileTextFtsQuery {
10993 literal_token: "needle",
10994 path_prefix: None,
10995 limit: 10,
10996 },
10997 None,
10998 )?
10999 .candidates[0]
11000 .path,
11001 &"src/main.rs".to_string(),
11002 "migrated FTS backfill candidate",
11003 )?;
11004 drop(migrated);
11005
11006 let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
11007 require_eq(
11008 &reopened.file_text_fts_state()?.synchronized,
11009 &true,
11010 "reopened FTS synchronization",
11011 )?;
11012 require_eq(
11013 &reopened
11014 .query_file_text_fts_candidates(
11015 &FileTextFtsQuery {
11016 literal_token: "migration",
11017 path_prefix: Some("src"),
11018 limit: 1,
11019 },
11020 None,
11021 )?
11022 .candidates
11023 .len(),
11024 &1,
11025 "reopened FTS candidate count",
11026 )?;
11027 Ok(())
11028 }
11029
11030 #[test]
11031 fn sqlite_read_progress_interrupts_active_repository_traversal_and_clears_handler()
11032 -> Result<(), Box<dyn Error>> {
11033 let store = AtlasStore::in_memory()?;
11034 let control = IndexWorkControl::with_deadline(
11035 projectatlas_core::IndexCancellation::new(),
11036 Instant::now() + std::time::Duration::from_millis(50),
11037 );
11038 let interrupted = with_sqlite_read_progress(
11039 &store.connection,
11040 Some(&control),
11041 IndexWorkStage::RepositoryTraversal,
11042 || {
11043 store
11044 .connection
11045 .query_row(
11046 "WITH RECURSIVE numbers(value) AS (
11047 VALUES(1)
11048 UNION ALL
11049 SELECT value + 1 FROM numbers WHERE value < 100000000
11050 )
11051 SELECT SUM(value) FROM numbers",
11052 [],
11053 |row| row.get::<_, i64>(0),
11054 )
11055 .map_err(DbError::from)
11056 },
11057 );
11058 require(
11059 matches!(
11060 interrupted,
11061 Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
11062 stage: IndexWorkStage::RepositoryTraversal
11063 }))
11064 ),
11065 "active SQLite traversal was not interrupted with its typed deadline",
11066 )?;
11067 require_eq(
11068 &store
11069 .connection
11070 .query_row("SELECT 1", [], |row| row.get::<_, i64>(0))?,
11071 &1,
11072 "cleared repository traversal progress handler",
11073 )?;
11074 store
11075 .connection
11076 .execute("CREATE TEMP TABLE follow_up(value INTEGER NOT NULL)", [])?;
11077 store
11078 .connection
11079 .execute("INSERT INTO follow_up(value) VALUES(1)", [])?;
11080 Ok(())
11081 }
11082
11083 #[cfg(feature = "sqlite-progress-test-observer")]
11084 #[test]
11085 fn sqlite_read_progress_propagates_cancellation_during_an_active_batch()
11086 -> Result<(), Box<dyn Error>> {
11087 use crate::sqlite_progress_test_observer::{
11088 SqliteReadProgressEvent, observe_sqlite_read_progress,
11089 };
11090 use std::cell::Cell;
11091 use std::rc::Rc;
11092
11093 let store = AtlasStore::in_memory()?;
11094 let cancellation = projectatlas_core::IndexCancellation::new();
11095 let control = IndexWorkControl::new(cancellation.clone(), None);
11096 let callback_seen = Rc::new(Cell::new(false));
11097 let cancelled = observe_sqlite_read_progress(
11098 {
11099 let callback_seen = Rc::clone(&callback_seen);
11100 move |event| {
11101 if matches!(
11102 event,
11103 SqliteReadProgressEvent::CallbackEntered {
11104 stage: IndexWorkStage::RepositoryTraversal
11105 }
11106 ) {
11107 callback_seen.set(true);
11108 cancellation.cancel();
11109 }
11110 }
11111 },
11112 || {
11113 with_sqlite_read_progress(
11114 &store.connection,
11115 Some(&control),
11116 IndexWorkStage::RepositoryTraversal,
11117 || {
11118 store
11119 .connection
11120 .query_row(
11121 "WITH RECURSIVE numbers(value) AS (
11122 VALUES(1)
11123 UNION ALL
11124 SELECT value + 1 FROM numbers WHERE value < 100000000
11125 )
11126 SELECT SUM(value) FROM numbers",
11127 [],
11128 |row| row.get::<_, i64>(0),
11129 )
11130 .map_err(DbError::from)
11131 },
11132 )
11133 },
11134 );
11135 require(
11136 callback_seen.get()
11137 && matches!(
11138 cancelled,
11139 Err(DbError::IndexWork(IndexWorkFailure::Cancelled {
11140 stage: IndexWorkStage::RepositoryTraversal
11141 }))
11142 ),
11143 "active SQLite batch did not propagate typed cancellation",
11144 )?;
11145 store.connection.execute(
11146 "CREATE TEMP TABLE cancellation_follow_up(value INTEGER)",
11147 [],
11148 )?;
11149 Ok(())
11150 }
11151
11152 #[test]
11153 fn fallback_admission_precedes_content_decode_and_clears_progress_handlers()
11154 -> Result<(), Box<dyn Error>> {
11155 let mut store = AtlasStore::in_memory()?;
11156 store.replace_scan(&[test_file_node("src/good.rs", "hash-good")])?;
11157 store.replace_file_texts_for_paths(
11158 &["src/good.rs".to_string()],
11159 &[IndexedFileText {
11160 path: "src/good.rs".to_string(),
11161 content_hash: Some("hash-good".to_string()),
11162 byte_count: 12,
11163 line_count: 1,
11164 content: "needle good\n".to_string(),
11165 }],
11166 )?;
11167 store.connection.execute(
11168 "INSERT INTO file_texts(path, content_hash, byte_count, line_count, content)
11169 VALUES('src/bad.rs', 'hash-bad', 1, 1, x'80')",
11170 [],
11171 )?;
11172
11173 let mut admitted = Vec::new();
11174 let mut visited = Vec::new();
11175 store.visit_file_texts_for_fallback(
11176 Some("src"),
11177 None,
11178 |metadata| {
11179 admitted.push(metadata.path.clone());
11180 Ok(if metadata.path == "src/bad.rs" {
11181 FileTextAdmission::Skip
11182 } else {
11183 FileTextAdmission::Read
11184 })
11185 },
11186 |text| {
11187 visited.push(text.path);
11188 Ok(true)
11189 },
11190 )?;
11191 require_eq(
11192 &admitted,
11193 &vec!["src/bad.rs".to_string(), "src/good.rs".to_string()],
11194 "fallback metadata admission rows",
11195 )?;
11196 require_eq(
11197 &visited,
11198 &vec!["src/good.rs".to_string()],
11199 "fallback decoded rows",
11200 )?;
11201 let mut scoped_plan = store.connection.prepare(&format!(
11202 "EXPLAIN QUERY PLAN {FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL}"
11203 ))?;
11204 let scoped_plan_details = scoped_plan
11205 .query_map(params!["src", "src/", "src0"], |row| {
11206 row.get::<_, String>(3)
11207 })?
11208 .collect::<Result<Vec<_>, _>>()?;
11209 require(
11210 scoped_plan_details
11211 .iter()
11212 .any(|detail| detail.contains("SEARCH file_texts USING INDEX"))
11213 && scoped_plan_details
11214 .iter()
11215 .all(|detail| !detail.contains("SCAN file_texts")),
11216 "path-scoped fallback did not use bounded binary index ranges",
11217 )?;
11218 let mut scoped_opcodes = store
11219 .connection
11220 .prepare(&format!("EXPLAIN {FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL}"))?;
11221 let scoped_opcode_rows = scoped_opcodes
11222 .query_map(params!["src", "src/", "src0"], |row| {
11223 Ok((row.get::<_, String>(1)?, row.get::<_, i64>(3)?))
11224 })?
11225 .collect::<Result<Vec<_>, _>>()?;
11226 require(
11227 scoped_opcode_rows
11228 .iter()
11229 .all(|(opcode, column)| opcode != "Column" || *column != 4),
11230 "fallback metadata cursor read source content before admission",
11231 )?;
11232 require(
11233 store
11234 .visit_file_texts_for_fallback(
11235 Some("src/bad.rs"),
11236 None,
11237 |_| Ok(FileTextAdmission::Read),
11238 |_| Ok(true),
11239 )
11240 .is_err(),
11241 "invalid source content decoded without error",
11242 )?;
11243
11244 let success_cancellation = projectatlas_core::IndexCancellation::new();
11245 let success_control = IndexWorkControl::new(success_cancellation.clone(), None);
11246 store.query_file_text_fts_candidates(
11247 &FileTextFtsQuery {
11248 literal_token: "needle",
11249 path_prefix: None,
11250 limit: 10,
11251 },
11252 Some(&success_control),
11253 )?;
11254 success_cancellation.cancel();
11255 let recursive_sum = store.connection.query_row(
11256 "WITH RECURSIVE numbers(value) AS (
11257 VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 5000
11258 ) SELECT SUM(value) FROM numbers",
11259 [],
11260 |row| row.get::<_, i64>(0),
11261 )?;
11262 require_eq(
11263 &recursive_sum,
11264 &12_502_500,
11265 "cleared success progress handler",
11266 )?;
11267
11268 let cancellation = projectatlas_core::IndexCancellation::new();
11269 let control = IndexWorkControl::new(cancellation.clone(), None);
11270 let mut rows_seen = 0;
11271 let cancelled = store.visit_file_texts_for_fallback(
11272 Some("src"),
11273 Some(&control),
11274 |_| {
11275 rows_seen += 1;
11276 cancellation.cancel();
11277 Ok(FileTextAdmission::Skip)
11278 },
11279 |_| Ok(true),
11280 );
11281 require_eq(&rows_seen, &1, "rows before fallback cancellation")?;
11282 require(
11283 matches!(
11284 cancelled,
11285 Err(DbError::IndexWork(IndexWorkFailure::Cancelled {
11286 stage: IndexWorkStage::TextIndex
11287 }))
11288 ),
11289 "fallback cancellation was not typed",
11290 )?;
11291 let post_error_sum = store.connection.query_row(
11292 "WITH RECURSIVE numbers(value) AS (
11293 VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 5000
11294 ) SELECT SUM(value) FROM numbers",
11295 [],
11296 |row| row.get::<_, i64>(0),
11297 )?;
11298 require_eq(
11299 &post_error_sum,
11300 &12_502_500,
11301 "cleared error progress handler",
11302 )?;
11303
11304 let expired = IndexWorkControl::with_deadline(
11305 projectatlas_core::IndexCancellation::new(),
11306 Instant::now(),
11307 );
11308 let deadline = store.query_file_text_fts_candidates(
11309 &FileTextFtsQuery {
11310 literal_token: "needle",
11311 path_prefix: None,
11312 limit: 10,
11313 },
11314 Some(&expired),
11315 );
11316 require(
11317 matches!(
11318 deadline,
11319 Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
11320 stage: IndexWorkStage::TextIndex
11321 }))
11322 ),
11323 "FTS deadline was not typed",
11324 )?;
11325 Ok(())
11326 }
11327
11328 #[test]
11329 fn suggested_purpose_is_not_approved() -> Result<(), Box<dyn Error>> {
11330 let mut store = AtlasStore::in_memory()?;
11331 let node = Node {
11332 path: "src/main.rs".to_string(),
11333 kind: NodeKind::File,
11334 parent_path: normalized_parent("src/main.rs"),
11335 extension: Some(".rs".to_string()),
11336 language: Some("rust".to_string()),
11337 size_bytes: Some(12),
11338 mtime_ns: Some(10),
11339 content_hash: Some("abc".to_string()),
11340 };
11341 store.replace_scan(&[node])?;
11342 store.set_suggested_purpose("src/main.rs", "Maybe application entry point")?;
11343 let nodes = store.load_nodes()?;
11344 require_eq(
11345 &nodes[0].purpose.source,
11346 &PurposeSource::Generated,
11347 "suggested source",
11348 )?;
11349 require_eq(
11350 &nodes[0].purpose.status,
11351 &PurposeStatus::Suggested,
11352 "suggested status",
11353 )?;
11354 store.set_purpose(
11355 "src/main.rs",
11356 "Application entry point",
11357 PurposeSource::Agent,
11358 )?;
11359 let nodes = store.load_nodes()?;
11360 require_eq(
11361 &nodes[0].purpose.status,
11362 &PurposeStatus::Approved,
11363 "agent-approved status",
11364 )?;
11365 store.set_suggested_purpose("src/main.rs", "Late generated suggestion")?;
11366 let nodes = store.load_nodes()?;
11367 require_eq(
11368 &nodes[0].purpose.purpose,
11369 &Some("Application entry point".to_string()),
11370 "approved purpose survives a late suggestion",
11371 )?;
11372 require_eq(
11373 &nodes[0].purpose.source,
11374 &PurposeSource::Agent,
11375 "approved purpose source survives a late suggestion",
11376 )?;
11377 require_eq(
11378 &nodes[0].purpose.status,
11379 &PurposeStatus::Approved,
11380 "approved purpose status survives a late suggestion",
11381 )?;
11382
11383 store.connection.execute(
11384 "
11385 UPDATE purposes
11386 SET status = ?1
11387 WHERE node_id = (SELECT id FROM nodes WHERE path = ?2)
11388 ",
11389 params![PurposeStatus::Stale.as_str(), "src/main.rs"],
11390 )?;
11391 store.set_suggested_purpose("src/main.rs", "Later generated suggestion")?;
11392 let nodes = store.load_nodes()?;
11393 require_eq(
11394 &nodes[0].purpose.purpose,
11395 &Some("Application entry point".to_string()),
11396 "stale reviewed purpose survives a late suggestion",
11397 )?;
11398 require_eq(
11399 &nodes[0].purpose.source,
11400 &PurposeSource::Agent,
11401 "stale reviewed source survives a late suggestion",
11402 )?;
11403 require_eq(
11404 &nodes[0].purpose.status,
11405 &PurposeStatus::Stale,
11406 "stale reviewed status survives a late suggestion",
11407 )?;
11408 Ok(())
11409 }
11410
11411 #[test]
11412 fn agent_reviewed_marker_depends_on_agent_approved_source() -> Result<(), Box<dyn Error>> {
11413 let mut store = AtlasStore::in_memory()?;
11414 store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
11415
11416 store.set_suggested_purpose("src/main.rs", "Maybe application entry point")?;
11417 let nodes = store.load_nodes()?;
11418 require_eq(
11419 &nodes[0].purpose.agent_reviewed(),
11420 &false,
11421 "generated suggestion is not agent reviewed",
11422 )?;
11423
11424 store.set_purpose(
11425 "src/main.rs",
11426 "Imported application entry point",
11427 PurposeSource::Imported,
11428 )?;
11429 let nodes = store.load_nodes()?;
11430 require_eq(
11431 &nodes[0].purpose.agent_reviewed(),
11432 &false,
11433 "imported purpose is not agent reviewed",
11434 )?;
11435
11436 store.set_purpose(
11437 "src/main.rs",
11438 "Agent-reviewed application entry point",
11439 PurposeSource::Agent,
11440 )?;
11441 let nodes = store.load_nodes()?;
11442 require_eq(
11443 &nodes[0].purpose.agent_reviewed(),
11444 &true,
11445 "agent-approved purpose is agent reviewed",
11446 )?;
11447
11448 store.connection.execute(
11449 "
11450 UPDATE purposes
11451 SET source = 'human'
11452 WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/main.rs')
11453 ",
11454 [],
11455 )?;
11456 let nodes = store.load_nodes()?;
11457 require_eq(
11458 &nodes[0].purpose.source,
11459 &PurposeSource::Agent,
11460 "legacy human source normalizes to agent",
11461 )?;
11462 require_eq(
11463 &nodes[0].purpose.agent_reviewed(),
11464 &true,
11465 "legacy approved human row remains reviewed",
11466 )?;
11467
11468 store.replace_scan(&[test_file_node("src/main.rs", "hash-b")])?;
11469 let nodes = store.load_nodes()?;
11470 require_eq(
11471 &nodes[0].purpose.status,
11472 &PurposeStatus::Approved,
11473 "changed reviewed purpose stays approved",
11474 )?;
11475 require_eq(
11476 &nodes[0].purpose.agent_reviewed(),
11477 &true,
11478 "changed approved purpose remains agent reviewed",
11479 )?;
11480 Ok(())
11481 }
11482
11483 #[test]
11484 fn purpose_curation_batch_coalesces_current_unapproved_rows() -> Result<(), Box<dyn Error>> {
11485 let temp = tempfile::tempdir()?;
11486 let root = temp.path().join("project");
11487 fs::create_dir(&root)?;
11488 let database = temp.path().join("projectatlas.db");
11489 let mut store = AtlasStore::open_for_project(&database, &root)?;
11490 store.replace_scan(&[
11491 test_file_node("src/a.rs", "hash-a"),
11492 test_file_node("src/b.rs", "hash-b"),
11493 test_file_node("src/accepted.rs", "hash-accepted"),
11494 ])?;
11495 store.set_suggested_purpose("src/b.rs", "Generated B suggestion")?;
11496 store.set_purpose("src/accepted.rs", "Accepted purpose", PurposeSource::Agent)?;
11497
11498 let selected = vec![
11499 "src/b.rs".to_string(),
11500 "src/a.rs".to_string(),
11501 "src/a.rs".to_string(),
11502 "src/accepted.rs".to_string(),
11503 ];
11504 let first = store.load_purpose_curation_batch(" issue 308 ", &selected)?;
11505 let second = store.load_purpose_curation_batch(
11506 "issue 308",
11507 &selected.iter().rev().cloned().collect::<Vec<_>>(),
11508 )?;
11509 require_eq(&first.task, &"issue 308".to_string(), "normalized task")?;
11510 require_eq(&first.work_key, &second.work_key, "deterministic batch key")?;
11511 require_eq(&first.items, &second.items, "deterministic candidate rows")?;
11512 require_eq(&first.items.len(), &2, "coalesced unapproved row count")?;
11513 require_eq(
11514 &first
11515 .items
11516 .iter()
11517 .map(|item| item.node.node.path.as_str())
11518 .collect::<Vec<_>>(),
11519 &vec!["src/a.rs", "src/b.rs"],
11520 "sorted actionable paths",
11521 )?;
11522 require_eq(
11523 &first
11524 .items
11525 .iter()
11526 .all(|item| item.work_key.len() == 64 && item.state_token.len() == 64),
11527 &true,
11528 "bounded opaque item identities",
11529 )?;
11530
11531 let page = store.purpose_curation_findings_page_current(&HealthQuery {
11532 start_index: 0,
11533 limit: 20,
11534 category: None,
11535 severity: Some(Severity::Warning),
11536 path_prefix: None,
11537 summary_only: false,
11538 scope: HealthScope::all(),
11539 })?;
11540 require_eq(&page.total, &2, "actionable queue count")?;
11541 require_eq(
11542 &page
11543 .findings
11544 .iter()
11545 .any(|finding| finding.path == "src/accepted.rs"),
11546 &false,
11547 "accepted purpose omitted from automatic curation",
11548 )?;
11549
11550 store.set_purpose(
11551 "src/accepted.rs",
11552 "Deliberately corrected purpose",
11553 PurposeSource::Agent,
11554 )?;
11555 let corrected = store
11556 .load_node_by_path("src/accepted.rs")?
11557 .ok_or_else(|| io::Error::other("corrected purpose path disappeared"))?;
11558 require_eq(
11559 &corrected.purpose.purpose,
11560 &Some("Deliberately corrected purpose".to_string()),
11561 "explicit accepted-purpose correction",
11562 )?;
11563 Ok(())
11564 }
11565
11566 #[test]
11567 fn conditional_purpose_batch_is_atomic_and_rejects_changed_work() -> Result<(), Box<dyn Error>>
11568 {
11569 let temp = tempfile::tempdir()?;
11570 let root = temp.path().join("project");
11571 fs::create_dir(&root)?;
11572 let database = temp.path().join("projectatlas.db");
11573 let mut store = AtlasStore::open_for_project(&database, &root)?;
11574 let nodes = vec![
11575 test_file_node("src/a.rs", "hash-a"),
11576 test_file_node("src/b.rs", "hash-b"),
11577 test_file_node("src/changed.rs", "hash-changed"),
11578 test_file_node("src/accepted.rs", "hash-accepted"),
11579 test_file_node("src/generation.rs", "hash-generation"),
11580 test_file_node("src/rollback-a.rs", "hash-rollback-a"),
11581 test_file_node("src/rollback-b.rs", "hash-rollback-b"),
11582 ];
11583 store.replace_scan(&nodes)?;
11584 store.set_suggested_purpose("src/b.rs", "Generated B suggestion")?;
11585 store.set_suggested_purpose("src/changed.rs", "Original suggestion")?;
11586 require_eq(
11587 &store.authored_purpose_revision()?,
11588 &0,
11589 "suggestions do not advance authored-purpose revision",
11590 )?;
11591 let task = "issue-308-purpose-curation";
11592
11593 let apply_batch = store
11594 .load_purpose_curation_batch(task, &["src/a.rs".to_string(), "src/b.rs".to_string()])?;
11595 let requests = apply_batch
11596 .items
11597 .iter()
11598 .map(|candidate| {
11599 conditional_purpose_request(
11600 task,
11601 candidate,
11602 &format!("Reviewed {}", candidate.node.node.path),
11603 )
11604 })
11605 .collect::<Vec<_>>();
11606 let applied = store.conditionally_set_purposes(&requests)?;
11607 require_eq(
11608 &applied
11609 .iter()
11610 .map(|result| result.state)
11611 .collect::<Vec<_>>(),
11612 &vec![
11613 PurposeConditionalApplyState::Applied,
11614 PurposeConditionalApplyState::Applied,
11615 ],
11616 "one-transaction batch outcomes",
11617 )?;
11618 require_eq(
11619 &applied.iter().all(|result| {
11620 result.current_purpose.as_ref().is_some_and(|purpose| {
11621 purpose.status == PurposeStatus::Approved
11622 && purpose.source == PurposeSource::Agent
11623 })
11624 }),
11625 &true,
11626 "applied transaction-owned purpose metadata",
11627 )?;
11628 require_eq(
11629 &store.authored_purpose_revision()?,
11630 &1,
11631 "one accepted batch advances one authored-purpose revision",
11632 )?;
11633
11634 let changed = store
11635 .load_purpose_curation_batch(task, &["src/changed.rs".to_string()])?
11636 .items
11637 .into_iter()
11638 .next()
11639 .ok_or_else(|| io::Error::other("changed candidate missing"))?;
11640 store.set_suggested_purpose("src/changed.rs", "Concurrent suggestion")?;
11641 let changed_result = store
11642 .conditionally_set_purposes(&[conditional_purpose_request(
11643 task,
11644 &changed,
11645 "Stale curator answer",
11646 )])?
11647 .pop()
11648 .ok_or_else(|| io::Error::other("changed conditional result missing"))?;
11649 require_eq(
11650 &changed_result.state,
11651 &PurposeConditionalApplyState::Stale,
11652 "changed suggestion state",
11653 )?;
11654 require_eq(
11655 &changed_result
11656 .current_purpose
11657 .as_ref()
11658 .map(|purpose| (purpose.status, purpose.source, purpose.purpose.as_deref())),
11659 &Some((
11660 PurposeStatus::Suggested,
11661 PurposeSource::Generated,
11662 Some("Concurrent suggestion"),
11663 )),
11664 "stale transaction-owned purpose metadata",
11665 )?;
11666 require_eq(
11667 &store.authored_purpose_revision()?,
11668 &1,
11669 "stale conditional purpose leaves revision unchanged",
11670 )?;
11671
11672 let accepted = store
11673 .load_purpose_curation_batch(task, &["src/accepted.rs".to_string()])?
11674 .items
11675 .into_iter()
11676 .next()
11677 .ok_or_else(|| io::Error::other("accepted candidate missing"))?;
11678 store.set_purpose(
11679 "src/accepted.rs",
11680 "Concurrent accepted purpose",
11681 PurposeSource::Agent,
11682 )?;
11683 require_eq(
11684 &store.authored_purpose_revision()?,
11685 &2,
11686 "explicit accepted correction advances the authored-purpose revision",
11687 )?;
11688 store.set_purpose(
11689 "src/accepted.rs",
11690 "Concurrent accepted purpose",
11691 PurposeSource::Agent,
11692 )?;
11693 require_eq(
11694 &store.authored_purpose_revision()?,
11695 &2,
11696 "identical accepted purpose leaves the authored-purpose revision unchanged",
11697 )?;
11698 let accepted_result = store
11699 .conditionally_set_purposes(&[conditional_purpose_request(
11700 task,
11701 &accepted,
11702 "Stale curator overwrite",
11703 )])?
11704 .pop()
11705 .ok_or_else(|| io::Error::other("accepted conditional result missing"))?;
11706 require_eq(
11707 &accepted_result.state,
11708 &PurposeConditionalApplyState::Accepted,
11709 "accepted concurrent state",
11710 )?;
11711 require_eq(
11712 &accepted_result
11713 .current_purpose
11714 .as_ref()
11715 .map(|purpose| (purpose.status, purpose.source, purpose.purpose.as_deref())),
11716 &Some((
11717 PurposeStatus::Approved,
11718 PurposeSource::Agent,
11719 Some("Concurrent accepted purpose"),
11720 )),
11721 "accepted transaction-owned purpose metadata",
11722 )?;
11723 require_eq(
11724 &store.authored_purpose_revision()?,
11725 &2,
11726 "accepted conditional no-op leaves revision unchanged",
11727 )?;
11728
11729 let unavailable_result = store
11730 .conditionally_set_purposes(&[PurposeConditionalApplyRequest {
11731 task: task.to_string(),
11732 path: "src/unavailable.rs".to_string(),
11733 work_key: "unavailable-work".to_string(),
11734 state_token: "unavailable-state".to_string(),
11735 purpose: "Unavailable purpose".to_string(),
11736 }])?
11737 .pop()
11738 .ok_or_else(|| io::Error::other("unavailable conditional result missing"))?;
11739 require_eq(
11740 &unavailable_result.state,
11741 &PurposeConditionalApplyState::PathUnavailable,
11742 "unavailable path state",
11743 )?;
11744 require_eq(
11745 &unavailable_result.current_purpose,
11746 &None,
11747 "unavailable transaction-owned purpose metadata",
11748 )?;
11749
11750 let generation = store
11751 .load_purpose_curation_batch(task, &["src/generation.rs".to_string()])?
11752 .items
11753 .into_iter()
11754 .next()
11755 .ok_or_else(|| io::Error::other("generation candidate missing"))?;
11756 let project = store
11757 .project_instance_id()?
11758 .ok_or_else(|| io::Error::other("project identity missing"))?;
11759 {
11760 let mut publication = store.begin_index_publication("purpose-curation-test")?;
11761 publication.begin_scan_replacement()?;
11762 publication.upsert_scan_node_batch(&nodes)?;
11763 publication.finish_scan_replacement()?;
11764 publication.replace_repository_graph(project, &[], &[], &[], &[])?;
11765 publication.complete()?;
11766 }
11767 let generation_state = store.conditionally_set_purpose(
11768 task,
11769 "src/generation.rs",
11770 &generation.work_key,
11771 &generation.state_token,
11772 "Prior-generation answer",
11773 )?;
11774 require_eq(
11775 &generation_state,
11776 &PurposeConditionalApplyState::Stale,
11777 "generation-bound state",
11778 )?;
11779
11780 let current = store.load_nodes_by_paths(&[
11781 "src/changed.rs".to_string(),
11782 "src/accepted.rs".to_string(),
11783 "src/generation.rs".to_string(),
11784 ])?;
11785 let purposes = current
11786 .iter()
11787 .map(|node| {
11788 (
11789 node.node.path.as_str(),
11790 node.purpose.purpose.as_deref(),
11791 node.purpose.status,
11792 )
11793 })
11794 .collect::<Vec<_>>();
11795 require_eq(
11796 &purposes,
11797 &vec![
11798 (
11799 "src/accepted.rs",
11800 Some("Concurrent accepted purpose"),
11801 PurposeStatus::Approved,
11802 ),
11803 (
11804 "src/changed.rs",
11805 Some("Concurrent suggestion"),
11806 PurposeStatus::Suggested,
11807 ),
11808 ("src/generation.rs", None, PurposeStatus::Missing),
11809 ],
11810 "conflicting rows remain untouched",
11811 )?;
11812
11813 let rollback_batch = store.load_purpose_curation_batch(
11814 task,
11815 &[
11816 "src/rollback-a.rs".to_string(),
11817 "src/rollback-b.rs".to_string(),
11818 ],
11819 )?;
11820 let rollback_requests = rollback_batch
11821 .items
11822 .iter()
11823 .map(|candidate| {
11824 conditional_purpose_request(
11825 task,
11826 candidate,
11827 &format!("Reviewed {}", candidate.node.node.path),
11828 )
11829 })
11830 .collect::<Vec<_>>();
11831 store.connection.execute_batch(
11832 "
11833 CREATE TEMP TRIGGER abort_second_conditional_purpose_update
11834 BEFORE UPDATE ON purposes
11835 WHEN OLD.node_id = (
11836 SELECT id FROM nodes WHERE path = 'src/rollback-b.rs'
11837 )
11838 BEGIN
11839 SELECT RAISE(ABORT, 'forced conditional purpose rollback');
11840 END;
11841 ",
11842 )?;
11843 if store.conditionally_set_purposes(&rollback_requests).is_ok() {
11844 return Err(io::Error::other("injected conditional batch failure succeeded").into());
11845 }
11846 drop(store);
11847 let reopened = AtlasStore::open_for_project(&database, &root)?;
11848 let rolled_back = reopened.load_nodes_by_paths(&[
11849 "src/rollback-a.rs".to_string(),
11850 "src/rollback-b.rs".to_string(),
11851 ])?;
11852 require_eq(
11853 &rolled_back
11854 .iter()
11855 .map(|node| node.purpose.status)
11856 .collect::<Vec<_>>(),
11857 &vec![PurposeStatus::Missing, PurposeStatus::Missing],
11858 "failed conditional batch rolled back after reopen",
11859 )?;
11860 Ok(())
11861 }
11862
11863 #[test]
11864 fn concurrent_purpose_curators_cannot_overwrite_the_winner() -> Result<(), Box<dyn Error>> {
11865 let temp = tempfile::tempdir()?;
11866 let root = temp.path().join("project");
11867 fs::create_dir(&root)?;
11868 let database = temp.path().join("projectatlas.db");
11869 let mut store = AtlasStore::open_for_project(&database, &root)?;
11870 store.replace_scan(&[test_file_node("src/main.rs", "hash-main")])?;
11871 let candidate = store
11872 .load_purpose_curation_batch("concurrent-curators", &["src/main.rs".to_string()])?
11873 .items
11874 .into_iter()
11875 .next()
11876 .ok_or_else(|| io::Error::other("concurrent candidate missing"))?;
11877 drop(store);
11878
11879 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
11880 let mut handles = Vec::new();
11881 for purpose in ["First reviewed purpose", "Second reviewed purpose"] {
11882 let database = database.clone();
11883 let root = root.clone();
11884 let barrier = std::sync::Arc::clone(&barrier);
11885 let candidate = candidate.clone();
11886 handles.push(std::thread::spawn(move || {
11887 let store = AtlasStore::open_for_project(&database, &root)?;
11888 barrier.wait();
11889 store.conditionally_set_purpose(
11890 "concurrent-curators",
11891 "src/main.rs",
11892 &candidate.work_key,
11893 &candidate.state_token,
11894 purpose,
11895 )
11896 }));
11897 }
11898 let outcomes = handles
11899 .into_iter()
11900 .map(|handle| {
11901 handle
11902 .join()
11903 .map_err(|_panic| io::Error::other("curator thread panicked"))?
11904 .map_err(io::Error::other)
11905 })
11906 .collect::<Result<Vec<_>, io::Error>>()?;
11907 require_eq(
11908 &outcomes
11909 .iter()
11910 .filter(|state| **state == PurposeConditionalApplyState::Applied)
11911 .count(),
11912 &1,
11913 "single concurrent winner",
11914 )?;
11915 require_eq(
11916 &outcomes
11917 .iter()
11918 .filter(|state| **state == PurposeConditionalApplyState::Accepted)
11919 .count(),
11920 &1,
11921 "accepted concurrent loser",
11922 )?;
11923
11924 let reopened = AtlasStore::open_for_project(&database, &root)?;
11925 let node = reopened
11926 .load_node_by_path("src/main.rs")?
11927 .ok_or_else(|| io::Error::other("concurrent path disappeared"))?;
11928 require_eq(
11929 &node.purpose.status,
11930 &PurposeStatus::Approved,
11931 "persisted concurrent winner status",
11932 )?;
11933 require_eq(
11934 &matches!(
11935 node.purpose.purpose.as_deref(),
11936 Some("First reviewed purpose" | "Second reviewed purpose")
11937 ),
11938 &true,
11939 "persisted concurrent winner value",
11940 )?;
11941 Ok(())
11942 }
11943
11944 #[test]
11945 fn purpose_curation_hydration_uses_existing_unique_indexes() -> Result<(), Box<dyn Error>> {
11946 let mut store = AtlasStore::in_memory()?;
11947 store.replace_scan(&[
11948 test_file_node("src/a.rs", "hash-a"),
11949 test_file_node("src/b.rs", "hash-b"),
11950 ])?;
11951 let sql = format!("EXPLAIN QUERY PLAN {}", load_nodes_by_paths_sql(2));
11952 let mut statement = store.connection.prepare(&sql)?;
11953 let details = statement
11954 .query_map(params!["src/a.rs", "src/b.rs"], |row| {
11955 row.get::<_, String>(3)
11956 })?
11957 .collect::<Result<Vec<_>, _>>()?;
11958 let plan = details.join("\n");
11959 require_eq(
11960 &plan.contains("SEARCH n USING INDEX sqlite_autoindex_nodes_"),
11961 &true,
11962 "unique path index query plan",
11963 )?;
11964 require_eq(
11965 &plan.contains("SEARCH p USING INTEGER PRIMARY KEY"),
11966 &true,
11967 "purpose primary-key query plan",
11968 )?;
11969 require_eq(&plan.contains("SCAN n"), &false, "no node-table scan")?;
11970 Ok(())
11971 }
11972
11973 #[test]
11974 fn updates_content_summary_without_approving_purpose() -> Result<(), Box<dyn Error>> {
11975 let mut store = AtlasStore::in_memory()?;
11976 let node = Node {
11977 path: "src/lib.rs".to_string(),
11978 kind: NodeKind::File,
11979 parent_path: normalized_parent("src/lib.rs"),
11980 extension: Some(".rs".to_string()),
11981 language: Some("rust".to_string()),
11982 size_bytes: Some(24),
11983 mtime_ns: Some(10),
11984 content_hash: Some("def".to_string()),
11985 };
11986 store.replace_scan(&[node])?;
11987 store.set_node_summary(
11988 "src/lib.rs",
11989 "rust source defining library entry functions.",
11990 )?;
11991 let nodes = store.load_nodes()?;
11992 require_eq(
11993 &nodes[0].summary,
11994 &Some("rust source defining library entry functions.".to_string()),
11995 "updated content summary",
11996 )?;
11997 require_eq(
11998 &nodes[0].purpose.status,
11999 &PurposeStatus::Missing,
12000 "summary update does not approve purpose",
12001 )?;
12002 require_eq(
12003 &store.has_agent_approved_purpose()?,
12004 &false,
12005 "missing purpose does not activate graph hydration",
12006 )?;
12007 store.set_purpose(
12008 "src/lib.rs",
12009 "Owns the library entry points.",
12010 PurposeSource::Agent,
12011 )?;
12012 require_eq(
12013 &store.has_agent_approved_purpose()?,
12014 &true,
12015 "agent-approved purpose activates graph hydration",
12016 )?;
12017 Ok(())
12018 }
12019
12020 #[test]
12021 fn replaces_symbol_graph_idempotently() -> Result<(), Box<dyn Error>> {
12022 let mut store = AtlasStore::in_memory()?;
12023 let graph = SymbolGraph {
12024 path: "src/main.rs".to_string(),
12025 language: Some("rust".to_string()),
12026 parser: ParserKind::TreeSitter,
12027 symbols: vec![CodeSymbol {
12028 path: "src/main.rs".to_string(),
12029 language: Some("rust".to_string()),
12030 name: "main".to_string(),
12031 kind: SymbolKind::Function,
12032 signature: "fn main()".to_string(),
12033 exported: true,
12034 documentation: Some("Run the application.".to_string()),
12035 line_start: 1,
12036 line_end: 3,
12037 parent: None,
12038 parser: ParserKind::TreeSitter,
12039 detail: Some("function_item".to_string()),
12040 }],
12041 relations: vec![SymbolRelation {
12042 path: "src/main.rs".to_string(),
12043 source_name: "main".to_string(),
12044 target_name: "println!".to_string(),
12045 kind: RelationKind::Calls,
12046 line: 2,
12047 context: "println!(\"hello\")".to_string(),
12048 parser: ParserKind::TreeSitter,
12049 }],
12050 };
12051
12052 store.replace_symbol_graph(&graph)?;
12053 store.replace_symbol_graph(&graph)?;
12054 let symbols = store.load_symbols(Some("src/main.rs"), Some("main"), 10)?;
12055 let relations = store.load_symbol_relations(Some("src/main.rs"), Some("println"), 10)?;
12056 let metadata = store
12057 .load_source_parse_metadata("src/main.rs")?
12058 .ok_or_else(|| io::Error::other("missing source parse metadata"))?;
12059 require_eq(&symbols.len(), &1, "symbol count after replace")?;
12060 require_eq(&relations.len(), &1, "relation count after replace")?;
12061 require_eq(&metadata.parser, &ParserKind::TreeSitter, "metadata parser")?;
12062 require_eq(&metadata.symbol_count, &1, "metadata symbol count")?;
12063 require_eq(&metadata.relation_count, &1, "metadata relation count")?;
12064 require_eq(&symbols[0].exported, &true, "exported metadata")?;
12065 require_eq(
12066 &symbols[0].documentation,
12067 &Some("Run the application.".to_string()),
12068 "documentation metadata",
12069 )?;
12070 Ok(())
12071 }
12072
12073 #[test]
12074 fn import_alias_lookup_uses_kind_first_covering_index() -> Result<(), Box<dyn Error>> {
12075 let mut store = AtlasStore::in_memory()?;
12076 store.replace_symbol_graph(&SymbolGraph {
12077 path: "src/main.rs".to_string(),
12078 language: Some("rust".to_string()),
12079 parser: ParserKind::TreeSitter,
12080 symbols: Vec::new(),
12081 relations: vec![
12082 SymbolRelation {
12083 path: "src/main.rs".to_string(),
12084 source_name: "main".to_string(),
12085 target_name: "use crate::service::run".to_string(),
12086 kind: RelationKind::Imports,
12087 line: 1,
12088 context: "use crate::service::run;".to_string(),
12089 parser: ParserKind::TreeSitter,
12090 },
12091 SymbolRelation {
12092 path: "src/main.rs".to_string(),
12093 source_name: "main".to_string(),
12094 target_name: "use crate::other::Thing".to_string(),
12095 kind: RelationKind::Imports,
12096 line: 2,
12097 context: "use crate::other::Thing;".to_string(),
12098 parser: ParserKind::TreeSitter,
12099 },
12100 ],
12101 })?;
12102
12103 let relations =
12104 store.load_import_relations_matching_targets(&["service".to_string()], 10)?;
12105 require_eq(&relations.len(), &1, "matched import relation count")?;
12106 require_eq(
12107 &store
12108 .load_import_relations_for_path("src/main.rs", 10)?
12109 .len(),
12110 &2,
12111 "exact-path import relation count",
12112 )?;
12113 let mut statement = store.connection.prepare(
12114 "EXPLAIN QUERY PLAN
12115 SELECT path, source_name, target_name, line
12116 FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
12117 WHERE kind = 'imports' AND target_name LIKE '%service%' ESCAPE '\\'
12118 ORDER BY path, line, source_name, target_name
12119 LIMIT 10",
12120 )?;
12121 let plan = statement
12122 .query_map([], |row| row.get::<_, String>(3))?
12123 .collect::<Result<Vec<_>, _>>()?
12124 .join("\n");
12125 require(
12126 plan.contains(
12127 "SEARCH symbol_relations USING COVERING INDEX idx_symbol_import_alias_lookup (kind=?)",
12128 ),
12129 &format!("import alias lookup missed kind-first covering index: {plan}"),
12130 )?;
12131 Ok(())
12132 }
12133
12134 #[test]
12135 fn file_scoped_symbol_kind_lookup_uses_path_index() -> Result<(), Box<dyn Error>> {
12136 let store = AtlasStore::in_memory()?;
12137 for sql in [
12138 "EXPLAIN QUERY PLAN
12139 SELECT path, language, name, kind, signature, line_start, line_end,
12140 parent, parser, detail, exported, documentation
12141 FROM symbols INDEXED BY idx_symbols_path
12142 WHERE path = 'src/lib.rs' AND kind IN ('function')
12143 ORDER BY path, line_start, name
12144 LIMIT 25",
12145 "EXPLAIN QUERY PLAN
12146 SELECT COUNT(*) FROM symbols INDEXED BY idx_symbols_path
12147 WHERE path = 'src/lib.rs' AND kind IN ('function')",
12148 ] {
12149 let mut statement = store.connection.prepare(sql)?;
12150 let plan = statement
12151 .query_map([], |row| row.get::<_, String>(3))?
12152 .collect::<Result<Vec<_>, _>>()?
12153 .join("\n");
12154 require(
12155 plan.contains("SEARCH symbols USING INDEX idx_symbols_path (path=?)"),
12156 &format!("file-scoped symbol lookup missed exact-path index: {plan}"),
12157 )?;
12158 }
12159 Ok(())
12160 }
12161
12162 #[test]
12163 fn preserves_source_parse_and_fact_parser_provenance_independently()
12164 -> Result<(), Box<dyn Error>> {
12165 let temp = tempfile::tempdir()?;
12166 let database = temp.path().join("projectatlas.db");
12167 let mut store = AtlasStore::open(&database)?;
12168 let graph = SymbolGraph {
12169 path: "src/optional.lang".to_string(),
12170 language: Some("optional-language".to_string()),
12171 parser: ParserKind::Fallback,
12172 symbols: vec![CodeSymbol {
12173 path: "src/optional.lang".to_string(),
12174 language: Some("optional-language".to_string()),
12175 name: "entry".to_string(),
12176 kind: SymbolKind::Function,
12177 signature: "entry()".to_string(),
12178 exported: false,
12179 documentation: None,
12180 line_start: 1,
12181 line_end: 1,
12182 parent: None,
12183 parser: ParserKind::Fallback,
12184 detail: None,
12185 }],
12186 relations: vec![SymbolRelation {
12187 path: "src/optional.lang".to_string(),
12188 source_name: "entry".to_string(),
12189 target_name: "helper".to_string(),
12190 kind: RelationKind::Calls,
12191 line: 1,
12192 context: "entry()".to_string(),
12193 parser: ParserKind::Fallback,
12194 }],
12195 };
12196 let metadata = SourceParseMetadata {
12197 path: graph.path.clone(),
12198 language: graph.language.clone(),
12199 parser: ParserKind::TreeSitter,
12200 symbol_count: graph.symbols.len(),
12201 relation_count: graph.relations.len(),
12202 };
12203
12204 store.replace_symbol_graph_with_metadata(&graph, &metadata)?;
12205
12206 let stored_metadata = store
12207 .load_source_parse_metadata(&graph.path)?
12208 .ok_or_else(|| io::Error::other("missing independent source parse metadata"))?;
12209 let symbols = store.load_symbols(Some(&graph.path), Some("entry"), 10)?;
12210 let relations = store.load_symbol_relations(Some(&graph.path), Some("helper"), 10)?;
12211 require_eq(
12212 &stored_metadata.parser,
12213 &ParserKind::TreeSitter,
12214 "grammar-backed source parser",
12215 )?;
12216 require_eq(
12217 &symbols[0].parser,
12218 &ParserKind::Fallback,
12219 "fallback symbol provenance",
12220 )?;
12221 require_eq(
12222 &relations[0].parser,
12223 &ParserKind::Fallback,
12224 "fallback relation provenance",
12225 )?;
12226
12227 let invalid_metadata = SourceParseMetadata {
12228 symbol_count: 0,
12229 ..metadata
12230 };
12231 let Err(error) = store.replace_symbol_graph_with_metadata(&graph, &invalid_metadata) else {
12232 return Err(io::Error::other("mismatched explicit metadata was accepted").into());
12233 };
12234 if !matches!(error, DbError::SymbolGraphRowShape { .. }) {
12235 return Err(io::Error::other(format!(
12236 "mismatched explicit metadata returned the wrong error: {error}"
12237 ))
12238 .into());
12239 }
12240
12241 let empty_graph = SymbolGraph {
12242 path: "src/empty.optional".to_string(),
12243 language: Some("optional-language".to_string()),
12244 parser: ParserKind::Fallback,
12245 symbols: Vec::new(),
12246 relations: Vec::new(),
12247 };
12248 let empty_metadata = SourceParseMetadata {
12249 path: empty_graph.path.clone(),
12250 language: empty_graph.language.clone(),
12251 parser: ParserKind::TreeSitter,
12252 symbol_count: 0,
12253 relation_count: 0,
12254 };
12255 store.replace_symbol_graph_with_metadata(&empty_graph, &empty_metadata)?;
12256 drop(store);
12257
12258 let reader = AtlasStore::open_read_only(&database)?;
12259 let reopened_metadata = reader
12260 .load_source_parse_metadata(&graph.path)?
12261 .ok_or_else(|| io::Error::other("missing reopened source parse metadata"))?;
12262 let metadata_paths = reader.source_parse_metadata_paths_for_paths(&[
12263 "src/missing.optional".to_string(),
12264 empty_graph.path.clone(),
12265 graph.path.clone(),
12266 graph.path.clone(),
12267 ])?;
12268 let reopened_graphs =
12269 reader.load_symbol_graphs_for_paths(&[graph.path.clone(), empty_graph.path.clone()])?;
12270 require_eq(
12271 &reopened_metadata.parser,
12272 &ParserKind::TreeSitter,
12273 "reopened source parser provenance",
12274 )?;
12275 require_eq(
12276 &metadata_paths,
12277 &HashSet::from([graph.path.clone(), empty_graph.path.clone()]),
12278 "batched source parse metadata paths",
12279 )?;
12280 require_eq(
12281 &reopened_graphs,
12282 &vec![empty_graph, graph],
12283 "reopened fact graph provenance",
12284 )?;
12285 reader.finish_index_read_snapshot()?;
12286 Ok(())
12287 }
12288
12289 #[test]
12290 fn reconstructs_exact_symbol_graph_batches_from_disk_and_fails_closed()
12291 -> Result<(), Box<dyn Error>> {
12292 let temp = tempfile::tempdir()?;
12293 let db_path = temp.path().join("projectatlas.db");
12294 let mut store = AtlasStore::open(&db_path)?;
12295 store.replace_scan(&[
12296 test_file_node("src/a.rs", "hash-a"),
12297 test_file_node("src/b.rs", "hash-b"),
12298 ])?;
12299 let graph = SymbolGraph {
12300 path: "src/a.rs".to_string(),
12301 language: Some("rust".to_string()),
12302 parser: ParserKind::TreeSitter,
12303 symbols: vec![CodeSymbol {
12304 path: "src/a.rs".to_string(),
12305 language: Some("rust".to_string()),
12306 name: "owner".to_string(),
12307 kind: SymbolKind::Function,
12308 signature: "fn owner()".to_string(),
12309 exported: true,
12310 documentation: None,
12311 line_start: 1,
12312 line_end: 2,
12313 parent: None,
12314 parser: ParserKind::TreeSitter,
12315 detail: Some("function_item".to_string()),
12316 }],
12317 relations: vec![SymbolRelation {
12318 path: "src/a.rs".to_string(),
12319 source_name: "owner".to_string(),
12320 target_name: "dependency".to_string(),
12321 kind: RelationKind::Calls,
12322 line: 2,
12323 context: "dependency()".to_string(),
12324 parser: ParserKind::TreeSitter,
12325 }],
12326 };
12327 store.replace_symbol_graph(&graph)?;
12328 let empty_graph = SymbolGraph {
12329 path: "src/b.rs".to_string(),
12330 language: Some("rust".to_string()),
12331 parser: ParserKind::TreeSitter,
12332 symbols: Vec::new(),
12333 relations: Vec::new(),
12334 };
12335 store.replace_symbol_graph(&empty_graph)?;
12336 drop(store);
12337
12338 let reader = AtlasStore::open_read_only(&db_path)?;
12339 let loaded = reader.load_symbol_graphs_for_paths(&[
12340 "src/b.rs".to_string(),
12341 "src/a.rs".to_string(),
12342 "src/a.rs".to_string(),
12343 "src/missing.rs".to_string(),
12344 ])?;
12345 require_eq(
12346 &loaded,
12347 &vec![graph, empty_graph],
12348 "batched symbol graph round trip",
12349 )?;
12350 for (table, expected_index) in [
12351 (
12352 "source_parse_metadata",
12353 "sqlite_autoindex_source_parse_metadata_1",
12354 ),
12355 ("symbols", "idx_symbols_path"),
12356 ("symbol_relations", "idx_symbol_relations_path"),
12357 ] {
12358 let sql = format!(
12359 "EXPLAIN QUERY PLAN SELECT path FROM {table}
12360 WHERE path IN ('src/a.rs', 'src/b.rs')"
12361 );
12362 let mut statement = reader.connection.prepare(&sql)?;
12363 let plan = statement
12364 .query_map([], |row| row.get::<_, String>(3))?
12365 .collect::<Result<Vec<_>, _>>()?
12366 .join("\n");
12367 if !plan.contains(expected_index) {
12368 return Err(io::Error::other(format!(
12369 "{table} batch lookup missed {expected_index}: {plan}"
12370 ))
12371 .into());
12372 }
12373 }
12374 reader.finish_index_read_snapshot()?;
12375 drop(reader);
12376
12377 let writer = AtlasStore::open(&db_path)?;
12378 writer.connection.execute(
12379 "UPDATE source_parse_metadata SET symbol_count = 2 WHERE path = 'src/a.rs'",
12380 [],
12381 )?;
12382 let Err(error) = writer.load_symbol_graphs_for_paths(&["src/a.rs".to_string()]) else {
12383 return Err(io::Error::other("metadata count corruption was accepted").into());
12384 };
12385 if !matches!(error, DbError::SymbolGraphRowShape { .. }) {
12386 return Err(io::Error::other(format!(
12387 "metadata count corruption returned the wrong error: {error}"
12388 ))
12389 .into());
12390 }
12391 Ok(())
12392 }
12393
12394 #[test]
12395 fn bounded_symbol_batch_streams_exact_paths_and_uses_path_index() -> Result<(), Box<dyn Error>>
12396 {
12397 let mut store = AtlasStore::in_memory()?;
12398 store.replace_scan(&[
12399 test_file_node("src/a.rs", "hash-a"),
12400 test_file_node("src/b.rs", "hash-b"),
12401 ])?;
12402 store.replace_symbol_graph(&SymbolGraph {
12403 path: "src/a.rs".to_string(),
12404 language: Some("rust".to_string()),
12405 parser: ParserKind::TreeSitter,
12406 symbols: vec![
12407 batch_test_symbol("src/a.rs", "alpha", 1, 32),
12408 batch_test_symbol("src/a.rs", "beta", 2, 32),
12409 ],
12410 relations: Vec::new(),
12411 })?;
12412 store.replace_symbol_graph(&SymbolGraph {
12413 path: "src/b.rs".to_string(),
12414 language: Some("rust".to_string()),
12415 parser: ParserKind::TreeSitter,
12416 symbols: vec![batch_test_symbol("src/b.rs", "gamma", 1, 32)],
12417 relations: Vec::new(),
12418 })?;
12419
12420 let complete = store.load_symbols_for_paths_bounded(
12421 &[
12422 "src/b.rs".to_string(),
12423 "src/a.rs".to_string(),
12424 "src/a.rs".to_string(),
12425 ],
12426 SymbolBatchReadBudget::new(2, 3, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
12427 None,
12428 )?;
12429 require_eq(
12430 &complete
12431 .rows
12432 .iter()
12433 .map(|symbol| symbol.name.as_str())
12434 .collect::<Vec<_>>(),
12435 &vec!["alpha", "beta", "gamma"],
12436 "deterministic exact-path symbol order",
12437 )?;
12438 require_eq(&complete.truncated, &false, "complete symbol batch")?;
12439 require_eq(&complete.reached_limit, &None, "complete batch limit")?;
12440 require_eq(&complete.work.requested_paths, &2, "unique admitted paths")?;
12441 require_eq(&complete.work.returned_rows, &3, "complete returned rows")?;
12442
12443 let row_limited = store.load_symbols_for_paths_bounded(
12444 &["src/b.rs".to_string(), "src/a.rs".to_string()],
12445 SymbolBatchReadBudget::new(2, 1, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
12446 None,
12447 )?;
12448 require_eq(
12449 &row_limited.reached_limit,
12450 &Some(SymbolBatchReadLimit::Rows),
12451 "row-bound classification",
12452 )?;
12453 require_eq(&row_limited.rows.len(), &1, "row-bound retained rows")?;
12454
12455 let byte_limited = store.load_symbols_for_paths_bounded(
12456 &["src/a.rs".to_string()],
12457 SymbolBatchReadBudget::new(1, 3, 1)?,
12458 None,
12459 )?;
12460 require_eq(
12461 &byte_limited.reached_limit,
12462 &Some(SymbolBatchReadLimit::DecodedBytes),
12463 "decoded-byte-bound classification",
12464 )?;
12465 require_eq(
12466 &byte_limited.rows.len(),
12467 &0,
12468 "tiny byte budget retained no partial row",
12469 )?;
12470 require_eq(
12471 &byte_limited.work.decoded_bytes,
12472 &0,
12473 "tiny byte budget retained no row payload",
12474 )?;
12475
12476 let gamma_bytes = code_symbol_decoded_bytes(&complete.rows[2])?;
12477 let exact_byte_boundary = store.load_symbols_for_paths_bounded(
12478 &["src/b.rs".to_string()],
12479 SymbolBatchReadBudget::new(1, 1, gamma_bytes)?,
12480 None,
12481 )?;
12482 require_eq(
12483 &exact_byte_boundary.rows.len(),
12484 &1,
12485 "exact decoded-byte boundary retained the complete row",
12486 )?;
12487 require_eq(
12488 &exact_byte_boundary.work.decoded_bytes,
12489 &gamma_bytes,
12490 "exact decoded-byte boundary accounting",
12491 )?;
12492
12493 let oversized_signature =
12494 "x".repeat(usize::try_from(MAX_SYMBOL_BATCH_DECODED_BYTES)?.saturating_add(1));
12495 store.connection.execute(
12496 "UPDATE symbols SET signature = ?1, line_start = 'invalid' WHERE path = 'src/b.rs'",
12497 [&oversized_signature],
12498 )?;
12499 let oversized_row = store.load_symbols_for_paths_bounded(
12500 &["src/b.rs".to_string()],
12501 SymbolBatchReadBudget::new(1, 1, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
12502 None,
12503 )?;
12504 require_eq(
12505 &oversized_row.reached_limit,
12506 &Some(SymbolBatchReadLimit::DecodedBytes),
12507 "oversized persisted row was rejected before hydration",
12508 )?;
12509 require_eq(
12510 &oversized_row.rows.len(),
12511 &0,
12512 "oversized persisted row retained no allocation",
12513 )?;
12514 store.connection.execute(
12515 "UPDATE symbols SET signature = 'fn gamma()', line_start = 1 WHERE path = 'src/b.rs'",
12516 [],
12517 )?;
12518
12519 let many_paths = (0..65)
12520 .rev()
12521 .map(|index| format!("generated/{index:04}.rs"))
12522 .collect::<Vec<_>>();
12523 for index in 0..65 {
12524 let path = format!("generated/{index:04}.rs");
12525 store.replace_symbol_graph(&SymbolGraph {
12526 path: path.clone(),
12527 language: Some("rust".to_string()),
12528 parser: ParserKind::TreeSitter,
12529 symbols: vec![batch_test_symbol(
12530 &path,
12531 &format!("generated_{index:04}"),
12532 1,
12533 0,
12534 )],
12535 relations: Vec::new(),
12536 })?;
12537 }
12538 let path_limited = store.load_symbols_for_paths_bounded(
12539 &many_paths,
12540 SymbolBatchReadBudget::new(
12541 MAX_SYMBOL_BATCH_PATHS,
12542 MAX_SYMBOL_BATCH_ROWS,
12543 MAX_SYMBOL_BATCH_DECODED_BYTES,
12544 )?,
12545 None,
12546 )?;
12547 require_eq(
12548 &path_limited.reached_limit,
12549 &Some(SymbolBatchReadLimit::Paths),
12550 "path-bound classification",
12551 )?;
12552 require_eq(
12553 &path_limited.work.requested_paths,
12554 &MAX_SYMBOL_BATCH_PATHS,
12555 "bounded path admission",
12556 )?;
12557 require_eq(
12558 &path_limited.rows.len(),
12559 &usize::try_from(MAX_SYMBOL_BATCH_PATHS)?,
12560 "all admitted paths loaded across binding chunks",
12561 )?;
12562 require(
12563 path_limited
12564 .rows
12565 .iter()
12566 .any(|symbol| symbol.name == "generated_0063"),
12567 "last deterministically admitted path was not loaded",
12568 )?;
12569 require(
12570 path_limited
12571 .rows
12572 .iter()
12573 .all(|symbol| symbol.name != "generated_0064"),
12574 "path outside the deterministic admission set was loaded",
12575 )?;
12576
12577 let expired = IndexWorkControl::with_deadline(
12578 projectatlas_core::IndexCancellation::new(),
12579 Instant::now(),
12580 );
12581 let expired_result = store.load_symbols_for_paths_bounded(
12582 &["src/a.rs".to_string()],
12583 SymbolBatchReadBudget::new(1, 3, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
12584 Some(&expired),
12585 );
12586 require(
12587 matches!(
12588 expired_result,
12589 Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
12590 stage: IndexWorkStage::RepositoryTraversal
12591 }))
12592 ),
12593 "expired symbol control returned a partial batch instead of a typed error",
12594 )?;
12595
12596 let mut plan_statement = store.connection.prepare(
12597 "EXPLAIN QUERY PLAN
12598 SELECT
12599 path,
12600 language,
12601 name,
12602 kind,
12603 signature,
12604 line_start,
12605 line_end,
12606 parent,
12607 parser,
12608 detail,
12609 exported,
12610 documentation,
12611 length(CAST(path AS BLOB))
12612 + COALESCE(length(CAST(language AS BLOB)), 0)
12613 + length(CAST(name AS BLOB))
12614 + length(CAST(signature AS BLOB))
12615 + COALESCE(length(CAST(parent AS BLOB)), 0)
12616 + COALESCE(length(CAST(detail AS BLOB)), 0)
12617 + COALESCE(length(CAST(documentation AS BLOB)), 0)
12618 FROM symbols
12619 WHERE path IN (?1, ?2)
12620 ORDER BY path, line_start, name
12621 LIMIT ?3",
12622 )?;
12623 let plan = plan_statement
12624 .query_map(params!["src/a.rs", "src/b.rs", 4], |row| {
12625 row.get::<_, String>(3)
12626 })?
12627 .collect::<Result<Vec<_>, _>>()?
12628 .join("\n");
12629 require(
12630 plan.contains("idx_symbols_path"),
12631 &format!("bounded symbol batch missed idx_symbols_path: {plan}"),
12632 )?;
12633 Ok(())
12634 }
12635
12636 #[test]
12637 fn full_scan_removal_clears_source_parse_metadata() -> Result<(), Box<dyn Error>> {
12638 let mut store = AtlasStore::in_memory()?;
12639 store.replace_scan(&[test_file_node("src/a.rs", "hash-a")])?;
12640 store.replace_symbol_graph(&SymbolGraph {
12641 path: "src/a.rs".to_string(),
12642 language: Some("rust".to_string()),
12643 parser: ParserKind::TreeSitter,
12644 symbols: Vec::new(),
12645 relations: Vec::new(),
12646 })?;
12647 require_eq(
12648 &store.load_source_parse_metadata("src/a.rs")?.is_some(),
12649 &true,
12650 "metadata exists before removal",
12651 )?;
12652
12653 store.replace_scan(&[test_file_node("src/b.rs", "hash-b")])?;
12654 require_eq(
12655 &store.load_source_parse_metadata("src/a.rs")?,
12656 &None,
12657 "metadata cleared after full scan removal",
12658 )?;
12659 Ok(())
12660 }
12661
12662 #[test]
12663 fn call_relations_are_limited_per_target() -> Result<(), Box<dyn Error>> {
12664 let mut store = AtlasStore::in_memory()?;
12665 let mut relations = Vec::new();
12666 for index in 0..5 {
12667 relations.push(SymbolRelation {
12668 path: format!("src/a{index}.rs"),
12669 source_name: format!("alpha_caller_{index}"),
12670 target_name: "alpha".to_string(),
12671 kind: RelationKind::Calls,
12672 line: index + 1,
12673 context: "alpha();".to_string(),
12674 parser: ParserKind::TreeSitter,
12675 });
12676 }
12677 relations.push(SymbolRelation {
12678 path: "src/z.rs".to_string(),
12679 source_name: "beta_caller".to_string(),
12680 target_name: "beta".to_string(),
12681 kind: RelationKind::Calls,
12682 line: 99,
12683 context: "beta();".to_string(),
12684 parser: ParserKind::TreeSitter,
12685 });
12686 store.replace_symbol_graph(&SymbolGraph {
12687 path: "src/main.rs".to_string(),
12688 language: Some("rust".to_string()),
12689 parser: ParserKind::TreeSitter,
12690 symbols: Vec::new(),
12691 relations,
12692 })?;
12693
12694 let loaded =
12695 store.load_call_relations_to_targets(&["alpha".to_string(), "beta".to_string()], 2)?;
12696 let alpha_count = loaded
12697 .iter()
12698 .filter(|relation| relation.target_name == "alpha")
12699 .count();
12700 let beta_count = loaded
12701 .iter()
12702 .filter(|relation| relation.target_name == "beta")
12703 .count();
12704 require_eq(&alpha_count, &2, "alpha per-target limit")?;
12705 require_eq(&beta_count, &1, "beta preserved despite alpha skew")?;
12706 let mut statement = store.connection.prepare(
12707 "EXPLAIN QUERY PLAN
12708 SELECT path, source_name, target_name, kind, line, context, parser
12709 FROM (
12710 SELECT path, source_name, target_name, kind, line, context, parser,
12711 ROW_NUMBER() OVER (
12712 PARTITION BY target_name
12713 ORDER BY path, line, source_name, target_name
12714 ) AS target_row
12715 FROM symbol_relations INDEXED BY idx_symbol_relations_target
12716 WHERE kind = 'calls' AND target_name IN ('alpha', 'beta')
12717 )
12718 WHERE target_row <= 2
12719 ORDER BY path, line, source_name, target_name",
12720 )?;
12721 let plan = statement
12722 .query_map([], |row| row.get::<_, String>(3))?
12723 .collect::<Result<Vec<_>, _>>()?
12724 .join("\n");
12725 require(
12726 plan.contains(
12727 "SEARCH symbol_relations USING INDEX idx_symbol_relations_target (target_name=?)",
12728 ),
12729 &format!("call target lookup missed exact-target index: {plan}"),
12730 )?;
12731 Ok(())
12732 }
12733
12734 #[test]
12735 fn stores_health_resolution_ids() -> Result<(), Box<dyn Error>> {
12736 let mut store = AtlasStore::in_memory()?;
12737 store.replace_scan(&[
12738 test_file_node("src/a.rs", "hash-a"),
12739 test_file_node("src/b.rs", "hash-b"),
12740 ])?;
12741 store.set_purpose("src/a.rs", "Shared purpose", PurposeSource::Agent)?;
12742 store.set_purpose("src/b.rs", "Shared purpose", PurposeSource::Agent)?;
12743 let duplicate = store
12744 .unresolved_health_findings(&[])?
12745 .into_iter()
12746 .find(|finding| finding.category == "duplicate-purpose")
12747 .ok_or_else(|| io::Error::other("duplicate-purpose finding missing"))?;
12748 let duplicate_id = duplicate.id.clone();
12749 store.resolve_health_finding(&HealthResolution {
12750 finding_id: duplicate_id.clone(),
12751 category: duplicate.category,
12752 path: duplicate.path,
12753 related_path: duplicate.related_path,
12754 rationale: "Paths intentionally mirror agent skill variants.".to_string(),
12755 })?;
12756 let ids = store.resolved_health_ids()?;
12757 require_eq(&ids, &vec![duplicate_id], "resolved ids")?;
12758 Ok(())
12759 }
12760
12761 #[test]
12762 fn health_resolution_accepts_all_scope_agent_review_findings() -> Result<(), Box<dyn Error>> {
12763 let mut store = AtlasStore::in_memory()?;
12764 let mut asset_file = test_file_node("assets/logo.svg", "hash-logo");
12765 asset_file.extension = Some(".svg".to_string());
12766 asset_file.language = None;
12767 store.replace_scan(&[test_folder_node("assets"), asset_file])?;
12768 store.set_purpose(
12769 "assets/logo.svg",
12770 "Imported SVG brand asset purpose",
12771 PurposeSource::Imported,
12772 )?;
12773
12774 let page = store.unresolved_health_findings_page(
12775 &[],
12776 &HealthQuery {
12777 start_index: 0,
12778 limit: 20,
12779 category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
12780 severity: Some(Severity::Warning),
12781 path_prefix: Some(".".to_string()),
12782 summary_only: false,
12783 scope: HealthScope::all(),
12784 },
12785 )?;
12786 let finding = page
12787 .findings
12788 .iter()
12789 .find(|finding| finding.path == "assets/logo.svg")
12790 .ok_or_else(|| io::Error::other("asset review finding missing"))?;
12791 store.resolve_health_finding(&HealthResolution {
12792 finding_id: finding.id.clone(),
12793 category: finding.category.clone(),
12794 path: finding.path.clone(),
12795 related_path: finding.related_path.clone(),
12796 rationale: "Asset purpose imported from legacy metadata and intentionally accepted."
12797 .to_string(),
12798 })?;
12799
12800 let remaining = store.unresolved_health_findings_page(
12801 &store.resolved_health_ids()?,
12802 &HealthQuery {
12803 start_index: 0,
12804 limit: 20,
12805 category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
12806 severity: Some(Severity::Warning),
12807 path_prefix: Some(".".to_string()),
12808 summary_only: false,
12809 scope: HealthScope::all(),
12810 },
12811 )?;
12812 require_eq(
12813 &health_paths(&remaining).contains(&"assets/logo.svg"),
12814 &false,
12815 "resolved all-scope asset review finding",
12816 )?;
12817 Ok(())
12818 }
12819
12820 #[test]
12821 fn purpose_set_reports_unindexed_path_without_sqlite_leak() -> Result<(), Box<dyn Error>> {
12822 let mut store = AtlasStore::in_memory()?;
12823 store.replace_scan(&[test_file_node("src/main.rs", "hash")])?;
12824 let error = match store.set_purpose("no/such/file.rs", "Missing file", PurposeSource::Agent)
12825 {
12826 Ok(()) => return Err(io::Error::other("missing path should fail").into()),
12827 Err(error) => error,
12828 };
12829
12830 require_eq(
12831 &error.to_string().contains("no/such/file.rs"),
12832 &true,
12833 "path named in error",
12834 )?;
12835 require_eq(
12836 &error.to_string().contains("sqlite error"),
12837 &false,
12838 "raw sqlite error hidden",
12839 )?;
12840 store.replace_scan(&[])?;
12841 let error = match store.set_purpose("src/main.rs", "Removed file", PurposeSource::Agent) {
12842 Ok(()) => return Err(io::Error::other("stale indexed path should fail").into()),
12843 Err(error) => error,
12844 };
12845 require_eq(
12846 &error.to_string().contains("src/main.rs"),
12847 &true,
12848 "stale path named in error",
12849 )?;
12850 Ok(())
12851 }
12852
12853 #[test]
12854 fn health_resolution_requires_active_finding_tuple() -> Result<(), Box<dyn Error>> {
12855 let mut store = AtlasStore::in_memory()?;
12856 store.replace_scan(&[test_file_node("src/main.rs", "hash")])?;
12857 let error = match store.resolve_health_finding(&HealthResolution {
12858 finding_id: "missing-id".to_string(),
12859 category: "duplicate-purpose".to_string(),
12860 path: "no/such/file.rs".to_string(),
12861 related_path: None,
12862 rationale: "typo".to_string(),
12863 }) {
12864 Ok(()) => {
12865 return Err(io::Error::other("nonexistent health finding should fail").into());
12866 }
12867 Err(error) => error,
12868 };
12869
12870 require_eq(
12871 &error.to_string().contains("not active"),
12872 &true,
12873 "inactive finding rejected",
12874 )?;
12875 Ok(())
12876 }
12877
12878 fn write_released_schema_eight_compatibility_fixture(
12880 db_path: &Path,
12881 root: &Path,
12882 ) -> Result<(), Box<dyn Error>> {
12883 write_schema_eight_compatibility_fixture(
12884 db_path,
12885 root,
12886 schema::create_released_schema_eight,
12887 )
12888 }
12889
12890 fn write_evolved_released_schema_eight_compatibility_fixture(
12892 db_path: &Path,
12893 root: &Path,
12894 ) -> Result<(), Box<dyn Error>> {
12895 write_schema_eight_compatibility_fixture(
12896 db_path,
12897 root,
12898 schema::create_evolved_released_schema_eight,
12899 )
12900 }
12901
12902 fn write_schema_eight_compatibility_fixture(
12904 db_path: &Path,
12905 root: &Path,
12906 create_schema: fn(&Connection) -> DbResult<()>,
12907 ) -> Result<(), Box<dyn Error>> {
12908 let connection = Connection::open(db_path)?;
12909 create_schema(&connection)?;
12910 schema::configure_writable(&connection)?;
12911 connection.execute_batch("BEGIN IMMEDIATE")?;
12912 let write_result = (|| -> DbResult<()> {
12913 set_metadata(
12914 &connection,
12915 PROJECT_ROOT_KEY,
12916 &normalize_native_path_display(root),
12917 )?;
12918 set_metadata(&connection, "custom_setting", "preserved")?;
12919 set_metadata(&connection, INDEX_PUBLICATION_STATE_KEY, "complete")?;
12920 set_metadata(
12921 &connection,
12922 INDEX_PUBLICATION_FINGERPRINT_KEY,
12923 "untrusted-contract",
12924 )?;
12925 set_metadata(&connection, INDEX_PUBLICATION_GENERATION_KEY, "7")?;
12926 connection.execute_batch(
12927 "
12928 INSERT INTO nodes(
12929 id, path, kind, parent_path, extension, language,
12930 size_bytes, mtime_ns, content_hash
12931 )
12932 VALUES(1, 'src/lib.rs', 'file', 'src', '.rs', 'rust', 12, 10, 'hash-legacy');
12933
12934 INSERT INTO purposes(node_id, purpose, source, status, updated_by)
12935 VALUES(1, 'Schema compatibility source', 'agent', 'approved', 'agent');
12936
12937 INSERT INTO summaries(node_id, summary_level, subject, summary)
12938 VALUES(1, 'node', '', 'released schema source');
12939
12940 INSERT INTO file_texts(path, content_hash, byte_count, line_count, content)
12941 VALUES('src/lib.rs', 'hash-legacy', 6, 1, 'legacy');
12942 ",
12943 )?;
12944 connection.execute(
12945 "
12946 INSERT INTO health_resolutions(
12947 finding_id, category, path, rationale
12948 )
12949 VALUES('schema-review', ?1, 'src/lib.rs', 'Reviewed schema fixture')
12950 ",
12951 [CATEGORY_DUPLICATE_PURPOSE],
12952 )?;
12953 record_released_schema_eight_usage_event(
12954 &connection,
12955 &usage_from_estimates(
12956 "schema-session",
12957 "summary",
12958 Some("src/lib.rs".to_string()),
12959 None,
12960 100,
12961 20,
12962 ),
12963 )
12964 })();
12965 match write_result {
12966 Ok(()) => connection.execute_batch("COMMIT")?,
12967 Err(error) => {
12968 if let Err(rollback) = connection.execute_batch("ROLLBACK") {
12969 return Err(DbError::TransactionRollback {
12970 operation: Box::new(error),
12971 rollback,
12972 }
12973 .into());
12974 }
12975 return Err(error.into());
12976 }
12977 }
12978 Ok(())
12979 }
12980
12981 fn write_schema_compatibility_fixture(
12983 db_path: &Path,
12984 root: &Path,
12985 schema_version: i64,
12986 label: &str,
12987 ) -> Result<(), Box<dyn Error>> {
12988 let mut store = AtlasStore::open(db_path)?;
12989 store.set_project_root(root)?;
12990 {
12991 let mut publication = store.begin_index_publication("untrusted-contract")?;
12992 write_test_projection(&mut publication, label)?;
12993 publication.complete()?;
12994 }
12995 store.set_purpose(
12996 "src/lib.rs",
12997 "Schema compatibility source",
12998 PurposeSource::Agent,
12999 )?;
13000 store.connection.execute(
13001 "
13002 INSERT INTO health_resolutions(
13003 finding_id,
13004 category,
13005 path,
13006 rationale
13007 )
13008 VALUES('schema-review', ?1, 'src/lib.rs', 'Reviewed schema fixture')
13009 ",
13010 [CATEGORY_DUPLICATE_PURPOSE],
13011 )?;
13012 store.record_usage(&usage_from_estimates(
13013 "schema-session",
13014 "summary",
13015 Some("src/lib.rs".to_string()),
13016 None,
13017 100,
13018 20,
13019 ))?;
13020 set_metadata(&store.connection, "custom_setting", "preserved")?;
13021 set_metadata(
13022 &store.connection,
13023 SCHEMA_VERSION_KEY,
13024 &schema_version.to_string(),
13025 )?;
13026 store
13027 .connection
13028 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
13029 Ok(())
13030 }
13031
13032 fn test_file_node(path: &str, hash: &str) -> Node {
13034 Node {
13035 path: path.to_string(),
13036 kind: NodeKind::File,
13037 parent_path: normalized_parent(path),
13038 extension: Some(".rs".to_string()),
13039 language: Some("rust".to_string()),
13040 size_bytes: Some(12),
13041 mtime_ns: Some(10),
13042 content_hash: Some(hash.to_string()),
13043 }
13044 }
13045
13046 fn batch_test_symbol(
13048 path: &str,
13049 name: &str,
13050 line_start: usize,
13051 documentation_bytes: usize,
13052 ) -> CodeSymbol {
13053 CodeSymbol {
13054 path: path.to_string(),
13055 language: Some("rust".to_string()),
13056 name: name.to_string(),
13057 kind: SymbolKind::Function,
13058 signature: format!("fn {name}()"),
13059 exported: false,
13060 documentation: Some("d".repeat(documentation_bytes)),
13061 line_start,
13062 line_end: line_start.saturating_add(1),
13063 parent: None,
13064 parser: ParserKind::TreeSitter,
13065 detail: Some("function_item".to_string()),
13066 }
13067 }
13068
13069 fn conditional_purpose_request(
13071 task: &str,
13072 candidate: &PurposeCurationCandidate,
13073 purpose: &str,
13074 ) -> PurposeConditionalApplyRequest {
13075 PurposeConditionalApplyRequest {
13076 task: task.to_string(),
13077 path: candidate.node.node.path.clone(),
13078 work_key: candidate.work_key.clone(),
13079 state_token: candidate.state_token.clone(),
13080 purpose: purpose.to_string(),
13081 }
13082 }
13083
13084 fn write_test_projection(store: &mut AtlasStore, label: &str) -> DbResult<()> {
13086 let path = "src/lib.rs";
13087 let hash = format!("hash-{label}");
13088 store.replace_scan(&[test_file_node(path, &hash)])?;
13089 store.replace_file_texts_for_paths(
13090 &[path.to_string()],
13091 &[IndexedFileText {
13092 path: path.to_string(),
13093 content_hash: Some(hash),
13094 byte_count: label.len(),
13095 line_count: 1,
13096 content: label.to_string(),
13097 }],
13098 )?;
13099 store.replace_symbol_graph(&SymbolGraph {
13100 path: path.to_string(),
13101 language: Some("rust".to_string()),
13102 parser: ParserKind::TreeSitter,
13103 symbols: vec![CodeSymbol {
13104 path: path.to_string(),
13105 language: Some("rust".to_string()),
13106 name: format!("{label}_symbol"),
13107 kind: SymbolKind::Function,
13108 signature: format!("fn {label}_symbol()"),
13109 exported: true,
13110 documentation: None,
13111 line_start: 1,
13112 line_end: 1,
13113 parent: None,
13114 parser: ParserKind::TreeSitter,
13115 detail: Some("function_item".to_string()),
13116 }],
13117 relations: vec![SymbolRelation {
13118 path: path.to_string(),
13119 source_name: format!("{label}_symbol"),
13120 target_name: format!("{label}_target"),
13121 kind: RelationKind::Calls,
13122 line: 1,
13123 context: format!("{label}_target();"),
13124 parser: ParserKind::TreeSitter,
13125 }],
13126 })?;
13127 store.set_node_summary(path, &format!("{label} summary"))?;
13128 Ok(())
13129 }
13130
13131 fn require_test_projection(
13133 store: &AtlasStore,
13134 generation: u64,
13135 label: &str,
13136 ) -> Result<(), Box<dyn Error>> {
13137 let path = "src/lib.rs";
13138 let publication = store
13139 .index_publication()?
13140 .ok_or_else(|| io::Error::other("publication missing"))?;
13141 require_eq(
13142 &publication.generation,
13143 &IndexGeneration::new(generation),
13144 "publication generation",
13145 )?;
13146 let node = store
13147 .load_node_by_path(path)?
13148 .ok_or_else(|| io::Error::other("projection node missing"))?;
13149 require_eq(
13150 &node.node.content_hash,
13151 &Some(format!("hash-{label}")),
13152 "projection node hash",
13153 )?;
13154 require_eq(
13155 &node.summary,
13156 &Some(format!("{label} summary")),
13157 "projection node summary",
13158 )?;
13159 let text = store
13160 .load_file_text(path)?
13161 .ok_or_else(|| io::Error::other("projection text missing"))?;
13162 require_eq(&text.content, &label.to_string(), "projection text")?;
13163 let symbols = store.load_symbols(Some(path), None, 10)?;
13164 require_eq(
13165 &symbols.first().map(|symbol| symbol.name.as_str()),
13166 &Some(format!("{label}_symbol")).as_deref(),
13167 "projection symbol",
13168 )?;
13169 let relations = store.load_symbol_relations(Some(path), None, 10)?;
13170 require_eq(
13171 &relations
13172 .first()
13173 .map(|relation| relation.target_name.as_str()),
13174 &Some(format!("{label}_target")).as_deref(),
13175 "projection relation",
13176 )?;
13177 let metadata = store
13178 .load_source_parse_metadata(path)?
13179 .ok_or_else(|| io::Error::other("projection parse metadata missing"))?;
13180 require_eq(&metadata.symbol_count, &1, "projection symbol metadata")?;
13181 require_eq(&metadata.relation_count, &1, "projection relation metadata")?;
13182 Ok(())
13183 }
13184
13185 fn require_writable_connection_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
13187 let foreign_keys =
13188 connection.pragma_query_value(None, "foreign_keys", |row| row.get::<_, i64>(0))?;
13189 require_eq(&foreign_keys, &1, "writable foreign-key enforcement")?;
13190 require_wal_profile(connection)?;
13191 let synchronous =
13192 connection.pragma_query_value(None, "synchronous", |row| row.get::<_, i64>(0))?;
13193 require_eq(&synchronous, &2, "writable FULL synchronous mode")?;
13194 require_busy_timeout(connection)
13195 }
13196
13197 fn require_read_connection_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
13199 let query_only =
13200 connection.pragma_query_value(None, "query_only", |row| row.get::<_, i64>(0))?;
13201 require_eq(&query_only, &1, "read query-only mode")?;
13202 require_wal_profile(connection)?;
13203 require_busy_timeout(connection)
13204 }
13205
13206 fn require_wal_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
13208 let journal_mode =
13209 connection.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))?;
13210 require_eq(
13211 &journal_mode.to_ascii_lowercase(),
13212 &"wal".to_string(),
13213 "WAL journal mode",
13214 )
13215 }
13216
13217 fn require_busy_timeout(connection: &Connection) -> Result<(), Box<dyn Error>> {
13219 let busy_timeout =
13220 connection.pragma_query_value(None, "busy_timeout", |row| row.get::<_, u64>(0))?;
13221 require_eq(
13222 &u128::from(busy_timeout),
13223 &SQLITE_BUSY_TIMEOUT.as_millis(),
13224 "bounded connection busy timeout",
13225 )
13226 }
13227
13228 fn test_folder_node(path: &str) -> Node {
13230 Node {
13231 path: path.to_string(),
13232 kind: NodeKind::Folder,
13233 parent_path: normalized_parent(path),
13234 extension: None,
13235 language: None,
13236 size_bytes: None,
13237 mtime_ns: Some(10),
13238 content_hash: None,
13239 }
13240 }
13241
13242 fn low_query() -> HealthQuery {
13244 HealthQuery {
13245 start_index: 0,
13246 limit: 20,
13247 category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
13248 severity: Some(Severity::Warning),
13249 path_prefix: Some(".".to_string()),
13250 summary_only: false,
13251 scope: HealthScope::purpose_default(),
13252 }
13253 }
13254
13255 fn health_paths(page: &HealthFindingsPage) -> Vec<&str> {
13257 page.findings
13258 .iter()
13259 .map(|finding| finding.path.as_str())
13260 .collect()
13261 }
13262
13263 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
13265 if condition {
13266 Ok(())
13267 } else {
13268 Err(io::Error::other(message).into())
13269 }
13270 }
13271
13272 fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
13274 where
13275 T: Debug + PartialEq,
13276 {
13277 if actual == expected {
13278 Ok(())
13279 } else {
13280 Err(io::Error::other(format!(
13281 "{label} mismatch: expected {expected:?}, got {actual:?}"
13282 ))
13283 .into())
13284 }
13285 }
13286}