1mod content_classification;
4mod derived_snapshot;
5mod diagnostics;
6mod hydration;
7mod legacy_relations;
8mod project_identity;
9mod repository_graph;
10mod schema;
11mod sqlite_profile;
12mod telemetry;
13mod worktree_registry;
14
15pub use content_classification::{
16 FileContentClassification, FileContentClassificationPage,
17 MAX_FILE_CONTENT_CLASSIFICATION_PAGE_ROWS, MAX_FILE_CONTENT_CLASSIFICATION_PATHS,
18};
19pub use derived_snapshot::{
20 DerivedGraphSnapshot, DerivedGraphSnapshotImport, DerivedGraphSnapshotMetadata,
21 DerivedSnapshotContent, MAX_DERIVED_SNAPSHOT_JSON_BYTES,
22};
23pub use diagnostics::{
24 DatabaseCoverageSample, DatabaseCoverageSummary, DatabaseCoverageTotalState,
25 DatabaseFilesystemSupport, DatabaseOperatingProfileReport, DatabasePublicationContractState,
26 DatabasePublicationReport, DatabaseSchemaCompatibility, DatabaseSchemaReport,
27 DatabaseSettingsReport, SqliteCompileOptionsIdentity, SqliteRuntimeReport,
28 database_settings_report,
29};
30pub use hydration::{
31 PreparedWorktreeHydrationCandidate, WorktreeHydrationActivation, WorktreeHydrationCandidate,
32};
33pub use legacy_relations::{
34 LegacyRelationRead, LegacyRelationReadBudget, LegacyRelationReadLimit, LegacyRelationReadWork,
35};
36pub use project_identity::{ProjectRootTransition, ProjectRootTransitionResult};
37pub use repository_graph::{
38 MAX_REPOSITORY_GRAPH_FRONTIER, RepositoryAffectedSourceFootprint, RepositoryCoverageQuery,
39 RepositoryCoverageRow, RepositoryGraphAdjacencyContinuation, RepositoryGraphAdjacencyPage,
40 RepositoryGraphAdjacencyReadPage, RepositoryGraphAdjacencyRow,
41 RepositoryGraphClassifiedRelationRow, RepositoryGraphDirection, RepositoryGraphPage,
42 RepositoryGraphReadBatch, RepositoryGraphReadBudget, RepositoryGraphReadPage,
43 RepositoryGraphReadPages, RepositoryGraphReadWork, RepositoryGraphRelationQuery,
44 RepositoryGraphRelationRow, RepositoryGraphStagingGuard, RepositoryNavigationConnections,
45 RepositoryNavigationNode, RepositoryResolutionCandidate, RepositoryResolutionReadPage,
46};
47pub use sqlite_profile::validate_database_location;
48pub use telemetry::{
49 PlannerStatisticsPolicy, PlannerStatisticsState, SpillCleanupState, TelemetryCheckpointState,
50 TelemetryRetentionPolicy, TelemetryRetentionState, WorktreeUsageSnapshot,
51 WorktreeUsageSyncState,
52};
53pub use worktree_registry::{
54 ActiveWorktreeRegistrationGuard, MAIN_WORKTREE_ALIAS, MAX_WORKTREE_ALIAS_BYTES, WorktreeAlias,
55 WorktreeRegistration, WorktreeRegistrationState,
56};
57
58use blake3::Hasher;
59use projectatlas_core::graph::{GraphContractError, ProjectInstanceId};
60use projectatlas_core::health::{
61 CATEGORY_DUPLICATE_PURPOSE, CATEGORY_MISSING_PURPOSE, CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
62 CATEGORY_REPEATED_TEMPORARY_FOLDER, CATEGORY_STALE_PURPOSE, CATEGORY_SUGGESTED_PURPOSE_REVIEW,
63 HealthFinding, MESSAGE_MISSING_PURPOSE, MESSAGE_PURPOSE_AGENT_REVIEW_REQUIRED,
64 MESSAGE_STALE_PURPOSE, MESSAGE_SUGGESTED_PURPOSE_REVIEW, RECOMMENDATION_DUPLICATE_PURPOSE,
65 RECOMMENDATION_MISSING_PURPOSE_QUEUE, RECOMMENDATION_PURPOSE_AGENT_REVIEW_REQUIRED,
66 RECOMMENDATION_REPEATED_TEMPORARY_FOLDER, RECOMMENDATION_STALE_PURPOSE,
67 RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW_QUEUE, STRUCTURAL_HEALTH_CATEGORIES, Severity,
68 TEMP_FOLDER_BUCKETS, finding_id,
69};
70use projectatlas_core::language::{ContentClassification, ContentSelection};
71use projectatlas_core::symbols::{
72 CodeSymbol, ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
73 SymbolRelation, SymbolSourceSelector,
74};
75use projectatlas_core::telemetry::{
76 TelemetryContractError, TokenOverview, TokenTrendReport, TokenTrendWindow, UsageEvent,
77 UsageInstanceId, UsageInstanceOwner,
78};
79use projectatlas_core::{
80 AGENT_REVIEWED_SOURCE_VALUES, CanonicalProjectRoot, CoreError, HIGH_IMPACT_FILE_NAMES,
81 HIGH_IMPACT_PATH_PREFIXES, HIGH_IMPACT_PATH_SEGMENTS, IndexGeneration, IndexWorkControl,
82 IndexWorkFailure, IndexWorkStage, IndexedNode, LEGACY_HUMAN_PURPOSE_SOURCE, Node, NodeKind,
83 Overview, Purpose, PurposeSource, PurposeStatus, normalize_native_path_display,
84 normalize_repo_path_prefix,
85};
86use rusqlite::types::Value;
87use rusqlite::{
88 Connection, ErrorCode, OpenFlags, OptionalExtension, TransactionBehavior, params,
89 params_from_iter,
90};
91use serde::{Deserialize, Serialize};
92use std::cell::{Cell, RefCell};
93use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
94use std::num::{ParseIntError, TryFromIntError};
95use std::ops::{Deref, DerefMut};
96use std::path::{Path, PathBuf};
97use std::time::Duration;
98use thiserror::Error;
99
100#[cfg(all(test, windows))]
101use schema::PREVIOUS_SCHEMA_VERSION;
102use schema::{
103 FILE_TEXT_FTS_PROJECTION_REVISION_KEY, FILE_TEXT_FTS_SOURCE_REVISION_KEY,
104 INDEX_PUBLICATION_FINGERPRINT_KEY, INDEX_PUBLICATION_GENERATION_KEY,
105 INDEX_PUBLICATION_STATE_KEY, PROJECT_ROOT_KEY, SchemaState,
106};
107#[cfg(test)]
108use schema::{SCHEMA_VERSION, SCHEMA_VERSION_KEY, sqlite_sidecar_path};
109use sqlite_profile::{
110 DatabaseLocation, JournalModePolicy, SQLITE_BUSY_TIMEOUT, open_writable_connection,
111};
112
113const MAX_SYMBOL_SEARCH_SUMMARY_CHARS: usize = 16_000;
115const SQLITE_PUBLICATION_ACQUIRE_TIMEOUT: Duration = Duration::ZERO;
117const SQLITE_TELEMETRY_BUSY_TIMEOUT: Duration = Duration::from_millis(25);
119pub const MAX_PURPOSE_CURATION_BATCH_ROWS: usize = 200;
121pub const CURRENT_SCHEMA_VERSION: i64 = schema::SCHEMA_VERSION;
123pub const MAX_FILE_TEXT_FTS_CANDIDATES: usize = 4_096;
125const FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL: &str = "
127 SELECT text.path, text.content_hash, text.byte_count, text.line_count,
128 classification.classification
129 FROM file_texts AS text
130 LEFT JOIN file_content_classifications AS classification
131 ON classification.path = text.path
132 WHERE text.path = ?1 OR (text.path >= ?2 AND text.path < ?3)
133 ORDER BY text.path
134";
135const FILE_TEXT_FALLBACK_ALL_METADATA_SQL: &str = "
137 SELECT text.path, text.content_hash, text.byte_count, text.line_count,
138 classification.classification
139 FROM file_texts AS text
140 LEFT JOIN file_content_classifications AS classification
141 ON classification.path = text.path
142 ORDER BY text.path
143";
144const FILE_TEXT_FALLBACK_CONTENT_SQL: &str = "SELECT content FROM file_texts WHERE path = ?1";
146const MAX_PURPOSE_CURATION_TASK_BYTES: usize = 256;
148const PURPOSE_CURATION_ITEM_KEY_DOMAIN: &str = "projectatlas:purpose-curation:item:v1";
150const PURPOSE_CURATION_STATE_TOKEN_DOMAIN: &str = "projectatlas:purpose-curation:state:v1";
152const PURPOSE_CURATION_BATCH_KEY_DOMAIN: &str = "projectatlas:purpose-curation:batch:v1";
154const AUTHORED_PURPOSE_REVISION_KEY: &str = "purpose.authored_revision";
156fn writable_open_flags(state: SchemaState, database_exists: bool) -> OpenFlags {
158 match (state, database_exists) {
159 (SchemaState::Fresh, false) => {
160 OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE
161 }
162 (SchemaState::Fresh | SchemaState::Current | SchemaState::UpgradeRequired, true)
163 | (SchemaState::Current | SchemaState::UpgradeRequired, false) => {
164 OpenFlags::SQLITE_OPEN_READ_WRITE
165 }
166 }
167}
168
169const fn writable_journal_policy(state: SchemaState) -> JournalModePolicy {
171 match state {
172 SchemaState::Current => JournalModePolicy::RequireWal,
173 SchemaState::Fresh | SchemaState::UpgradeRequired => JournalModePolicy::EnsureWal,
174 }
175}
176
177#[derive(Debug, Error)]
179pub enum DbError {
180 #[error("sqlite error: {0}")]
182 Sqlite(#[from] rusqlite::Error),
183 #[error(
185 "legacy relation read reached {limit:?}; use view detailed with an exact file anchor and continuation"
186 )]
187 LegacyRelationReadLimit {
188 limit: LegacyRelationReadLimit,
190 work: LegacyRelationReadWork,
192 },
193 #[error(
195 "database path {path:?} is on an unsupported filesystem (mount: {mount_point:?}, type: {filesystem_type:?}); live SQLite WAL requires supported local storage"
196 )]
197 DatabaseFilesystemUnsupported {
198 path: PathBuf,
200 mount_point: Option<PathBuf>,
202 filesystem_type: Option<String>,
204 },
205 #[error(
207 "database path {path:?} has uncertain filesystem placement (mount: {mount_point:?}, type: {filesystem_type:?}): {reason}"
208 )]
209 DatabaseFilesystemUncertain {
210 path: PathBuf,
212 mount_point: Option<PathBuf>,
214 filesystem_type: Option<String>,
216 reason: String,
218 },
219 #[error("SQLite operating profile mismatch for {setting}: expected {expected}, found {found}")]
221 DatabaseOperatingProfile {
222 setting: &'static str,
224 expected: String,
226 found: String,
228 },
229 #[error("repository graph contract error: {0}")]
231 GraphContract(#[from] GraphContractError),
232 #[error("telemetry contract error: {0}")]
234 TelemetryContract(#[from] TelemetryContractError),
235 #[error("invalid worktree alias {alias:?}: {reason}")]
237 InvalidWorktreeAlias {
238 alias: String,
240 reason: &'static str,
242 },
243 #[error("active worktree registration {alias:?} was not found")]
245 WorktreeRegistrationNotFound {
246 alias: String,
248 },
249 #[error("worktree registration {field} conflicts with active value {value:?}")]
251 WorktreeRegistrationConflict {
252 field: &'static str,
254 value: String,
256 },
257 #[error(
259 "worktree registration migration found duplicate active native {field} identities in registrations {first_registration_id} and {second_registration_id}; repair one legacy row and retry"
260 )]
261 WorktreeRegistrationMigrationConflict {
262 field: &'static str,
264 first_registration_id: i64,
266 second_registration_id: i64,
268 },
269 #[error(
271 "worktree registration migration cannot establish native {field} identity for registration {registration_id}; repair the legacy row and retry"
272 )]
273 WorktreeRegistrationMigrationIdentityUnavailable {
274 field: &'static str,
276 registration_id: i64,
278 },
279 #[error("worktree registration capacity {limit} is exhausted")]
281 WorktreeRegistrationCapacity {
282 limit: usize,
284 },
285 #[error("invalid worktree registration {field} path {path:?}")]
287 InvalidWorktreeRegistrationPath {
288 field: &'static str,
290 path: String,
292 },
293 #[error("invalid worktree registration row: {reason}")]
295 WorktreeRegistrationRow {
296 reason: &'static str,
298 },
299 #[error("worktree telemetry project identity does not match registration {registration_id}")]
301 WorktreeTelemetryProjectMismatch {
302 registration_id: i64,
304 },
305 #[error("worktree telemetry snapshot {resource} exceeded limit {limit}: observed {observed}")]
307 WorktreeTelemetrySnapshotLimit {
308 resource: &'static str,
310 limit: usize,
312 observed: usize,
314 },
315 #[error("telemetry runtime instance has a conflicting worktree origin")]
317 WorktreeTelemetryOriginConflict,
318 #[error("invalid worktree hydration request: {reason}")]
320 WorktreeHydrationInvalid {
321 reason: &'static str,
323 },
324 #[error("worktree hydration destination already exists: {path:?}")]
326 WorktreeHydrationDestinationExists {
327 path: PathBuf,
329 },
330 #[error("worktree hydration I/O failed for {path:?}: {source}")]
332 WorktreeHydrationIo {
333 path: PathBuf,
335 #[source]
337 source: std::io::Error,
338 },
339 #[error("worktree hydration backup remained busy after {attempts} attempts")]
341 WorktreeHydrationBackupBusy {
342 attempts: usize,
344 },
345 #[error(
347 "worktree hydration candidate is not reconciled: baseline generation {baseline}, found {found}"
348 )]
349 WorktreeHydrationNotReconciled {
350 baseline: IndexGeneration,
352 found: IndexGeneration,
354 },
355 #[error(
357 "repository graph project identity {found} does not match selected identity {expected}"
358 )]
359 GraphProjectIdentityMismatch {
360 expected: String,
362 found: String,
364 },
365 #[error("repository graph is unavailable without a complete published generation")]
367 GraphPublicationUnavailable,
368 #[error("invalid {table} row: {reason}")]
370 GraphRowShape {
371 table: &'static str,
373 reason: &'static str,
375 },
376 #[error("invalid derived graph snapshot: {reason}")]
378 DerivedSnapshotInvalid {
379 reason: &'static str,
381 },
382 #[error(
384 "derived graph snapshot {resource} exceeds the limit: found {found}, maximum {maximum}"
385 )]
386 DerivedSnapshotLimit {
387 resource: &'static str,
389 found: u64,
391 maximum: u64,
393 },
394 #[error("derived graph snapshot I/O failed for {path:?}: {source}")]
396 DerivedSnapshotIo {
397 path: PathBuf,
399 #[source]
401 source: std::io::Error,
402 },
403 #[error("derived graph snapshot JSON is invalid: {0}")]
405 DerivedSnapshotJson(#[from] serde_json::Error),
406 #[error("invalid persisted symbol graph for {path:?}: {reason}")]
408 SymbolGraphRowShape {
409 path: String,
411 reason: &'static str,
413 },
414 #[error("resolution-key collision in {domain} for digest {digest:?}")]
416 ResolutionKeyCollision {
417 domain: &'static str,
419 digest: [u8; 32],
421 },
422 #[error("invalid {field} blob length {found}; expected {expected}")]
424 InvalidBlobLength {
425 field: &'static str,
427 expected: usize,
429 found: usize,
431 },
432 #[error("graph count for {field} exceeds SQLite integer range: {value}")]
434 GraphCountOverflow {
435 field: &'static str,
437 value: u64,
439 },
440 #[error("unsupported schema version {found}, expected {expected}")]
442 SchemaVersion {
443 found: i64,
445 expected: i64,
447 },
448 #[error("existing database is missing schema_version metadata")]
450 SchemaVersionMissing,
451 #[error("database initialization is required before this operation")]
453 InitializationRequired,
454 #[error("incompatible schema object {object:?}: expected {expected}, found {found}")]
456 SchemaShape {
457 object: String,
459 expected: String,
461 found: String,
463 },
464 #[error("database integrity check failed: {message}")]
466 IntegrityCheck {
467 message: String,
469 },
470 #[error("schema migration did not reach expected version {expected}")]
472 SchemaPostcondition {
473 expected: i64,
475 },
476 #[error("existing database is missing project_root metadata")]
478 ProjectRootMissing,
479 #[error("database project root {found:?} does not match selected root {expected:?}")]
481 ProjectRootMismatch {
482 expected: String,
484 found: String,
486 identities: Option<Box<ProjectRootMismatchIdentities>>,
488 },
489 #[error("project-root identity error: {0}")]
491 ProjectRootIdentity(#[from] CoreError),
492 #[error("bound project database is missing canonical project-root identity")]
494 ProjectRootIdentityMissing,
495 #[error(
497 "legacy root adoption requires an intact schema-19 database at the selected root's .projectatlas/projectatlas.db, without native root identity"
498 )]
499 LegacyRootAdoptionUnavailable,
500 #[error(
502 "legacy root adoption committed, but the selected database location changed or could not be verified; stop recovery and preserve both database locations and their WAL/SHM sidecars: {source}"
503 )]
504 LegacyRootAdoptionCommittedLocationChanged {
505 #[source]
507 source: Box<DbError>,
508 },
509 #[error("invalid project root transition destination {root:?}: {source}")]
511 ProjectRootDestinationInvalid {
512 root: String,
514 #[source]
516 source: std::io::Error,
517 },
518 #[error("project root transition requires an existing bound root")]
520 ProjectRootTransitionRequiresExistingRoot,
521 #[error("project root move destination {root:?} matches the existing root")]
523 ProjectRootTransitionRequiresDifferentRoot {
524 root: String,
526 },
527 #[error("project root {root:?} still exists; use detach for an independent copy")]
529 ProjectRootStillPresent {
530 root: String,
532 },
533 #[error("cannot prove project root {root:?} is absent: {source}")]
535 ProjectRootAbsenceUncertain {
536 root: String,
538 #[source]
540 source: std::io::Error,
541 },
542 #[error(
544 "project root transition state changed: root {expected_root:?} -> {found_root:?}, identity {expected_identity:?} -> {found_identity:?}"
545 )]
546 ProjectRootTransitionChanged {
547 expected_root: Option<String>,
549 found_root: Option<String>,
551 expected_identity: Option<String>,
553 found_identity: Option<String>,
555 },
556 #[error("bound project database is missing project instance identity")]
558 ProjectInstanceIdentityMissing,
559 #[error("failed to generate a distinct nonzero project instance identity")]
561 ProjectInstanceIdentityGenerationFailed,
562 #[error("{operation}; rollback also failed: {rollback}")]
564 TransactionRollback {
565 #[source]
567 operation: Box<DbError>,
568 rollback: rusqlite::Error,
570 },
571 #[error(
573 "publication writer acquisition failed: {operation}; restoring the standard busy policy also failed: {restore}"
574 )]
575 PublicationAcquirePolicyRestore {
576 #[source]
578 operation: Box<rusqlite::Error>,
579 restore: Box<rusqlite::Error>,
581 },
582 #[error("invalid {field} value in database: {value}")]
584 InvalidEnum {
585 field: &'static str,
587 value: String,
589 },
590 #[error("literal token cannot use FTS acceleration: {reason}")]
592 FileTextFtsTokenUnsafe {
593 reason: &'static str,
595 },
596 #[error("FTS candidate request {requested} exceeds the maximum {maximum}")]
598 FileTextFtsCandidateLimit {
599 requested: usize,
601 maximum: usize,
603 },
604 #[error("invalid FTS BM25 score for {path:?}")]
606 FileTextFtsScoreInvalid {
607 path: String,
609 },
610 #[error("invalid FTS synchronization state: {reason}")]
612 FileTextFtsStateInvalid {
613 reason: &'static str,
615 },
616 #[error("file text {path:?} has invalid {field}: recorded {recorded}, actual {actual}")]
618 FileTextMetadataMismatch {
619 path: String,
621 field: &'static str,
623 recorded: usize,
625 actual: usize,
627 },
628 #[error("{0}")]
630 IndexWork(#[from] IndexWorkFailure),
631 #[error("invalid count for {field}: {value}")]
633 InvalidCount {
634 field: &'static str,
636 value: i64,
638 source: TryFromIntError,
640 },
641 #[error("invalid integer metadata for {field}: {value:?}: {source}")]
643 InvalidInteger {
644 field: &'static str,
646 value: String,
648 source: ParseIntError,
650 },
651 #[error("integer metadata for {field} overflowed at {value}")]
653 IntegerMetadataOverflow {
654 field: &'static str,
656 value: u64,
658 },
659 #[error("path {path:?} is not indexed; run scan, fix the path, or choose an indexed path")]
661 PathNotIndexed {
662 path: String,
664 },
665 #[error("file classification batch requested {requested} paths; maximum is {maximum}")]
667 FileContentClassificationBatchTooLarge {
668 requested: usize,
670 maximum: usize,
672 },
673 #[error("file classification batch repeats path {path:?}")]
675 FileContentClassificationDuplicatePath {
676 path: String,
678 },
679 #[error("current file {path:?} has no content classification")]
681 FileContentClassificationMissing {
682 path: String,
684 },
685 #[error("content classification for {path:?} does not belong to a current file")]
687 FileContentClassificationNotCurrent {
688 path: String,
690 },
691 #[error("file classification page requested {requested} rows; maximum is {maximum}")]
693 FileContentClassificationLimit {
694 requested: u32,
696 maximum: u32,
698 },
699 #[error("invalid purpose-curation task label: {reason}")]
701 PurposeCurationTaskInvalid {
702 reason: &'static str,
704 },
705 #[error("purpose-curation batch requested {requested} paths; maximum is {maximum}")]
707 PurposeCurationBatchTooLarge {
708 requested: usize,
710 maximum: usize,
712 },
713 #[error(
715 "health finding {finding_id:?} with category {category:?} and path {path:?} is not active; run health-check and use an exact finding id/path/category"
716 )]
717 HealthFindingNotActive {
718 finding_id: String,
720 category: String,
722 path: String,
724 },
725 #[error("index publication contract changed during projection refresh")]
727 PublicationContractChanged,
728 #[error("index publication base generation changed: expected {expected}, found {found}")]
730 PublicationBaseGenerationChanged {
731 expected: IndexGeneration,
733 found: IndexGeneration,
735 },
736 #[error("index publication generation overflowed")]
738 PublicationGenerationOverflow,
739 #[error("index publication cannot complete before scan replacement finishes")]
741 ScanReplacementIncomplete,
742 #[error("index read snapshot is already active on this store")]
744 IndexReadSnapshotActive,
745 #[error("read-only store has no database path for telemetry persistence")]
747 TelemetryPathUnavailable,
748 #[error("telemetry field {field} uses {bytes} UTF-8 bytes; limit is {limit}")]
750 TelemetryFieldTooLarge {
751 field: &'static str,
753 bytes: usize,
755 limit: usize,
757 },
758 #[error("invalid telemetry retention limit for {field}: {value}")]
760 TelemetryLimitInvalid {
761 field: &'static str,
763 value: usize,
765 },
766 #[error("telemetry integer overflow for {field}")]
768 TelemetryIntegerOverflow {
769 field: &'static str,
771 },
772 #[error("telemetry usage instance is sealed or expired")]
774 TelemetryInstanceInactive,
775 #[error("telemetry runtime identity is unavailable")]
777 TelemetryIdentityUnavailable,
778 #[error("telemetry runtime identity does not match its retained owner or caller label")]
780 TelemetryInstanceMismatch,
781 #[error("telemetry active-instance capacity is exhausted")]
783 TelemetryInstanceCapacity,
784 #[error("telemetry modeled-baseline capacity is exhausted")]
786 TelemetryBaselineCapacity,
787 #[error("telemetry modeled-baseline key collided with different witness material")]
789 TelemetryBaselineCollision,
790}
791
792impl DbError {
793 #[must_use]
795 pub fn supported_schema_migration(&self) -> Option<(i64, i64, u32)> {
796 match self {
797 Self::SchemaVersion { found, expected } => schema::migration_steps_remaining(*found)
798 .filter(|steps| *steps > 0)
799 .map(|steps| (*found, *expected, steps)),
800 _ => None,
801 }
802 }
803
804 #[must_use]
806 pub fn unsupported_schema_version(&self) -> Option<(i64, i64)> {
807 match self {
808 Self::SchemaVersion { found, expected }
809 if schema::migration_steps_remaining(*found).is_none() =>
810 {
811 Some((*found, *expected))
812 }
813 _ => None,
814 }
815 }
816
817 #[must_use]
820 pub fn is_write_unavailable(&self) -> bool {
821 match self {
822 Self::Sqlite(error) => matches!(
823 error.sqlite_error_code(),
824 Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked | ErrorCode::ReadOnly)
825 ),
826 _ => false,
827 }
828 }
829}
830
831#[derive(Clone, Debug, Eq, PartialEq)]
833pub struct ProjectRootMismatchIdentities {
834 pub expected: CanonicalProjectRoot,
836 pub found: CanonicalProjectRoot,
838}
839
840pub type DbResult<T> = Result<T, DbError>;
842
843pub const MAX_SYMBOL_BATCH_PATHS: u32 = 64;
845pub const MAX_SYMBOL_BATCH_ROWS: u32 = 4_096;
847pub const MAX_SYMBOL_BATCH_DECODED_BYTES: u64 = 4 * 1_024 * 1_024;
849const SYMBOL_BATCH_BIND_PATHS: usize = 48;
851
852#[derive(Clone, Debug, Eq, PartialEq)]
854pub struct ClassifiedSymbol {
855 pub symbol: CodeSymbol,
857 pub classification: ContentClassification,
859}
860
861#[derive(Clone, Copy, Debug, Eq, PartialEq)]
863pub struct SymbolBatchReadBudget {
864 paths: u32,
866 rows: u32,
868 decoded_bytes: u64,
870}
871
872impl SymbolBatchReadBudget {
873 pub fn new(paths: u32, rows: u32, decoded_bytes: u64) -> DbResult<Self> {
879 if paths == 0
880 || paths > MAX_SYMBOL_BATCH_PATHS
881 || rows == 0
882 || rows > MAX_SYMBOL_BATCH_ROWS
883 || decoded_bytes == 0
884 || decoded_bytes > MAX_SYMBOL_BATCH_DECODED_BYTES
885 {
886 return Err(GraphContractError::InvalidLimits {
887 reason: "symbol batch limit is zero or above the product ceiling",
888 }
889 .into());
890 }
891 Ok(Self {
892 paths,
893 rows,
894 decoded_bytes,
895 })
896 }
897
898 #[must_use]
900 pub const fn paths(self) -> u32 {
901 self.paths
902 }
903
904 #[must_use]
906 pub const fn rows(self) -> u32 {
907 self.rows
908 }
909
910 #[must_use]
912 pub const fn decoded_bytes(self) -> u64 {
913 self.decoded_bytes
914 }
915}
916
917#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
919pub struct SymbolBatchReadWork {
920 pub requested_paths: u32,
922 pub returned_rows: u32,
924 pub decoded_bytes: u64,
926}
927
928#[derive(Clone, Debug, Default, Eq, PartialEq)]
930pub struct SymbolBatchRead {
931 pub rows: Vec<CodeSymbol>,
933 pub truncated: bool,
935 pub reached_limit: Option<SymbolBatchReadLimit>,
937 pub work: SymbolBatchReadWork,
939}
940
941#[derive(Clone, Copy, Debug, Eq, PartialEq)]
943pub enum SymbolBatchReadLimit {
944 Paths,
946 Rows,
948 DecodedBytes,
950}
951
952#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
954#[serde(rename_all = "snake_case")]
955pub enum IndexPublicationState {
956 Updating,
958 Complete,
960}
961
962impl IndexPublicationState {
963 const fn as_str(self) -> &'static str {
965 match self {
966 Self::Updating => "updating",
967 Self::Complete => "complete",
968 }
969 }
970
971 fn from_db(value: String) -> DbResult<Self> {
973 match value.as_str() {
974 "updating" => Ok(Self::Updating),
975 "complete" => Ok(Self::Complete),
976 _ => Err(DbError::InvalidEnum {
977 field: INDEX_PUBLICATION_STATE_KEY,
978 value,
979 }),
980 }
981 }
982}
983
984#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
986pub struct IndexPublication {
987 pub state: IndexPublicationState,
989 pub contract_fingerprint: Option<String>,
991 pub generation: IndexGeneration,
993}
994
995enum PublicationContract {
997 Full(String),
999 Projection(String),
1001}
1002
1003pub struct IndexPublicationGuard<'store> {
1005 store: &'store mut AtlasStore,
1007 contract: PublicationContract,
1009 previous_generation: IndexGeneration,
1011 scan_replacement_pending: bool,
1013 active: bool,
1015}
1016
1017pub struct AtlasStore {
1019 connection: Connection,
1021 read_snapshot_active: Cell<bool>,
1023 database_path: Option<PathBuf>,
1025 database_location: Option<DatabaseLocation>,
1027 read_only: bool,
1029 validated_project_root: Option<String>,
1031 validated_project_root_identity: Option<CanonicalProjectRoot>,
1033 validated_project_instance_id: Option<ProjectInstanceId>,
1035 library_usage_instances: RefCell<HashMap<String, Option<UsageInstanceId>>>,
1037}
1038
1039pub struct PurposeMutationTransaction<'connection> {
1044 transaction: rusqlite::Transaction<'connection>,
1046}
1047
1048impl PurposeMutationTransaction<'_> {
1049 pub fn commit(self) -> DbResult<()> {
1055 self.transaction.commit().map_err(Into::into)
1056 }
1057
1058 pub fn rollback(self) -> DbResult<()> {
1064 self.transaction.rollback().map_err(Into::into)
1065 }
1066}
1067
1068#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1070pub struct CapturedProjectBinding {
1071 pub project_instance_id: ProjectInstanceId,
1073 pub project_root: Option<String>,
1079 #[serde(skip)]
1081 pub project_root_identity: CanonicalProjectRoot,
1082}
1083
1084#[derive(Clone, Debug, Eq, PartialEq)]
1086pub struct StoredImportRelation {
1087 pub path: String,
1089 pub source_name: String,
1091 pub target_name: String,
1093 pub line: usize,
1095}
1096
1097#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1099pub struct PurposeCurationCandidate {
1100 pub node: IndexedNode,
1102 pub work_key: String,
1104 pub state_token: String,
1106}
1107
1108#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1110pub struct PurposeCurationBatch {
1111 pub project_instance_id: ProjectInstanceId,
1113 pub active_generation: IndexGeneration,
1115 pub task: String,
1117 pub work_key: String,
1119 pub items: Vec<PurposeCurationCandidate>,
1121}
1122
1123#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1125#[serde(rename_all = "snake_case")]
1126pub enum PurposeConditionalApplyState {
1127 Applied,
1129 Stale,
1131 Accepted,
1133 PathUnavailable,
1135}
1136
1137#[derive(Clone, Debug, Eq, PartialEq)]
1139pub struct PurposeConditionalApplyRequest {
1140 pub task: String,
1142 pub path: String,
1144 pub work_key: String,
1146 pub state_token: String,
1148 pub purpose: String,
1150}
1151
1152#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1154pub struct PurposeConditionalApplyResult {
1155 pub path: String,
1157 pub state: PurposeConditionalApplyState,
1159 pub current_purpose: Option<Purpose>,
1161}
1162
1163#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1165enum ProjectIdentityRequirement {
1166 Required,
1168 TransitionOwned,
1170}
1171
1172#[derive(Clone, Copy, Eq, PartialEq)]
1174enum StoreInitializationPolicy {
1175 AllowFresh,
1177 ExistingSchema,
1179}
1180
1181impl ProjectIdentityRequirement {
1182 const fn is_required(self) -> bool {
1184 matches!(self, Self::Required)
1185 }
1186}
1187
1188impl Deref for IndexPublicationGuard<'_> {
1189 type Target = AtlasStore;
1190
1191 fn deref(&self) -> &Self::Target {
1192 self.store
1193 }
1194}
1195
1196impl DerefMut for IndexPublicationGuard<'_> {
1197 fn deref_mut(&mut self) -> &mut Self::Target {
1198 self.store
1199 }
1200}
1201
1202impl IndexPublicationGuard<'_> {
1203 pub fn begin_scan_replacement(&mut self) -> DbResult<()> {
1213 mark_all_scan_nodes_absent(&self.store.connection)?;
1214 self.scan_replacement_pending = true;
1215 Ok(())
1216 }
1217
1218 pub fn upsert_scan_node_batch(&mut self, nodes: &[Node]) -> DbResult<()> {
1224 upsert_nodes(&self.store.connection, nodes)
1225 }
1226
1227 pub fn finish_scan_replacement(&mut self) -> DbResult<()> {
1233 delete_absent_scan_projections(&self.store.connection)?;
1234 content_classification::validate_complete_file_content_classifications(
1235 &self.store.connection,
1236 )?;
1237 self.scan_replacement_pending = false;
1238 Ok(())
1239 }
1240
1241 pub fn complete(mut self) -> DbResult<()> {
1249 if self.scan_replacement_pending {
1250 return Err(DbError::ScanReplacementIncomplete);
1251 }
1252 content_classification::validate_complete_file_content_classifications(
1253 &self.store.connection,
1254 )?;
1255 repository_graph::validate_complete_document_unresolved_reasons(&self.store.connection)?;
1256 let next_generation = self
1257 .previous_generation
1258 .checked_next()
1259 .ok_or(DbError::PublicationGenerationOverflow)?;
1260 match &self.contract {
1261 PublicationContract::Full(contract_fingerprint) => {
1262 set_metadata(
1263 &self.store.connection,
1264 INDEX_PUBLICATION_FINGERPRINT_KEY,
1265 contract_fingerprint,
1266 )?;
1267 }
1268 PublicationContract::Projection(contract_fingerprint) => {
1269 let matches =
1270 load_index_publication(&self.store.connection)?.is_some_and(|publication| {
1271 publication.state == IndexPublicationState::Updating
1272 && publication.contract_fingerprint.as_deref()
1273 == Some(contract_fingerprint.as_str())
1274 && publication.generation == self.previous_generation
1275 });
1276 if !matches {
1277 return Err(DbError::PublicationContractChanged);
1278 }
1279 }
1280 }
1281 set_metadata(
1282 &self.store.connection,
1283 INDEX_PUBLICATION_GENERATION_KEY,
1284 &next_generation.to_string(),
1285 )?;
1286 set_metadata(
1287 &self.store.connection,
1288 INDEX_PUBLICATION_STATE_KEY,
1289 IndexPublicationState::Complete.as_str(),
1290 )?;
1291 self.store.connection.execute_batch("COMMIT")?;
1292 self.active = false;
1293 Ok(())
1294 }
1295}
1296
1297impl Drop for IndexPublicationGuard<'_> {
1298 fn drop(&mut self) {
1299 if self.active {
1300 let _rollback_result = self.store.connection.execute_batch("ROLLBACK");
1301 }
1302 }
1303}
1304
1305#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1307pub struct IndexedFileText {
1308 pub path: String,
1310 pub content_hash: Option<String>,
1312 pub byte_count: usize,
1314 pub line_count: usize,
1316 pub content: String,
1318}
1319
1320#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1322pub struct FileTextFtsQuery<'query> {
1323 pub literal_token: &'query str,
1325 pub path_prefix: Option<&'query str>,
1327 pub limit: usize,
1329}
1330
1331#[derive(Clone, Debug, PartialEq)]
1333pub struct FileTextFtsCandidate {
1334 pub path: String,
1336 pub content_hash: Option<String>,
1338 pub byte_count: usize,
1340 pub line_count: usize,
1342 pub bm25: f64,
1344}
1345
1346#[derive(Clone, Debug, PartialEq)]
1348pub struct FileTextFtsPage {
1349 pub candidates: Vec<FileTextFtsCandidate>,
1351 pub overflow: bool,
1353}
1354
1355#[derive(Clone, Debug, Eq, PartialEq)]
1357pub struct FileTextMetadata {
1358 pub path: String,
1360 pub content_hash: Option<String>,
1362 pub byte_count: usize,
1364 pub line_count: usize,
1366 pub classification: ContentClassification,
1368}
1369
1370#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1372pub enum FileTextAdmission {
1373 Read,
1375 Skip,
1377 Stop,
1379}
1380
1381#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1383pub struct FileTextFtsState {
1384 pub source_rows: usize,
1386 pub indexed_rows: usize,
1388 pub synchronized: bool,
1390}
1391
1392#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1394pub struct HealthResolution {
1395 pub finding_id: String,
1397 pub category: String,
1399 pub path: String,
1401 pub related_path: Option<String>,
1403 pub rationale: String,
1405}
1406
1407#[derive(Clone, Debug, Eq, PartialEq)]
1409pub struct HealthQuery {
1410 pub start_index: usize,
1412 pub limit: usize,
1414 pub category: Option<String>,
1416 pub severity: Option<Severity>,
1418 pub path_prefix: Option<String>,
1420 pub summary_only: bool,
1422 pub scope: HealthScope,
1424}
1425
1426#[derive(Clone, Copy)]
1428enum HealthResolutionFilter<'a> {
1429 Explicit(&'a [String]),
1431 Stored,
1433}
1434
1435#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1437pub enum HealthScope {
1438 All,
1440 SourceOnly,
1442 PurposeDefault,
1444 PurposeWithAssets,
1446 PurposeWithSourceFiles,
1448 PurposeStrict,
1450}
1451
1452impl HealthScope {
1453 pub fn all() -> Self {
1455 Self::All
1456 }
1457
1458 pub fn source_only() -> Self {
1460 Self::SourceOnly
1461 }
1462
1463 pub fn purpose_default() -> Self {
1465 Self::PurposeDefault
1466 }
1467
1468 pub fn purpose_with_assets() -> Self {
1470 Self::PurposeWithAssets
1471 }
1472
1473 pub fn purpose_with_source_files() -> Self {
1475 Self::PurposeWithSourceFiles
1476 }
1477
1478 pub fn purpose_strict() -> Self {
1480 Self::PurposeStrict
1481 }
1482
1483 pub fn is_source_focused(self) -> bool {
1485 self.source_only_filter()
1486 }
1487
1488 pub fn is_purpose_queue(self) -> bool {
1490 self.high_impact_queue()
1491 }
1492
1493 fn source_only_filter(self) -> bool {
1495 matches!(
1496 self,
1497 Self::SourceOnly | Self::PurposeDefault | Self::PurposeWithSourceFiles
1498 )
1499 }
1500
1501 fn high_impact_queue(self) -> bool {
1503 matches!(
1504 self,
1505 Self::PurposeDefault
1506 | Self::PurposeWithAssets
1507 | Self::PurposeWithSourceFiles
1508 | Self::PurposeStrict
1509 )
1510 }
1511
1512 fn include_assets(self) -> bool {
1514 matches!(self, Self::PurposeWithAssets)
1515 }
1516
1517 fn include_source_files(self) -> bool {
1519 matches!(self, Self::PurposeWithSourceFiles | Self::PurposeStrict)
1520 }
1521
1522 fn include_all_files(self) -> bool {
1524 matches!(self, Self::PurposeStrict)
1525 }
1526}
1527
1528#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1530pub struct HealthFindingsPage {
1531 pub total: usize,
1533 pub unfiltered_total: usize,
1535 pub returned: usize,
1537 pub start_index: usize,
1539 pub limit: usize,
1541 pub findings: Vec<HealthFinding>,
1543}
1544
1545#[derive(Clone, Copy, Debug)]
1547struct PurposeHealthSpec {
1548 status: &'static str,
1550 category: &'static str,
1552 message: &'static str,
1554 recommendation: &'static str,
1556}
1557
1558const PURPOSE_HEALTH_SPECS: [PurposeHealthSpec; 3] = [
1560 PurposeHealthSpec {
1561 status: PurposeStatus::Missing.as_str(),
1562 category: CATEGORY_MISSING_PURPOSE,
1563 message: MESSAGE_MISSING_PURPOSE,
1564 recommendation: RECOMMENDATION_MISSING_PURPOSE_QUEUE,
1565 },
1566 PurposeHealthSpec {
1567 status: PurposeStatus::Suggested.as_str(),
1568 category: CATEGORY_SUGGESTED_PURPOSE_REVIEW,
1569 message: MESSAGE_SUGGESTED_PURPOSE_REVIEW,
1570 recommendation: RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW_QUEUE,
1571 },
1572 PurposeHealthSpec {
1573 status: PurposeStatus::Stale.as_str(),
1574 category: CATEGORY_STALE_PURPOSE,
1575 message: MESSAGE_STALE_PURPOSE,
1576 recommendation: RECOMMENDATION_STALE_PURPOSE,
1577 },
1578];
1579
1580#[cfg(test)]
1582fn with_validated_write_transaction<T>(
1583 connection: &Connection,
1584 expected_root: Option<&str>,
1585 expected_identity: Option<ProjectInstanceId>,
1586 operation: impl FnOnce(&Connection) -> DbResult<T>,
1587) -> DbResult<T> {
1588 let transaction =
1589 rusqlite::Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?;
1590 let result = schema::validate_active_binding(&transaction, expected_root, expected_identity)
1591 .and_then(|()| operation(&transaction));
1592 match result {
1593 Ok(value) => {
1594 transaction.commit()?;
1595 Ok(value)
1596 }
1597 Err(operation) => match transaction.rollback() {
1598 Ok(()) => Err(operation),
1599 Err(rollback) => Err(DbError::TransactionRollback {
1600 operation: Box::new(operation),
1601 rollback,
1602 }),
1603 },
1604 }
1605}
1606
1607fn with_validated_native_write_transaction<T>(
1609 connection: &Connection,
1610 expected_root: Option<&CanonicalProjectRoot>,
1611 expected_identity: Option<ProjectInstanceId>,
1612 operation: impl FnOnce(&Connection) -> DbResult<T>,
1613) -> DbResult<T> {
1614 let transaction =
1615 rusqlite::Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?;
1616 let result =
1617 schema::validate_active_native_binding(&transaction, expected_root, expected_identity)
1618 .and_then(|()| operation(&transaction));
1619 match result {
1620 Ok(value) => {
1621 transaction.commit()?;
1622 Ok(value)
1623 }
1624 Err(operation) => match transaction.rollback() {
1625 Ok(()) => Err(operation),
1626 Err(rollback) => Err(DbError::TransactionRollback {
1627 operation: Box::new(operation),
1628 rollback,
1629 }),
1630 },
1631 }
1632}
1633
1634impl AtlasStore {
1635 fn require_mutation_scope(&self) -> DbResult<()> {
1637 if self.read_snapshot_active.get() {
1638 Err(DbError::IndexReadSnapshotActive)
1639 } else {
1640 Ok(())
1641 }
1642 }
1643
1644 fn validated_savepoint(&mut self) -> DbResult<rusqlite::Savepoint<'_>> {
1646 self.require_mutation_scope()?;
1647 let validate_binding = self.connection.is_autocommit();
1648 let expected_root_identity = self.validated_project_root_identity.clone();
1649 let expected_identity = self.validated_project_instance_id;
1650 let savepoint = self.connection.savepoint()?;
1651 if validate_binding {
1652 schema::validate_active_native_binding(
1653 &savepoint,
1654 expected_root_identity.as_ref(),
1655 expected_identity,
1656 )?;
1657 }
1658 Ok(savepoint)
1659 }
1660
1661 fn with_validated_write<T>(
1663 &self,
1664 operation: impl FnOnce(&Connection) -> DbResult<T>,
1665 ) -> DbResult<T> {
1666 self.require_mutation_scope()?;
1667 if !self.connection.is_autocommit() {
1668 return operation(&self.connection);
1669 }
1670 with_validated_native_write_transaction(
1671 &self.connection,
1672 self.validated_project_root_identity.as_ref(),
1673 self.validated_project_instance_id,
1674 operation,
1675 )
1676 }
1677
1678 pub fn begin_purpose_mutation(&self) -> DbResult<PurposeMutationTransaction<'_>> {
1690 self.require_mutation_scope()?;
1691 let transaction =
1692 rusqlite::Transaction::new_unchecked(&self.connection, TransactionBehavior::Immediate)?;
1693 schema::validate_active_native_binding(
1694 &transaction,
1695 self.validated_project_root_identity.as_ref(),
1696 self.validated_project_instance_id,
1697 )?;
1698 Ok(PurposeMutationTransaction { transaction })
1699 }
1700
1701 fn with_telemetry_connection<T>(
1708 &self,
1709 operation: impl FnOnce(&Connection) -> DbResult<T>,
1710 ) -> DbResult<T> {
1711 if self.read_only {
1712 self.finish_index_read_snapshot()?;
1713 } else {
1714 self.require_mutation_scope()?;
1715 }
1716 let (Some(path), Some(location)) =
1717 (self.database_path.as_ref(), self.database_location.as_ref())
1718 else {
1719 if self.database_path.is_none() && self.database_location.is_none() {
1720 return operation(&self.connection);
1721 }
1722 return Err(DbError::TelemetryPathUnavailable);
1723 };
1724 let _ = schema::preflight(path, None)?;
1725 let connection = open_writable_connection(
1726 path,
1727 OpenFlags::SQLITE_OPEN_READ_WRITE,
1728 location,
1729 SQLITE_TELEMETRY_BUSY_TIMEOUT,
1730 JournalModePolicy::RequireWal,
1731 )?;
1732 operation(&connection)
1733 }
1734
1735 pub fn open(path: &Path) -> DbResult<Self> {
1741 Self::open_with_project_root(path, None)
1742 }
1743
1744 pub fn open_for_project(path: &Path, root: &Path) -> DbResult<Self> {
1755 let expected_identity = CanonicalProjectRoot::from_path(root)?;
1756 Self::open_with_binding_requirement(
1757 path,
1758 None,
1759 Some(&expected_identity),
1760 ProjectIdentityRequirement::Required,
1761 StoreInitializationPolicy::AllowFresh,
1762 )
1763 }
1764
1765 pub fn open_existing_for_project(path: &Path, root: &Path) -> DbResult<Self> {
1776 let expected_identity = CanonicalProjectRoot::from_path(root)?;
1777 Self::open_with_binding_requirement(
1778 path,
1779 None,
1780 Some(&expected_identity),
1781 ProjectIdentityRequirement::Required,
1782 StoreInitializationPolicy::ExistingSchema,
1783 )
1784 }
1785
1786 fn open_with_project_root(path: &Path, expected_root: Option<&str>) -> DbResult<Self> {
1788 Self::open_with_binding_requirement(
1789 path,
1790 expected_root,
1791 None,
1792 ProjectIdentityRequirement::Required,
1793 StoreInitializationPolicy::AllowFresh,
1794 )
1795 }
1796
1797 fn open_for_root_transition(path: &Path) -> DbResult<Self> {
1799 Self::open_with_binding_requirement(
1800 path,
1801 None,
1802 None,
1803 ProjectIdentityRequirement::TransitionOwned,
1804 StoreInitializationPolicy::AllowFresh,
1805 )
1806 }
1807
1808 fn open_with_binding_requirement(
1810 path: &Path,
1811 expected_root: Option<&str>,
1812 expected_identity: Option<&CanonicalProjectRoot>,
1813 identity_requirement: ProjectIdentityRequirement,
1814 initialization: StoreInitializationPolicy,
1815 ) -> DbResult<Self> {
1816 let (preflight, location) = if let Some(expected_identity) = expected_identity {
1817 schema::preflight_for_project(path, expected_identity)?
1818 } else {
1819 schema::preflight(path, None)?
1820 };
1821 if initialization == StoreInitializationPolicy::ExistingSchema
1822 && preflight.state == SchemaState::Fresh
1823 {
1824 return Err(DbError::InitializationRequired);
1825 }
1826 if expected_identity.is_none()
1827 && identity_requirement.is_required()
1828 && preflight.state == SchemaState::UpgradeRequired
1829 && schema::legacy_root_requires_native_authority(preflight.project_root.as_deref())
1830 {
1831 return Err(DbError::ProjectRootIdentityMissing);
1835 }
1836 let validated_project_root = expected_identity
1837 .and_then(|identity| identity.display_string().ok())
1838 .or_else(|| expected_root.map(str::to_owned))
1839 .or_else(|| {
1840 expected_identity
1841 .is_none()
1842 .then(|| preflight.project_root.clone())
1843 .flatten()
1844 });
1845 if preflight.state == SchemaState::Current
1846 && expected_identity.is_some()
1847 && identity_requirement.is_required()
1848 && preflight.project_instance_id.is_none()
1849 {
1850 return Err(DbError::ProjectInstanceIdentityMissing);
1851 }
1852 let connection = open_writable_connection(
1853 path,
1854 writable_open_flags(preflight.state, location.database_exists),
1855 &location,
1856 SQLITE_BUSY_TIMEOUT,
1857 writable_journal_policy(preflight.state),
1858 )?;
1859 let transition_transaction = preflight.state == SchemaState::UpgradeRequired
1860 && identity_requirement == ProjectIdentityRequirement::TransitionOwned;
1861 if transition_transaction {
1862 connection.execute_batch("BEGIN IMMEDIATE")?;
1863 }
1864 let opened = (|| {
1865 if preflight.state == SchemaState::Current
1866 && let Some(identity) = expected_identity
1867 {
1868 project_identity::ensure_project_root_identity(&connection, identity)?;
1869 }
1870 let validated_project_instance_id = if preflight.state == SchemaState::Current {
1871 if let Some(expected_identity) = expected_identity {
1872 schema::revalidate_current_native_binding(
1873 &connection,
1874 expected_identity,
1875 identity_requirement.is_required(),
1876 )?
1877 } else if identity_requirement == ProjectIdentityRequirement::TransitionOwned {
1878 project_identity::load_project_identity(&connection)?
1883 } else if let Some(stored_identity) =
1884 project_identity::load_project_root_identity(&connection)?
1885 {
1886 schema::revalidate_current_native_binding(
1887 &connection,
1888 &stored_identity,
1889 identity_requirement.is_required(),
1890 )?
1891 } else {
1892 let stored_instance_id = project_identity::load_project_identity(&connection)?;
1893 schema::validate_binding_completeness(
1894 validated_project_root.as_deref(),
1895 stored_instance_id,
1896 identity_requirement.is_required(),
1897 )?;
1898 if stored_instance_id.is_some() {
1899 return Err(DbError::ProjectRootIdentityMissing);
1900 }
1901 None
1902 }
1903 } else {
1904 if let Some(expected_identity) = expected_identity {
1905 let expected_display = expected_identity.display_string().ok();
1906 match initialization {
1907 StoreInitializationPolicy::AllowFresh => {
1908 schema::initialize_with_project_root(
1909 &connection,
1910 expected_display.as_deref(),
1911 Some(expected_identity),
1912 )?;
1913 }
1914 StoreInitializationPolicy::ExistingSchema => {
1915 schema::migrate_existing_with_project_root(
1916 &connection,
1917 expected_display.as_deref(),
1918 Some(expected_identity),
1919 )?;
1920 }
1921 }
1922 } else {
1923 let initialization_root = expected_root.or(preflight.project_root.as_deref());
1924 if transition_transaction {
1925 schema::initialize_with_project_root_in_transaction(
1926 &connection,
1927 initialization_root,
1928 None,
1929 )?;
1930 } else {
1931 schema::initialize_with_project_root(
1932 &connection,
1933 initialization_root,
1934 None,
1935 )?;
1936 }
1937 }
1938 project_identity::load_project_identity(&connection)?
1939 };
1940 let validated_project_root_identity =
1941 project_identity::load_project_root_identity(&connection)?;
1942 if identity_requirement.is_required()
1943 && (validated_project_root.is_some() || validated_project_root_identity.is_some())
1944 && validated_project_instance_id.is_none()
1945 {
1946 return Err(DbError::ProjectInstanceIdentityMissing);
1947 }
1948 let database_location = if location.database_exists {
1949 location
1950 } else {
1951 sqlite_profile::inspect_database_location(path)?
1952 };
1953 Ok((
1954 database_location,
1955 validated_project_root,
1956 validated_project_root_identity,
1957 validated_project_instance_id,
1958 ))
1959 })();
1960 if transition_transaction {
1961 match opened {
1962 Ok((
1963 database_location,
1964 validated_project_root,
1965 validated_project_root_identity,
1966 validated_project_instance_id,
1967 )) => Ok(Self {
1968 connection,
1969 read_snapshot_active: Cell::new(false),
1970 database_path: Some(path.to_path_buf()),
1971 database_location: Some(database_location),
1972 read_only: false,
1973 validated_project_root,
1974 validated_project_root_identity,
1975 validated_project_instance_id,
1976 library_usage_instances: RefCell::new(HashMap::new()),
1977 }),
1978 Err(error) => Err(schema::rollback_after_error(&connection, error)),
1979 }
1980 } else {
1981 let (
1982 database_location,
1983 validated_project_root,
1984 validated_project_root_identity,
1985 validated_project_instance_id,
1986 ) = opened?;
1987 Ok(Self {
1988 connection,
1989 read_snapshot_active: Cell::new(false),
1990 database_path: Some(path.to_path_buf()),
1991 database_location: Some(database_location),
1992 read_only: false,
1993 validated_project_root,
1994 validated_project_root_identity,
1995 validated_project_instance_id,
1996 library_usage_instances: RefCell::new(HashMap::new()),
1997 })
1998 }
1999 }
2000
2001 pub fn open_read_only(path: &Path) -> DbResult<Self> {
2008 Self::open_read_only_with_project_root(path, None)
2009 }
2010
2011 pub fn open_read_only_for_project(path: &Path, root: &Path) -> DbResult<Self> {
2018 let expected_identity = CanonicalProjectRoot::from_path(root)?;
2019 Self::open_read_only_with_project_root(path, Some(&expected_identity))
2020 }
2021
2022 #[must_use]
2024 pub const fn is_read_only(&self) -> bool {
2025 self.read_only
2026 }
2027
2028 #[must_use]
2030 pub fn has_active_read_snapshot(&self) -> bool {
2031 self.read_snapshot_active.get()
2032 }
2033
2034 fn open_read_only_with_project_root(
2036 path: &Path,
2037 expected_identity: Option<&CanonicalProjectRoot>,
2038 ) -> DbResult<Self> {
2039 let (connection, preflight) = schema::open_current_read_only(path, None)?;
2040 let validated_project_instance_id = project_identity::load_project_identity(&connection)?;
2041 let validated_project_root_identity =
2042 project_identity::load_project_root_identity(&connection)?;
2043 if expected_identity.is_some() && validated_project_root_identity.is_none() {
2044 return Err(DbError::ProjectRootIdentityMissing);
2045 }
2046 if let (Some(expected), Some(found)) =
2047 (expected_identity, validated_project_root_identity.as_ref())
2048 {
2049 project_identity::prove_existing_root_equivalence(expected.as_path(), found.as_path())?;
2050 }
2051 if validated_project_root_identity.is_none() {
2052 schema::validate_binding_completeness(
2053 preflight.project_root.as_deref(),
2054 validated_project_instance_id,
2055 true,
2056 )?;
2057 } else if validated_project_instance_id.is_none() {
2058 return Err(DbError::ProjectInstanceIdentityMissing);
2059 }
2060 let validated_project_root = match validated_project_root_identity.as_ref() {
2061 Some(identity) => identity.display_string().ok(),
2062 None => preflight.project_root,
2063 };
2064 let database_location = sqlite_profile::inspect_database_location(path)?;
2065 Ok(Self {
2066 connection,
2067 read_snapshot_active: Cell::new(true),
2068 database_path: Some(path.to_path_buf()),
2069 database_location: Some(database_location),
2070 read_only: true,
2071 validated_project_root,
2072 validated_project_root_identity,
2073 validated_project_instance_id,
2074 library_usage_instances: RefCell::new(HashMap::new()),
2075 })
2076 }
2077
2078 pub fn in_memory() -> DbResult<Self> {
2084 let store = Self {
2085 connection: Connection::open_in_memory()?,
2086 read_snapshot_active: Cell::new(false),
2087 database_path: None,
2088 database_location: None,
2089 read_only: false,
2090 validated_project_root: None,
2091 validated_project_root_identity: None,
2092 validated_project_instance_id: None,
2093 library_usage_instances: RefCell::new(HashMap::new()),
2094 };
2095 schema::initialize(&store.connection, None)?;
2096 Ok(store)
2097 }
2098
2099 pub fn initialize_schema(&self) -> DbResult<()> {
2105 schema::initialize(&self.connection, None)
2106 }
2107
2108 pub fn replace_scan(&mut self, nodes: &[Node]) -> DbResult<()> {
2114 let savepoint = self.validated_savepoint()?;
2115 mark_all_scan_nodes_absent(&savepoint)?;
2116 upsert_nodes(&savepoint, nodes)?;
2117 delete_absent_scan_projections(&savepoint)?;
2118 savepoint.commit()?;
2119 Ok(())
2120 }
2121
2122 pub fn upsert_scan_nodes(&mut self, nodes: &[Node]) -> DbResult<()> {
2128 let savepoint = self.validated_savepoint()?;
2129 upsert_nodes(&savepoint, nodes)?;
2130 savepoint.commit()?;
2131 Ok(())
2132 }
2133
2134 pub fn mark_paths_absent(&mut self, paths: &[String]) -> DbResult<()> {
2140 let savepoint = self.validated_savepoint()?;
2141 let fts_revision = begin_file_text_fts_update(&savepoint)?;
2142 {
2143 let mut mark_nodes = savepoint.prepare_cached(
2144 "UPDATE nodes SET exists_now = 0 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2145 )?;
2146 let mut delete_classifications = savepoint.prepare_cached(
2147 "DELETE FROM file_content_classifications
2148 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2149 )?;
2150 let mut delete_relations = savepoint.prepare_cached(
2151 "DELETE FROM symbol_relations WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2152 )?;
2153 let mut delete_symbols = savepoint.prepare_cached(
2154 "DELETE FROM symbols WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2155 )?;
2156 let mut delete_parse_metadata = savepoint.prepare_cached(
2157 "DELETE FROM source_parse_metadata WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2158 )?;
2159 let mut delete_text_fts = savepoint.prepare_cached(
2160 "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
2161 SELECT 'delete', rowid, content FROM file_texts \
2162 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2163 )?;
2164 let mut delete_text = savepoint.prepare_cached(
2165 "DELETE FROM file_texts WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2166 )?;
2167 for path in paths {
2168 if path == "." || path.is_empty() {
2169 continue;
2170 }
2171 let descendant_pattern = sqlite_descendant_pattern(path);
2172 delete_classifications.execute(params![path, descendant_pattern])?;
2173 mark_nodes.execute(params![path, descendant_pattern])?;
2174 delete_relations.execute(params![path, descendant_pattern])?;
2175 delete_symbols.execute(params![path, descendant_pattern])?;
2176 delete_parse_metadata.execute(params![path, descendant_pattern])?;
2177 delete_text_fts.execute(params![path, descendant_pattern])?;
2178 delete_text.execute(params![path, descendant_pattern])?;
2179 }
2180 }
2181 complete_file_text_fts_update(&savepoint, fts_revision)?;
2182 savepoint.commit()?;
2183 Ok(())
2184 }
2185
2186 pub fn replace_file_texts_for_paths<'text>(
2196 &mut self,
2197 paths: &[String],
2198 texts: impl IntoIterator<Item = &'text IndexedFileText>,
2199 ) -> DbResult<()> {
2200 let texts = texts.into_iter().collect::<Vec<_>>();
2201 for text in &texts {
2202 validate_indexed_file_text(text)?;
2203 }
2204 let savepoint = self.validated_savepoint()?;
2205 let fts_revision = begin_file_text_fts_update(&savepoint)?;
2206 {
2207 let mut delete_fts = savepoint.prepare_cached(
2208 "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
2209 SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
2210 )?;
2211 let mut delete = savepoint.prepare_cached("DELETE FROM file_texts WHERE path = ?1")?;
2212 for path in paths {
2213 delete_fts.execute([path])?;
2214 delete.execute([path])?;
2215 }
2216 }
2217 {
2218 let mut delete_current_fts = savepoint.prepare_cached(
2219 "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
2220 SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
2221 )?;
2222 let mut upsert = savepoint.prepare_cached(
2223 "
2224 INSERT INTO file_texts(path, content_hash, byte_count, line_count, content, updated_at)
2225 VALUES(?1, ?2, ?3, ?4, ?5, CURRENT_TIMESTAMP)
2226 ON CONFLICT(path) DO UPDATE SET
2227 content_hash = excluded.content_hash,
2228 byte_count = excluded.byte_count,
2229 line_count = excluded.line_count,
2230 content = excluded.content,
2231 updated_at = CURRENT_TIMESTAMP
2232 ",
2233 )?;
2234 let mut index = savepoint.prepare_cached(
2235 "INSERT INTO file_text_fts(rowid, content) \
2236 SELECT rowid, content FROM file_texts WHERE path = ?1",
2237 )?;
2238 for text in texts {
2239 delete_current_fts.execute([&text.path])?;
2240 upsert.execute(params![
2241 text.path,
2242 text.content_hash.as_deref(),
2243 usize_to_i64(text.byte_count),
2244 usize_to_i64(text.line_count),
2245 text.content
2246 ])?;
2247 index.execute([&text.path])?;
2248 }
2249 }
2250 complete_file_text_fts_update(&savepoint, fts_revision)?;
2251 savepoint.commit()?;
2252 Ok(())
2253 }
2254
2255 pub fn load_file_text(&self, path: &str) -> DbResult<Option<IndexedFileText>> {
2261 let mut statement = self.connection.prepare_cached(
2262 "
2263 SELECT path, content_hash, byte_count, line_count, content
2264 FROM file_texts
2265 WHERE path = ?1
2266 ",
2267 )?;
2268 let mut rows = statement.query([path])?;
2269 rows.next()?.map(file_text_from_row).transpose()
2270 }
2271
2272 pub fn query_file_text_fts_candidates(
2284 &self,
2285 query: &FileTextFtsQuery<'_>,
2286 control: Option<&IndexWorkControl>,
2287 ) -> DbResult<FileTextFtsPage> {
2288 validate_file_text_fts_token(query.literal_token)?;
2289 if query.limit > MAX_FILE_TEXT_FTS_CANDIDATES {
2290 return Err(DbError::FileTextFtsCandidateLimit {
2291 requested: query.limit,
2292 maximum: MAX_FILE_TEXT_FTS_CANDIDATES,
2293 });
2294 }
2295 let match_expression = format!("\"{}\"", query.literal_token);
2296 let row_limit = usize_to_i64(query.limit.saturating_add(1));
2297 with_file_text_progress(&self.connection, control, || {
2298 let mut candidates = Vec::with_capacity(query.limit.saturating_add(1));
2299 if let Some(path_prefix) = normalized_file_text_path_prefix(query.path_prefix) {
2300 let descendant_pattern = sqlite_descendant_pattern(path_prefix);
2301 let mut statement = self.connection.prepare_cached(
2302 "
2303 SELECT
2304 f.path,
2305 f.content_hash,
2306 f.byte_count,
2307 f.line_count,
2308 bm25(file_text_fts)
2309 FROM file_text_fts
2310 JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
2311 WHERE file_text_fts MATCH ?1
2312 AND (f.path = ?2 OR f.path LIKE ?3 ESCAPE '\\')
2313 ORDER BY bm25(file_text_fts), f.path
2314 LIMIT ?4
2315 ",
2316 )?;
2317 let mut rows = statement.query(params![
2318 match_expression,
2319 path_prefix,
2320 descendant_pattern,
2321 row_limit,
2322 ])?;
2323 collect_file_text_fts_candidates(&mut rows, control, &mut candidates)?;
2324 } else {
2325 let mut statement = self.connection.prepare_cached(
2326 "
2327 SELECT
2328 f.path,
2329 f.content_hash,
2330 f.byte_count,
2331 f.line_count,
2332 bm25(file_text_fts)
2333 FROM file_text_fts
2334 JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
2335 WHERE file_text_fts MATCH ?1
2336 ORDER BY bm25(file_text_fts), f.path
2337 LIMIT ?2
2338 ",
2339 )?;
2340 let mut rows = statement.query(params![match_expression, row_limit])?;
2341 collect_file_text_fts_candidates(&mut rows, control, &mut candidates)?;
2342 }
2343 let overflow = candidates.len() > query.limit;
2344 candidates.truncate(query.limit);
2345 Ok(FileTextFtsPage {
2346 candidates,
2347 overflow,
2348 })
2349 })
2350 }
2351
2352 pub fn visit_file_texts_for_fallback<A, V>(
2364 &self,
2365 path_prefix: Option<&str>,
2366 control: Option<&IndexWorkControl>,
2367 mut admit: A,
2368 mut visitor: V,
2369 ) -> DbResult<()>
2370 where
2371 A: FnMut(&FileTextMetadata) -> DbResult<FileTextAdmission>,
2372 V: FnMut(IndexedFileText) -> DbResult<bool>,
2373 {
2374 with_file_text_progress(&self.connection, control, || {
2375 let mut content_statement = self
2376 .connection
2377 .prepare_cached(FILE_TEXT_FALLBACK_CONTENT_SQL)?;
2378 if let Some(path_prefix) = normalized_file_text_path_prefix(path_prefix) {
2379 let (descendant_start, descendant_end) = file_text_descendant_range(path_prefix);
2380 let mut statement = self
2381 .connection
2382 .prepare_cached(FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL)?;
2383 let mut rows =
2384 statement.query(params![path_prefix, descendant_start, descendant_end])?;
2385 visit_file_text_fallback_rows(
2386 &mut rows,
2387 &mut content_statement,
2388 control,
2389 &mut admit,
2390 &mut visitor,
2391 )
2392 } else {
2393 let mut statement = self
2394 .connection
2395 .prepare_cached(FILE_TEXT_FALLBACK_ALL_METADATA_SQL)?;
2396 let mut rows = statement.query([])?;
2397 visit_file_text_fallback_rows(
2398 &mut rows,
2399 &mut content_statement,
2400 control,
2401 &mut admit,
2402 &mut visitor,
2403 )
2404 }
2405 })
2406 }
2407
2408 pub fn file_text_fts_ready(&self) -> DbResult<bool> {
2417 Ok(load_file_text_fts_revisions(&self.connection)?
2418 .is_some_and(|(source, projection)| source == projection))
2419 }
2420
2421 pub fn file_text_fts_state(&self) -> DbResult<FileTextFtsState> {
2428 let revision_synchronized = self.file_text_fts_ready()?;
2429 let (source_rows, indexed_rows, synchronized) = self.connection.query_row(
2430 "
2431 SELECT
2432 (SELECT COUNT(*) FROM file_texts),
2433 (SELECT COUNT(*) FROM file_text_fts_docsize),
2434 NOT EXISTS(
2435 SELECT 1
2436 FROM file_texts AS f
2437 LEFT JOIN file_text_fts_docsize AS d ON d.id = f.rowid
2438 WHERE d.id IS NULL
2439 )
2440 AND NOT EXISTS(
2441 SELECT 1
2442 FROM file_text_fts_docsize AS d
2443 LEFT JOIN file_texts AS f ON f.rowid = d.id
2444 WHERE f.rowid IS NULL
2445 )
2446 ",
2447 [],
2448 |row| {
2449 Ok((
2450 row.get::<_, i64>(0)?,
2451 row.get::<_, i64>(1)?,
2452 row.get::<_, i64>(2)? != 0,
2453 ))
2454 },
2455 )?;
2456 Ok(FileTextFtsState {
2457 source_rows: count_to_usize("file_texts", source_rows)?,
2458 indexed_rows: count_to_usize("file_text_fts", indexed_rows)?,
2459 synchronized: revision_synchronized && synchronized,
2460 })
2461 }
2462
2463 pub fn load_file_texts_for_search(
2475 &self,
2476 literal_pattern: Option<&str>,
2477 case_sensitive: bool,
2478 ) -> DbResult<Vec<IndexedFileText>> {
2479 let mut texts = Vec::new();
2480 self.visit_file_texts_for_search(literal_pattern, case_sensitive, |text| {
2481 texts.push(text);
2482 Ok(true)
2483 })?;
2484 Ok(texts)
2485 }
2486
2487 pub fn visit_file_texts_for_search<F>(
2499 &self,
2500 literal_pattern: Option<&str>,
2501 case_sensitive: bool,
2502 mut visitor: F,
2503 ) -> DbResult<()>
2504 where
2505 F: FnMut(IndexedFileText) -> DbResult<bool>,
2506 {
2507 if let Some(pattern) =
2508 literal_pattern.filter(|pattern| case_sensitive && !pattern.is_empty())
2509 {
2510 let mut statement = self.connection.prepare(
2511 "
2512 SELECT path, content_hash, byte_count, line_count, content
2513 FROM file_texts
2514 WHERE instr(content, ?1) > 0
2515 ORDER BY path
2516 ",
2517 )?;
2518 let mut rows = statement.query([pattern])?;
2519 while let Some(row) = rows.next()? {
2520 if !visitor(file_text_from_row(row)?)? {
2521 return Ok(());
2522 }
2523 }
2524 } else {
2525 let mut statement = self.connection.prepare(
2526 "
2527 SELECT path, content_hash, byte_count, line_count, content
2528 FROM file_texts
2529 ORDER BY path
2530 ",
2531 )?;
2532 let mut rows = statement.query([])?;
2533 while let Some(row) = rows.next()? {
2534 if !visitor(file_text_from_row(row)?)? {
2535 return Ok(());
2536 }
2537 }
2538 }
2539 Ok(())
2540 }
2541
2542 pub fn file_text_count(&self) -> DbResult<usize> {
2548 let count = self
2549 .connection
2550 .query_row("SELECT COUNT(*) FROM file_texts", [], |row| {
2551 row.get::<_, i64>(0)
2552 })?;
2553 count_to_usize("file_texts", count)
2554 }
2555
2556 pub fn file_text_byte_count(&self) -> DbResult<usize> {
2562 let count = self.connection.query_row(
2563 "SELECT COALESCE(SUM(byte_count), 0) FROM file_texts",
2564 [],
2565 |row| row.get::<_, i64>(0),
2566 )?;
2567 count_to_usize("file_text_bytes", count)
2568 }
2569
2570 pub fn set_project_root(&mut self, root: &Path) -> DbResult<()> {
2576 let identity = CanonicalProjectRoot::from_path(root)?;
2577 let value = identity.display_string().ok();
2578 let previous_identity = self.validated_project_instance_id;
2579 let savepoint = self.validated_savepoint()?;
2580 let found = savepoint
2581 .query_row(
2582 "SELECT value FROM metadata WHERE key = ?1",
2583 [PROJECT_ROOT_KEY],
2584 |row| row.get::<_, String>(0),
2585 )
2586 .optional()?;
2587 let found_identity = project_identity::load_project_root_identity(&savepoint)?;
2588 if found.is_none() && found_identity.is_none() {
2589 project_identity::set_project_root_identity(&savepoint, &identity)?;
2590 project_identity::set_project_root_metadata(&savepoint, &identity)?;
2591 } else {
2592 project_identity::ensure_project_root_identity_in_transaction(&savepoint, &identity)?;
2593 }
2594 let (project_identity, _) = project_identity::ensure_project_identity(&savepoint)?;
2595 let identity_changed = previous_identity != Some(project_identity);
2596 savepoint.commit()?;
2597 self.validated_project_root = value;
2598 self.validated_project_root_identity = Some(identity);
2599 self.validated_project_instance_id = Some(project_identity);
2600 if identity_changed {
2601 self.library_usage_instances.get_mut().clear();
2602 }
2603 Ok(())
2604 }
2605
2606 pub fn project_root(&self) -> DbResult<Option<String>> {
2612 self.connection
2613 .query_row(
2614 "SELECT value FROM metadata WHERE key = ?1",
2615 [PROJECT_ROOT_KEY],
2616 |row| row.get::<_, String>(0),
2617 )
2618 .optional()
2619 .map_err(DbError::from)
2620 }
2621
2622 pub fn captured_project_binding(&self) -> DbResult<CapturedProjectBinding> {
2631 Ok(CapturedProjectBinding {
2632 project_instance_id: self
2633 .validated_project_instance_id
2634 .ok_or(DbError::ProjectInstanceIdentityMissing)?,
2635 project_root: self.validated_project_root.clone(),
2636 project_root_identity: self
2637 .validated_project_root_identity
2638 .clone()
2639 .ok_or(DbError::ProjectRootIdentityMissing)?,
2640 })
2641 }
2642
2643 pub fn project_root_identity(&self) -> DbResult<Option<CanonicalProjectRoot>> {
2650 project_identity::load_project_root_identity(&self.connection)
2651 }
2652
2653 #[must_use]
2661 pub fn project_root_identity_matches(&self, selected: &CanonicalProjectRoot) -> bool {
2662 self.validated_project_root_identity
2663 .as_ref()
2664 .is_some_and(|persisted| {
2665 project_identity::prove_existing_root_equivalence(
2666 selected.as_path(),
2667 persisted.as_path(),
2668 )
2669 .is_ok()
2670 })
2671 }
2672
2673 pub fn revalidate_captured_project_binding(&self) -> DbResult<()> {
2685 let binding = self.captured_project_binding()?;
2686 let Some(path) = self.database_path.as_deref() else {
2687 return schema::validate_active_native_binding(
2688 &self.connection,
2689 Some(&binding.project_root_identity),
2690 Some(binding.project_instance_id),
2691 );
2692 };
2693 let (connection, _) = schema::open_current_read_only(path, None)?;
2694 schema::validate_active_native_binding(
2695 &connection,
2696 Some(&binding.project_root_identity),
2697 Some(binding.project_instance_id),
2698 )
2699 }
2700
2701 fn purpose_curation_context(
2703 &self,
2704 connection: &Connection,
2705 ) -> DbResult<(ProjectInstanceId, IndexGeneration)> {
2706 let selected = self
2707 .validated_project_instance_id
2708 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
2709 let current = project_identity::load_project_identity(connection)?
2710 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
2711 if current != selected {
2712 return Err(DbError::GraphProjectIdentityMismatch {
2713 expected: selected.to_string(),
2714 found: current.to_string(),
2715 });
2716 }
2717 let generation =
2718 project_identity::load_graph_generation(connection)?.unwrap_or(IndexGeneration::ZERO);
2719 Ok((current, generation))
2720 }
2721
2722 pub fn begin_index_publication(
2732 &mut self,
2733 contract_fingerprint: &str,
2734 ) -> DbResult<IndexPublicationGuard<'_>> {
2735 let base_generation = self
2736 .index_publication()?
2737 .map_or(IndexGeneration::ZERO, |publication| publication.generation);
2738 self.begin_index_publication_from(contract_fingerprint, base_generation)
2739 }
2740
2741 pub fn begin_index_publication_from(
2753 &mut self,
2754 contract_fingerprint: &str,
2755 expected_base_generation: IndexGeneration,
2756 ) -> DbResult<IndexPublicationGuard<'_>> {
2757 self.begin_publication(
2758 PublicationContract::Full(contract_fingerprint.to_string()),
2759 Some(expected_base_generation),
2760 )
2761 }
2762
2763 pub fn probe_index_publication_writer(&mut self) -> DbResult<()> {
2775 begin_immediate_publication(&self.connection)?;
2776 self.connection.execute_batch("ROLLBACK")?;
2777 Ok(())
2778 }
2779
2780 pub fn begin_index_projection_refresh(
2788 &mut self,
2789 contract_fingerprint: &str,
2790 ) -> DbResult<IndexPublicationGuard<'_>> {
2791 let base_generation = self
2792 .index_publication()?
2793 .map_or(IndexGeneration::ZERO, |publication| publication.generation);
2794 self.begin_index_projection_refresh_from(contract_fingerprint, base_generation)
2795 }
2796
2797 pub fn begin_index_projection_refresh_from(
2806 &mut self,
2807 contract_fingerprint: &str,
2808 expected_base_generation: IndexGeneration,
2809 ) -> DbResult<IndexPublicationGuard<'_>> {
2810 self.begin_publication(
2811 PublicationContract::Projection(contract_fingerprint.to_string()),
2812 Some(expected_base_generation),
2813 )
2814 }
2815
2816 fn begin_publication(
2818 &mut self,
2819 contract: PublicationContract,
2820 expected_base_generation: Option<IndexGeneration>,
2821 ) -> DbResult<IndexPublicationGuard<'_>> {
2822 begin_immediate_publication(&self.connection)?;
2823 let setup = (|| {
2824 schema::validate_active_native_binding(
2825 &self.connection,
2826 self.validated_project_root_identity.as_ref(),
2827 self.validated_project_instance_id,
2828 )?;
2829 let previous = load_index_publication(&self.connection)?;
2830 if let Some(expected) = expected_base_generation {
2831 let base_matches = match previous.as_ref() {
2832 None => expected == IndexGeneration::ZERO,
2833 Some(publication) => {
2834 publication.state == IndexPublicationState::Complete
2835 && publication.generation != IndexGeneration::ZERO
2836 && publication.generation == expected
2837 }
2838 };
2839 if !base_matches {
2840 return Err(DbError::PublicationBaseGenerationChanged {
2841 expected,
2842 found: previous
2843 .as_ref()
2844 .map_or(IndexGeneration::ZERO, |publication| publication.generation),
2845 });
2846 }
2847 }
2848 if let PublicationContract::Projection(expected) = &contract {
2849 let matches = previous.as_ref().is_some_and(|publication| {
2850 publication.state == IndexPublicationState::Complete
2851 && publication.contract_fingerprint.as_deref() == Some(expected.as_str())
2852 });
2853 if !matches {
2854 return Err(DbError::PublicationContractChanged);
2855 }
2856 }
2857 set_metadata(
2858 &self.connection,
2859 INDEX_PUBLICATION_STATE_KEY,
2860 IndexPublicationState::Updating.as_str(),
2861 )?;
2862 Ok(previous.map_or(IndexGeneration::ZERO, |publication| publication.generation))
2863 })();
2864 let previous_generation = match setup {
2865 Ok(generation) => generation,
2866 Err(error) => {
2867 self.connection.execute_batch("ROLLBACK")?;
2868 return Err(error);
2869 }
2870 };
2871 Ok(IndexPublicationGuard {
2872 store: self,
2873 contract,
2874 previous_generation,
2875 scan_replacement_pending: false,
2876 active: true,
2877 })
2878 }
2879
2880 pub fn begin_index_read_snapshot(&self) -> DbResult<()> {
2888 if self.read_snapshot_active.replace(true) {
2889 return Err(DbError::IndexReadSnapshotActive);
2890 }
2891 if let Err(error) = self.connection.execute_batch("BEGIN DEFERRED") {
2892 self.read_snapshot_active.set(false);
2893 return Err(error.into());
2894 }
2895 Ok(())
2896 }
2897
2898 pub fn finish_index_read_snapshot(&self) -> DbResult<()> {
2907 if !self.read_snapshot_active.get() {
2908 return Ok(());
2909 }
2910 self.connection.execute_batch("COMMIT")?;
2911 self.read_snapshot_active.set(false);
2912 Ok(())
2913 }
2914
2915 pub fn index_publication(&self) -> DbResult<Option<IndexPublication>> {
2921 load_index_publication(&self.connection)
2922 }
2923
2924 pub fn replace_symbol_graph(&mut self, graph: &SymbolGraph) -> DbResult<()> {
2930 let metadata = SourceParseMetadata::from_graph(graph);
2931 self.replace_symbol_graph_with_metadata(graph, &metadata)
2932 }
2933
2934 pub fn replace_symbol_graph_with_metadata(
2943 &mut self,
2944 graph: &SymbolGraph,
2945 metadata: &SourceParseMetadata,
2946 ) -> DbResult<()> {
2947 if metadata.path != graph.path
2948 || metadata.language != graph.language
2949 || metadata.symbol_count != graph.symbols.len()
2950 || metadata.relation_count != graph.relations.len()
2951 {
2952 return Err(DbError::SymbolGraphRowShape {
2953 path: graph.path.clone(),
2954 reason: "source parse metadata identity or fact counts differ from the graph",
2955 });
2956 }
2957 let savepoint = self.validated_savepoint()?;
2958 let node_id = {
2959 let mut delete_symbols =
2960 savepoint.prepare_cached("DELETE FROM symbols WHERE path = ?1")?;
2961 let mut delete_relations =
2962 savepoint.prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?;
2963 delete_symbols.execute([&graph.path])?;
2964 delete_relations.execute([&graph.path])?;
2965
2966 let mut upsert_metadata = savepoint.prepare_cached(
2967 "
2968 INSERT INTO source_parse_metadata(
2969 path,
2970 language,
2971 source_parser,
2972 fact_parser,
2973 symbol_count,
2974 relation_count,
2975 updated_at
2976 )
2977 VALUES(?1, ?2, ?3, ?4, ?5, ?6, CURRENT_TIMESTAMP)
2978 ON CONFLICT(path) DO UPDATE SET
2979 language = excluded.language,
2980 source_parser = excluded.source_parser,
2981 fact_parser = excluded.fact_parser,
2982 symbol_count = excluded.symbol_count,
2983 relation_count = excluded.relation_count,
2984 updated_at = CURRENT_TIMESTAMP
2985 ",
2986 )?;
2987 upsert_metadata.execute(params![
2988 metadata.path,
2989 metadata.language.as_deref(),
2990 metadata.parser.to_string(),
2991 graph.parser.to_string(),
2992 usize_to_i64(metadata.symbol_count),
2993 usize_to_i64(metadata.relation_count),
2994 ])?;
2995 let mut select_node = savepoint
2996 .prepare_cached("SELECT id FROM nodes WHERE path = ?1 AND exists_now = 1")?;
2997 let node_id = select_node
2998 .query_row([&graph.path], |row| row.get::<_, i64>(0))
2999 .optional()?;
3000
3001 let mut insert_symbol = savepoint.prepare_cached(
3002 "
3003 INSERT INTO symbols(
3004 path,
3005 language,
3006 name,
3007 kind,
3008 signature,
3009 exported,
3010 documentation,
3011 line_start,
3012 line_end,
3013 source_byte_start,
3014 source_byte_end,
3015 source_column_start,
3016 source_column_end,
3017 parent,
3018 parser,
3019 detail
3020 )
3021 VALUES(
3022 ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
3023 ?13, ?14, ?15, ?16
3024 )
3025 ",
3026 )?;
3027 for symbol in &graph.symbols {
3028 let selector = symbol_source_selector_values(symbol)?;
3029 insert_symbol.execute(params![
3030 symbol.path,
3031 symbol.language.as_deref(),
3032 symbol.name,
3033 symbol.kind.to_string(),
3034 symbol.signature,
3035 symbol.exported,
3036 symbol.documentation.as_deref(),
3037 usize_to_i64(symbol.line_start),
3038 usize_to_i64(symbol.line_end),
3039 selector[0],
3040 selector[1],
3041 selector[2],
3042 selector[3],
3043 symbol.parent.as_deref(),
3044 symbol.parser.to_string(),
3045 symbol.detail.as_deref(),
3046 ])?;
3047 }
3048
3049 let mut insert_relation = savepoint.prepare_cached(
3050 "
3051 INSERT INTO symbol_relations(
3052 path,
3053 source_name,
3054 target_name,
3055 kind,
3056 line,
3057 context,
3058 parser
3059 )
3060 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)
3061 ",
3062 )?;
3063 for relation in &graph.relations {
3064 insert_relation.execute(params![
3065 relation.path,
3066 relation.source_name,
3067 relation.target_name,
3068 relation.kind.to_string(),
3069 usize_to_i64(relation.line),
3070 relation.context,
3071 relation.parser.to_string(),
3072 ])?;
3073 }
3074 node_id
3075 };
3076 if let Some(node_id) = node_id {
3077 replace_symbol_search_summary(
3078 &savepoint,
3079 node_id,
3080 symbol_search_summary(graph).as_deref(),
3081 )?;
3082 }
3083 savepoint.commit()?;
3084 Ok(())
3085 }
3086
3087 pub fn clear_source_index_for_path(&self, path: &str) -> DbResult<()> {
3096 self.with_validated_write(|connection| {
3097 let node_id = self.node_id_for_path(path)?;
3098 connection
3099 .prepare_cached("DELETE FROM symbols WHERE path = ?1")?
3100 .execute([path])?;
3101 connection
3102 .prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?
3103 .execute([path])?;
3104 connection
3105 .prepare_cached("DELETE FROM source_parse_metadata WHERE path = ?1")?
3106 .execute([path])?;
3107 connection
3108 .prepare_cached(
3109 "
3110 DELETE FROM summaries
3111 WHERE node_id = ?1
3112 AND (
3113 (summary_level = 'node' AND subject = '')
3114 OR (summary_level = 'search' AND subject = 'symbols')
3115 )
3116 ",
3117 )?
3118 .execute([node_id])?;
3119 Ok(())
3120 })
3121 }
3122
3123 pub fn clear_symbol_graph_for_path(&self, path: &str) -> DbResult<()> {
3129 self.with_validated_write(|connection| {
3130 let node_id = self.node_id_for_path(path)?;
3131 connection
3132 .prepare_cached("DELETE FROM symbols WHERE path = ?1")?
3133 .execute([path])?;
3134 connection
3135 .prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?
3136 .execute([path])?;
3137 connection
3138 .prepare_cached("DELETE FROM source_parse_metadata WHERE path = ?1")?
3139 .execute([path])?;
3140 connection
3141 .prepare_cached(
3142 "
3143 DELETE FROM summaries
3144 WHERE node_id = ?1
3145 AND summary_level = 'search'
3146 AND subject = 'symbols'
3147 ",
3148 )?
3149 .execute([node_id])?;
3150 Ok(())
3151 })
3152 }
3153
3154 pub fn set_node_summary(&self, path: &str, summary: &str) -> DbResult<()> {
3160 self.with_validated_write(|connection| {
3161 let node_id = self.node_id_for_path(path)?;
3162 connection
3163 .prepare_cached(
3164 "
3165 INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
3166 VALUES(?1, 'node', '', ?2, CURRENT_TIMESTAMP)
3167 ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
3168 summary_level = 'node',
3169 subject = '',
3170 summary = excluded.summary,
3171 updated_at = CURRENT_TIMESTAMP
3172 ",
3173 )?
3174 .execute(params![node_id, summary])?;
3175 Ok(())
3176 })
3177 }
3178
3179 pub fn clear_node_summary(&self, path: &str) -> DbResult<()> {
3185 self.with_validated_write(|connection| {
3186 let node_id = self.node_id_for_path(path)?;
3187 connection
3188 .prepare_cached(
3189 "
3190 DELETE FROM summaries
3191 WHERE node_id = ?1
3192 AND summary_level = 'node'
3193 AND subject = ''
3194 ",
3195 )?
3196 .execute([node_id])?;
3197 Ok(())
3198 })
3199 }
3200
3201 pub fn load_symbols(
3207 &self,
3208 file: Option<&str>,
3209 query: Option<&str>,
3210 limit: usize,
3211 ) -> DbResult<Vec<CodeSymbol>> {
3212 let max_rows = usize_to_i64(limit.max(1));
3213 match (file, query) {
3214 (Some(file), Some(query)) => self.query_symbols(
3215 "
3216 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3217 source_byte_start, source_byte_end, source_column_start, source_column_end
3218 FROM symbols
3219 WHERE path = ?1 AND (name LIKE ?2 OR signature LIKE ?2 OR documentation LIKE ?2)
3220 AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')
3221 ORDER BY path, line_start, name
3222 LIMIT ?3
3223 ",
3224 params![file, like_query(query), max_rows],
3225 ),
3226 (Some(file), None) => self.query_symbols(
3227 "
3228 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3229 source_byte_start, source_byte_end, source_column_start, source_column_end
3230 FROM symbols
3231 WHERE path = ?1
3232 AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')
3233 ORDER BY path, line_start, name
3234 LIMIT ?2
3235 ",
3236 params![file, max_rows],
3237 ),
3238 (None, Some(query)) => self.query_symbols(
3239 "
3240 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3241 source_byte_start, source_byte_end, source_column_start, source_column_end
3242 FROM symbols
3243 WHERE (name LIKE ?1 OR signature LIKE ?1 OR documentation LIKE ?1 OR path LIKE ?1)
3244 AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')
3245 ORDER BY path, line_start, name
3246 LIMIT ?2
3247 ",
3248 params![like_query(query), max_rows],
3249 ),
3250 (None, None) => self.query_symbols(
3251 "
3252 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3253 source_byte_start, source_byte_end, source_column_start, source_column_end
3254 FROM symbols
3255 WHERE NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')
3256 ORDER BY path, line_start, name
3257 LIMIT ?1
3258 ",
3259 params![max_rows],
3260 ),
3261 }
3262 }
3263
3264 pub fn load_classified_symbols(
3273 &self,
3274 file: Option<&str>,
3275 query: Option<&str>,
3276 selection: ContentSelection,
3277 limit: usize,
3278 ) -> DbResult<Vec<ClassifiedSymbol>> {
3279 let (sql, bindings) = classified_symbols_sql(file, query, selection, limit);
3280 let mut statement = self.connection.prepare(&sql)?;
3281 let mut rows = statement.query(params_from_iter(bindings.iter()))?;
3282 let mut symbols = Vec::new();
3283 while let Some(row) = rows.next()? {
3284 symbols.push(classified_symbol_from_row(row)?);
3285 }
3286 Ok(symbols)
3287 }
3288
3289 pub fn load_symbols_for_paths_bounded(
3301 &self,
3302 paths: &[String],
3303 budget: SymbolBatchReadBudget,
3304 control: Option<&IndexWorkControl>,
3305 ) -> DbResult<SymbolBatchRead> {
3306 let path_limit =
3307 usize::try_from(budget.paths()).map_err(|source| DbError::InvalidCount {
3308 field: "symbol batch paths",
3309 value: i64::from(budget.paths()),
3310 source,
3311 })?;
3312 let mut exact_paths = BTreeSet::new();
3313 let mut reached_limit = None;
3314 for path in paths {
3315 if let Some(control) = control {
3316 control.check(IndexWorkStage::RepositoryTraversal)?;
3317 }
3318 exact_paths.insert(path.clone());
3319 if exact_paths.len() > path_limit {
3320 exact_paths.pop_last();
3321 reached_limit.get_or_insert(SymbolBatchReadLimit::Paths);
3322 }
3323 }
3324 if exact_paths.is_empty() {
3325 return Ok(SymbolBatchRead::default());
3326 }
3327 let exact_paths = exact_paths.into_iter().collect::<Vec<_>>();
3328 let row_limit = usize::try_from(budget.rows()).map_err(|source| DbError::InvalidCount {
3329 field: "symbol batch rows",
3330 value: i64::from(budget.rows()),
3331 source,
3332 })?;
3333 let mut symbols = Vec::new();
3334 let mut decoded_bytes = 0_u64;
3335 'chunks: for chunk in exact_paths.chunks(SYMBOL_BATCH_BIND_PATHS) {
3336 if let Some(control) = control {
3337 control.check(IndexWorkStage::RepositoryTraversal)?;
3338 }
3339 let remaining_rows = row_limit.saturating_sub(symbols.len());
3340 if remaining_rows == 0 {
3341 reached_limit.get_or_insert(SymbolBatchReadLimit::Rows);
3342 break;
3343 }
3344 let placeholders = numbered_placeholders(1, chunk.len());
3345 let limit_parameter = chunk.len().saturating_add(1);
3346 let sql = format!(
3347 "
3348 SELECT
3349 path,
3350 language,
3351 name,
3352 kind,
3353 signature,
3354 line_start,
3355 line_end,
3356 parent,
3357 parser,
3358 detail,
3359 exported,
3360 documentation,
3361 source_byte_start,
3362 source_byte_end,
3363 source_column_start,
3364 source_column_end,
3365 length(CAST(path AS BLOB))
3366 + COALESCE(length(CAST(language AS BLOB)), 0)
3367 + length(CAST(name AS BLOB))
3368 + length(CAST(signature AS BLOB))
3369 + COALESCE(length(CAST(parent AS BLOB)), 0)
3370 + COALESCE(length(CAST(detail AS BLOB)), 0)
3371 + COALESCE(length(CAST(documentation AS BLOB)), 0)
3372 FROM symbols
3373 WHERE path IN ({placeholders})
3374 AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')
3375 ORDER BY path, line_start, name
3376 LIMIT ?{limit_parameter}
3377 "
3378 );
3379 let mut bindings = chunk.iter().cloned().map(Value::Text).collect::<Vec<_>>();
3380 bindings.push(Value::Integer(usize_to_i64(
3381 remaining_rows.saturating_add(1),
3382 )));
3383 with_sqlite_read_progress(
3384 &self.connection,
3385 control,
3386 IndexWorkStage::RepositoryTraversal,
3387 || {
3388 let mut statement = self.connection.prepare(&sql)?;
3389 let mut rows = statement.query(params_from_iter(bindings.iter()))?;
3390 while let Some(row) = rows.next()? {
3391 if let Some(control) = control {
3392 control.check(IndexWorkStage::RepositoryTraversal)?;
3393 }
3394 if symbols.len() >= row_limit {
3395 reached_limit.get_or_insert(SymbolBatchReadLimit::Rows);
3396 break;
3397 }
3398 let preflight_bytes = code_symbol_preflight_bytes(row)?;
3399 if decoded_bytes.saturating_add(preflight_bytes) > budget.decoded_bytes() {
3400 reached_limit.get_or_insert(SymbolBatchReadLimit::DecodedBytes);
3401 break;
3402 }
3403 let symbol = code_symbol_from_row(row)?;
3404 let row_bytes = code_symbol_decoded_bytes(&symbol)?;
3405 if decoded_bytes.saturating_add(row_bytes) > budget.decoded_bytes() {
3406 reached_limit.get_or_insert(SymbolBatchReadLimit::DecodedBytes);
3407 break;
3408 }
3409 decoded_bytes = decoded_bytes.saturating_add(row_bytes);
3410 symbols.push(symbol);
3411 }
3412 Ok(())
3413 },
3414 )?;
3415 if matches!(
3416 reached_limit,
3417 Some(SymbolBatchReadLimit::Rows | SymbolBatchReadLimit::DecodedBytes)
3418 ) {
3419 break 'chunks;
3420 }
3421 }
3422 symbols.sort_by(|left, right| {
3423 (&left.path, left.line_start, &left.name, &left.signature).cmp(&(
3424 &right.path,
3425 right.line_start,
3426 &right.name,
3427 &right.signature,
3428 ))
3429 });
3430 let symbols = symbols.into_boxed_slice().into_vec();
3431 Ok(SymbolBatchRead {
3432 work: SymbolBatchReadWork {
3433 requested_paths: u32::try_from(exact_paths.len()).unwrap_or(u32::MAX),
3434 returned_rows: u32::try_from(symbols.len()).unwrap_or(u32::MAX),
3435 decoded_bytes,
3436 },
3437 rows: symbols,
3438 truncated: reached_limit.is_some(),
3439 reached_limit,
3440 })
3441 }
3442
3443 pub fn load_symbols_by_kinds(
3449 &self,
3450 file: &str,
3451 kinds: &[SymbolKind],
3452 limit: usize,
3453 ) -> DbResult<Vec<CodeSymbol>> {
3454 if kinds.is_empty() {
3455 return Ok(Vec::new());
3456 }
3457 let max_rows = usize_to_i64(limit.max(1));
3458 let placeholders = numbered_placeholders(2, kinds.len());
3459 let sql = format!(
3460 "
3461 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3462 source_byte_start, source_byte_end, source_column_start, source_column_end
3463 FROM symbols INDEXED BY idx_symbols_path
3464 WHERE path = ?1 AND kind IN ({placeholders})
3465 ORDER BY path, line_start, name
3466 LIMIT {max_rows}
3467 "
3468 );
3469 let mut values = Vec::with_capacity(kinds.len() + 1);
3470 values.push(file.to_string());
3471 values.extend(kinds.iter().map(ToString::to_string));
3472 self.query_symbols(&sql, params_from_iter(values.iter()))
3473 }
3474
3475 pub fn count_symbols_by_kinds(&self, file: &str, kinds: &[SymbolKind]) -> DbResult<usize> {
3481 if kinds.is_empty() {
3482 return Ok(0);
3483 }
3484 let placeholders = numbered_placeholders(2, kinds.len());
3485 let sql = format!(
3486 "SELECT COUNT(*) FROM symbols INDEXED BY idx_symbols_path
3487 WHERE path = ?1 AND kind IN ({placeholders})"
3488 );
3489 let mut values = Vec::with_capacity(kinds.len() + 1);
3490 values.push(file.to_string());
3491 values.extend(kinds.iter().map(ToString::to_string));
3492 let count = self
3493 .connection
3494 .query_row(&sql, params_from_iter(values.iter()), |row| {
3495 row.get::<_, i64>(0)
3496 })?;
3497 Ok(i64_to_usize(count))
3498 }
3499
3500 pub fn symbol_name_counts(&self, names: &[String]) -> DbResult<HashMap<String, usize>> {
3506 if names.is_empty() {
3507 return Ok(HashMap::new());
3508 }
3509 let placeholders = numbered_placeholders(1, names.len());
3510 let sql = format!(
3511 "SELECT name, COUNT(*) FROM symbols WHERE name IN ({placeholders}) GROUP BY name"
3512 );
3513 let mut statement = self.connection.prepare(&sql)?;
3514 let rows = statement.query_map(params_from_iter(names.iter()), |row| {
3515 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
3516 })?;
3517 let mut counts = HashMap::new();
3518 for row in rows {
3519 let (name, count) = row?;
3520 counts.insert(name, i64_to_usize(count));
3521 }
3522 Ok(counts)
3523 }
3524
3525 pub fn load_symbols_by_names(&self, names: &[String]) -> DbResult<Vec<CodeSymbol>> {
3531 if names.is_empty() {
3532 return Ok(Vec::new());
3533 }
3534 let placeholders = numbered_placeholders(1, names.len());
3535 let sql = format!(
3536 "
3537 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3538 source_byte_start, source_byte_end, source_column_start, source_column_end
3539 FROM symbols
3540 WHERE name IN ({placeholders})
3541 AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')
3542 ORDER BY path, line_start, name
3543 "
3544 );
3545 self.query_symbols(&sql, params_from_iter(names.iter()))
3546 }
3547
3548 pub fn load_exported_symbol_names_for_path(
3554 &self,
3555 file: &str,
3556 limit: usize,
3557 ) -> DbResult<Vec<String>> {
3558 let max_rows = usize_to_i64(limit.max(1));
3559 let mut statement = self.connection.prepare(
3560 "
3561 SELECT DISTINCT name
3562 FROM symbols
3563 WHERE path = ?1 AND exported = 1
3564 ORDER BY name
3565 LIMIT ?2
3566 ",
3567 )?;
3568 let rows = statement.query_map(params![file, max_rows], |row| row.get::<_, String>(0))?;
3569 let mut names = Vec::new();
3570 for row in rows {
3571 names.push(row?);
3572 }
3573 Ok(names)
3574 }
3575
3576 pub fn exported_symbol_count_for_path(&self, file: &str) -> DbResult<usize> {
3582 let count = self.connection.query_row(
3583 "SELECT COUNT(DISTINCT name) FROM symbols WHERE path = ?1 AND exported = 1",
3584 [file],
3585 |row| row.get::<_, i64>(0),
3586 )?;
3587 Ok(i64_to_usize(count))
3588 }
3589
3590 pub fn load_symbol_by_name(&self, file: &str, name: &str) -> DbResult<Option<CodeSymbol>> {
3596 let mut symbols = self.load_symbols(Some(file), Some(name), 100)?;
3597 symbols.retain(|symbol| symbol.name == name);
3598 Ok(symbols.into_iter().next())
3599 }
3600
3601 pub fn load_symbols_by_exact_file_and_name(
3607 &self,
3608 file: &str,
3609 name: &str,
3610 ) -> DbResult<Vec<CodeSymbol>> {
3611 self.query_symbols(
3612 "
3613 SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3614 source_byte_start, source_byte_end, source_column_start, source_column_end
3615 FROM symbols
3616 WHERE path = ?1 AND name = ?2
3617 AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')
3618 ORDER BY line_start, line_end, kind, parent
3619 ",
3620 params![file, name],
3621 )
3622 }
3623
3624 pub fn load_node_by_path(&self, path: &str) -> DbResult<Option<IndexedNode>> {
3630 let mut statement = self.connection.prepare(
3631 "
3632 SELECT
3633 n.path,
3634 n.kind,
3635 n.parent_path,
3636 n.extension,
3637 n.language,
3638 n.size_bytes,
3639 n.mtime_ns,
3640 n.content_hash,
3641 p.purpose,
3642 p.source,
3643 p.status,
3644 s.summary
3645 FROM nodes n
3646 JOIN purposes p ON p.node_id = n.id
3647 LEFT JOIN summaries s ON s.node_id = n.id
3648 AND s.summary_level = 'node'
3649 AND s.subject = ''
3650 WHERE n.exists_now = 1 AND n.path = ?1
3651 ",
3652 )?;
3653 let row = statement
3654 .query_row([path], |row| {
3655 let kind_value: String = row.get(1)?;
3656 let source_value: String = row.get(9)?;
3657 let status_value: String = row.get(10)?;
3658 Ok((
3659 row.get::<_, String>(0)?,
3660 kind_value,
3661 row.get::<_, Option<String>>(2)?,
3662 row.get::<_, Option<String>>(3)?,
3663 row.get::<_, Option<String>>(4)?,
3664 option_u64_from_sql(row, 5)?,
3665 row.get::<_, Option<i64>>(6)?,
3666 row.get::<_, Option<String>>(7)?,
3667 row.get::<_, Option<String>>(8)?,
3668 source_value,
3669 status_value,
3670 row.get::<_, Option<String>>(11)?,
3671 ))
3672 })
3673 .optional()?;
3674 row.map(indexed_node_from_parts).transpose()
3675 }
3676
3677 pub fn load_nodes_by_paths(&self, paths: &[String]) -> DbResult<Vec<IndexedNode>> {
3683 self.load_nodes_by_paths_controlled(paths, None)
3684 }
3685
3686 pub fn load_nodes_by_paths_controlled(
3692 &self,
3693 paths: &[String],
3694 control: Option<&IndexWorkControl>,
3695 ) -> DbResult<Vec<IndexedNode>> {
3696 let mut unique_paths = paths.to_vec();
3697 unique_paths.sort();
3698 unique_paths.dedup();
3699 if unique_paths.is_empty() {
3700 return Ok(Vec::new());
3701 }
3702 let mut nodes = Vec::new();
3703 for chunk in unique_paths.chunks(MAX_PURPOSE_CURATION_BATCH_ROWS) {
3704 let hydrated = with_sqlite_read_progress(
3705 &self.connection,
3706 control,
3707 IndexWorkStage::RepositoryTraversal,
3708 || {
3709 let sql = load_nodes_by_paths_sql(chunk.len());
3710 let mut statement = self.connection.prepare(&sql)?;
3711 let rows = statement.query_map(params_from_iter(chunk), |row| {
3712 let kind_value: String = row.get(1)?;
3713 let source_value: String = row.get(9)?;
3714 let status_value: String = row.get(10)?;
3715 Ok((
3716 row.get::<_, String>(0)?,
3717 kind_value,
3718 row.get::<_, Option<String>>(2)?,
3719 row.get::<_, Option<String>>(3)?,
3720 row.get::<_, Option<String>>(4)?,
3721 option_u64_from_sql(row, 5)?,
3722 row.get::<_, Option<i64>>(6)?,
3723 row.get::<_, Option<String>>(7)?,
3724 row.get::<_, Option<String>>(8)?,
3725 source_value,
3726 status_value,
3727 row.get::<_, Option<String>>(11)?,
3728 ))
3729 })?;
3730 let mut selected = Vec::new();
3731 for row in rows {
3732 selected.push(indexed_node_from_parts(row?)?);
3733 }
3734 Ok(selected)
3735 },
3736 )?;
3737 nodes.extend(hydrated);
3738 }
3739 Ok(nodes)
3740 }
3741
3742 pub fn load_purpose_owner_nodes_by_paths_controlled(
3757 &self,
3758 project: ProjectInstanceId,
3759 generation: IndexGeneration,
3760 paths: &[String],
3761 budget: RepositoryGraphReadBudget,
3762 control: Option<&IndexWorkControl>,
3763 ) -> DbResult<RepositoryGraphReadBatch<IndexedNode>> {
3764 if paths
3765 .iter()
3766 .map(String::as_str)
3767 .collect::<HashSet<_>>()
3768 .len()
3769 != paths.len()
3770 {
3771 return Err(GraphContractError::InvalidLimits {
3772 reason: "purpose-owner hydration paths must be unique",
3773 }
3774 .into());
3775 }
3776 self.require_repository_graph_snapshot(project, generation)?;
3777 let mut meter = repository_graph::RepositoryGraphReadMeter::new(budget, paths.len())?;
3778 let path_count =
3779 u32::try_from(paths.len()).map_err(|_source| GraphContractError::InvalidLimits {
3780 reason: "purpose-owner hydration path count overflowed",
3781 })?;
3782 if path_count > budget.returned_rows() {
3783 return Err(GraphContractError::InvalidLimits {
3784 reason: "purpose-owner paths exceed the return budget",
3785 }
3786 .into());
3787 }
3788 if path_count > budget.hydrated_paths() {
3789 return Err(GraphContractError::InvalidLimits {
3790 reason: "purpose-owner paths exceed the hydration budget",
3791 }
3792 .into());
3793 }
3794 if paths.is_empty() {
3795 return Ok(RepositoryGraphReadBatch {
3796 rows: Vec::new(),
3797 work: meter.finish(0)?,
3798 });
3799 }
3800
3801 let mut selected = HashMap::with_capacity(paths.len());
3802 for chunk in paths.chunks(MAX_PURPOSE_CURATION_BATCH_ROWS) {
3803 let hydrated = with_sqlite_read_progress(
3804 &self.connection,
3805 control,
3806 IndexWorkStage::RepositoryTraversal,
3807 || {
3808 let sql = load_nodes_by_paths_sql(chunk.len());
3809 let mut statement = self.connection.prepare(&sql)?;
3810 let mut rows = statement.query(params_from_iter(chunk))?;
3811 let mut nodes = Vec::new();
3812 while let Some(row) = rows.next()? {
3813 let parts = indexed_node_parts_from_sql_row(row)?;
3814 meter.record_decoded_bytes(indexed_node_parts_decoded_bytes(&parts)?)?;
3815 let node = indexed_node_from_parts(parts)?;
3816 meter.record_hydrated_path(&node.node.path)?;
3817 nodes.push(node);
3818 }
3819 Ok(nodes)
3820 },
3821 )?;
3822 for node in hydrated {
3823 let path = node.node.path.clone();
3824 if selected.insert(path, node).is_some() {
3825 return Err(DbError::GraphRowShape {
3826 table: "nodes",
3827 reason: "purpose-owner hydration returned a duplicate path",
3828 });
3829 }
3830 }
3831 }
3832
3833 let mut ordered = Vec::with_capacity(paths.len());
3834 for path in paths {
3835 if let Some(node) = selected.remove(path) {
3836 ordered.push(node);
3837 }
3838 }
3839 let work = meter.finish(ordered.len())?;
3840 Ok(RepositoryGraphReadBatch {
3841 rows: ordered,
3842 work,
3843 })
3844 }
3845
3846 pub fn load_purpose_curation_batch(
3856 &self,
3857 task: &str,
3858 paths: &[String],
3859 ) -> DbResult<PurposeCurationBatch> {
3860 let task = normalize_purpose_curation_task(task)?;
3861 let mut unique_paths = paths.to_vec();
3862 unique_paths.sort();
3863 unique_paths.dedup();
3864 if unique_paths.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
3865 return Err(DbError::PurposeCurationBatchTooLarge {
3866 requested: unique_paths.len(),
3867 maximum: MAX_PURPOSE_CURATION_BATCH_ROWS,
3868 });
3869 }
3870 let (project_instance_id, active_generation) =
3871 self.purpose_curation_context(&self.connection)?;
3872 let items = self
3873 .load_nodes_by_paths(&unique_paths)?
3874 .into_iter()
3875 .filter(|node| {
3876 matches!(
3877 node.purpose.status,
3878 PurposeStatus::Missing | PurposeStatus::Suggested
3879 )
3880 })
3881 .map(|node| {
3882 purpose_curation_candidate(project_instance_id, active_generation, &task, node)
3883 })
3884 .collect::<Vec<_>>();
3885 let work_key =
3886 purpose_curation_batch_work_key(project_instance_id, active_generation, &task, &items);
3887 Ok(PurposeCurationBatch {
3888 project_instance_id,
3889 active_generation,
3890 task,
3891 work_key,
3892 items,
3893 })
3894 }
3895
3896 pub fn load_symbol_relations(
3902 &self,
3903 file: Option<&str>,
3904 query: Option<&str>,
3905 limit: usize,
3906 ) -> DbResult<Vec<SymbolRelation>> {
3907 let max_rows = usize_to_i64(limit.max(1));
3908 match (file, query) {
3909 (Some(file), Some(query)) => self.query_relations(
3910 "
3911 SELECT path, source_name, target_name, kind, line, context, parser
3912 FROM symbol_relations
3913 WHERE path = ?1 AND (source_name LIKE ?2 OR target_name LIKE ?2 OR context LIKE ?2)
3914 ORDER BY path, line, source_name, target_name
3915 LIMIT ?3
3916 ",
3917 params![file, like_query(query), max_rows],
3918 ),
3919 (Some(file), None) => self.query_relations(
3920 "
3921 SELECT path, source_name, target_name, kind, line, context, parser
3922 FROM symbol_relations
3923 WHERE path = ?1
3924 ORDER BY path, line, source_name, target_name
3925 LIMIT ?2
3926 ",
3927 params![file, max_rows],
3928 ),
3929 (None, Some(query)) => self.query_relations(
3930 "
3931 SELECT path, source_name, target_name, kind, line, context, parser
3932 FROM symbol_relations
3933 WHERE source_name LIKE ?1 OR target_name LIKE ?1 OR context LIKE ?1 OR path LIKE ?1
3934 ORDER BY path, line, source_name, target_name
3935 LIMIT ?2
3936 ",
3937 params![like_query(query), max_rows],
3938 ),
3939 (None, None) => self.query_relations(
3940 "
3941 SELECT path, source_name, target_name, kind, line, context, parser
3942 FROM symbol_relations
3943 ORDER BY path, line, source_name, target_name
3944 LIMIT ?1
3945 ",
3946 params![max_rows],
3947 ),
3948 }
3949 }
3950
3951 pub fn load_symbol_relations_by_kind(
3957 &self,
3958 file: &str,
3959 kind: RelationKind,
3960 limit: usize,
3961 ) -> DbResult<Vec<SymbolRelation>> {
3962 let max_rows = usize_to_i64(limit.max(1));
3963 self.query_relations(
3964 "
3965 SELECT path, source_name, target_name, kind, line, context, parser
3966 FROM symbol_relations
3967 WHERE path = ?1 AND kind = ?2
3968 ORDER BY path, line, source_name, target_name
3969 LIMIT ?3
3970 ",
3971 params![file, kind.to_string(), max_rows],
3972 )
3973 }
3974
3975 pub fn count_symbol_relations_by_kind(
3981 &self,
3982 file: &str,
3983 kind: RelationKind,
3984 ) -> DbResult<usize> {
3985 let count = self.connection.query_row(
3986 "SELECT COUNT(*) FROM symbol_relations WHERE path = ?1 AND kind = ?2",
3987 params![file, kind.to_string()],
3988 |row| row.get::<_, i64>(0),
3989 )?;
3990 Ok(i64_to_usize(count))
3991 }
3992
3993 pub fn load_distinct_relation_targets_by_kind(
3999 &self,
4000 file: &str,
4001 kind: RelationKind,
4002 limit: usize,
4003 ) -> DbResult<Vec<String>> {
4004 let max_rows = usize_to_i64(limit.max(1));
4005 let mut statement = self.connection.prepare(
4006 "
4007 SELECT DISTINCT target_name
4008 FROM symbol_relations
4009 WHERE path = ?1 AND kind = ?2
4010 ORDER BY target_name
4011 LIMIT ?3
4012 ",
4013 )?;
4014 let rows = statement.query_map(params![file, kind.to_string(), max_rows], |row| {
4015 row.get::<_, String>(0)
4016 })?;
4017 let mut targets = Vec::new();
4018 for row in rows {
4019 targets.push(row?);
4020 }
4021 Ok(targets)
4022 }
4023
4024 pub fn count_distinct_relation_targets_by_kind(
4030 &self,
4031 file: &str,
4032 kind: RelationKind,
4033 ) -> DbResult<usize> {
4034 let count = self.connection.query_row(
4035 "SELECT COUNT(DISTINCT target_name) FROM symbol_relations WHERE path = ?1 AND kind = ?2",
4036 params![file, kind.to_string()],
4037 |row| row.get::<_, i64>(0),
4038 )?;
4039 Ok(i64_to_usize(count))
4040 }
4041
4042 pub fn load_call_relations_to_targets(
4048 &self,
4049 target_names: &[String],
4050 limit_per_target: usize,
4051 ) -> DbResult<Vec<SymbolRelation>> {
4052 if target_names.is_empty() {
4053 return Ok(Vec::new());
4054 }
4055 let placeholders = numbered_placeholders(1, target_names.len());
4056 let limit_placeholder = target_names.len() + 1;
4057 let sql = format!(
4058 "
4059 SELECT path, source_name, target_name, kind, line, context, parser
4060 FROM (
4061 SELECT
4062 path,
4063 source_name,
4064 target_name,
4065 kind,
4066 line,
4067 context,
4068 parser,
4069 ROW_NUMBER() OVER (
4070 PARTITION BY target_name
4071 ORDER BY path, line, source_name, target_name
4072 ) AS target_row
4073 FROM symbol_relations INDEXED BY idx_symbol_relations_target
4074 WHERE kind = 'calls' AND target_name IN ({placeholders})
4075 )
4076 WHERE target_row <= ?{limit_placeholder}
4077 ORDER BY path, line, source_name, target_name
4078 "
4079 );
4080 let mut values = target_names
4081 .iter()
4082 .map(|target| Value::Text(target.clone()))
4083 .collect::<Vec<_>>();
4084 values.push(Value::Integer(usize_to_i64(limit_per_target.max(1))));
4085 let mut relations = self.query_relations(&sql, params_from_iter(values.iter()))?;
4086 relations.sort_by(|left, right| {
4087 left.path
4088 .cmp(&right.path)
4089 .then_with(|| left.line.cmp(&right.line))
4090 .then_with(|| left.source_name.cmp(&right.source_name))
4091 .then_with(|| left.target_name.cmp(&right.target_name))
4092 });
4093 relations.dedup_by(|left, right| {
4094 left.path == right.path
4095 && left.source_name == right.source_name
4096 && left.target_name == right.target_name
4097 && left.kind == right.kind
4098 && left.line == right.line
4099 });
4100 Ok(relations)
4101 }
4102
4103 pub fn load_import_relations_matching_targets(
4109 &self,
4110 terms: &[String],
4111 limit_per_term: usize,
4112 ) -> DbResult<Vec<StoredImportRelation>> {
4113 let mut unique_terms = terms.to_vec();
4114 unique_terms.sort();
4115 unique_terms.dedup();
4116 let mut relations = Vec::new();
4117 for term in unique_terms.iter().filter(|term| !term.trim().is_empty()) {
4118 let mut statement = self.connection.prepare_cached(
4119 "
4120 SELECT path, source_name, target_name, line
4121 FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
4122 WHERE kind = 'imports' AND target_name LIKE ?1 ESCAPE '\\'
4123 ORDER BY path, line, source_name, target_name
4124 LIMIT ?2
4125 ",
4126 )?;
4127 let rows = statement.query_map(
4128 params![
4129 sqlite_like_pattern(term),
4130 usize_to_i64(limit_per_term.max(1))
4131 ],
4132 stored_import_relation_from_row,
4133 )?;
4134 for row in rows {
4135 relations.push(row?);
4136 }
4137 }
4138 relations.sort_by(|left, right| {
4139 left.path
4140 .cmp(&right.path)
4141 .then_with(|| left.line.cmp(&right.line))
4142 .then_with(|| left.source_name.cmp(&right.source_name))
4143 .then_with(|| left.target_name.cmp(&right.target_name))
4144 });
4145 relations.dedup_by(|left, right| {
4146 left.path == right.path
4147 && left.source_name == right.source_name
4148 && left.target_name == right.target_name
4149 && left.line == right.line
4150 });
4151 Ok(relations)
4152 }
4153
4154 pub fn load_import_relations_for_path(
4160 &self,
4161 path: &str,
4162 limit: usize,
4163 ) -> DbResult<Vec<StoredImportRelation>> {
4164 let mut statement = self.connection.prepare_cached(
4165 "
4166 SELECT path, source_name, target_name, line
4167 FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
4168 WHERE kind = 'imports' AND path = ?1
4169 ORDER BY path, line, source_name, target_name
4170 LIMIT ?2
4171 ",
4172 )?;
4173 let rows = statement.query_map(
4174 params![path, usize_to_i64(limit.max(1))],
4175 stored_import_relation_from_row,
4176 )?;
4177 let mut relations = Vec::new();
4178 for row in rows {
4179 relations.push(row?);
4180 }
4181 Ok(relations)
4182 }
4183
4184 pub fn symbol_count(&self) -> DbResult<usize> {
4190 let count = self
4191 .connection
4192 .query_row("SELECT COUNT(*) FROM symbols WHERE NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')", [], |row| {
4193 row.get::<_, i64>(0)
4194 })?;
4195 Ok(i64_to_usize(count))
4196 }
4197
4198 pub fn symbol_relation_count(&self) -> DbResult<usize> {
4204 let count =
4205 self.connection
4206 .query_row("SELECT COUNT(*) FROM symbol_relations", [], |row| {
4207 row.get::<_, i64>(0)
4208 })?;
4209 count_to_usize("symbol_relations", count)
4210 }
4211
4212 pub fn symbol_count_for_path(&self, path: &str) -> DbResult<usize> {
4218 let count = self.connection.query_row(
4219 "SELECT COUNT(*) FROM symbols WHERE path = ?1 AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')",
4220 [path],
4221 |row| row.get::<_, i64>(0),
4222 )?;
4223 Ok(i64_to_usize(count))
4224 }
4225
4226 pub fn symbol_counts_for_paths(&self, paths: &[String]) -> DbResult<HashMap<String, usize>> {
4234 let mut counts = HashMap::new();
4235 for chunk in paths.chunks(900) {
4236 if chunk.is_empty() {
4237 continue;
4238 }
4239 let placeholders = vec!["?"; chunk.len()].join(",");
4240 let sql = format!(
4241 "SELECT path, COUNT(*) FROM symbols WHERE path IN ({placeholders}) AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage') GROUP BY path"
4242 );
4243 let mut statement = self.connection.prepare(&sql)?;
4244 let rows = statement.query_map(params_from_iter(chunk.iter()), |row| {
4245 let path = row.get::<_, String>(0)?;
4246 let count = row.get::<_, i64>(1)?;
4247 Ok((path, i64_to_usize(count)))
4248 })?;
4249 for row in rows {
4250 let (path, count) = row?;
4251 counts.insert(path, count);
4252 }
4253 }
4254 Ok(counts)
4255 }
4256
4257 pub fn source_parse_metadata_paths_for_paths(
4263 &self,
4264 paths: &[String],
4265 ) -> DbResult<HashSet<String>> {
4266 let mut indexed = HashSet::new();
4267 for chunk in paths.chunks(900) {
4268 if chunk.is_empty() {
4269 continue;
4270 }
4271 let placeholders = numbered_placeholders(1, chunk.len());
4272 let sql = format!(
4273 "SELECT path FROM source_parse_metadata
4274 WHERE path IN ({placeholders})
4275 ORDER BY path"
4276 );
4277 let mut statement = self.connection.prepare(&sql)?;
4278 let rows = statement.query_map(params_from_iter(chunk.iter()), |row| {
4279 row.get::<_, String>(0)
4280 })?;
4281 for row in rows {
4282 indexed.insert(row?);
4283 }
4284 }
4285 Ok(indexed)
4286 }
4287
4288 pub fn symbol_parser_kinds_for_path(&self, path: &str) -> DbResult<Vec<ParserKind>> {
4294 let mut statement = self.connection.prepare(
4295 "
4296 SELECT DISTINCT parser
4297 FROM symbols
4298 WHERE path = ?1
4299 ORDER BY parser
4300 ",
4301 )?;
4302 let rows = statement.query_map([path], |row| {
4303 Ok(ParserKind::from_db(&row.get::<_, String>(0)?))
4304 })?;
4305 let mut parsers = Vec::new();
4306 for row in rows {
4307 parsers.push(row?);
4308 }
4309 Ok(parsers)
4310 }
4311
4312 pub fn load_symbol_graphs_for_paths(&self, paths: &[String]) -> DbResult<Vec<SymbolGraph>> {
4323 const PATHS_PER_QUERY: usize = 900;
4324
4325 let mut paths = paths.to_vec();
4326 paths.sort();
4327 paths.dedup();
4328 let mut graphs = Vec::with_capacity(paths.len());
4329 for chunk in paths.chunks(PATHS_PER_QUERY) {
4330 let placeholders = numbered_placeholders(1, chunk.len());
4331 let metadata_sql = format!(
4332 "SELECT path, language, source_parser, fact_parser, symbol_count, relation_count
4333 FROM source_parse_metadata
4334 WHERE path IN ({placeholders})
4335 ORDER BY path"
4336 );
4337 let mut metadata_statement = self.connection.prepare(&metadata_sql)?;
4338 let metadata_rows =
4339 metadata_statement.query_map(params_from_iter(chunk.iter()), |row| {
4340 Ok((
4341 row.get::<_, String>(0)?,
4342 row.get::<_, Option<String>>(1)?,
4343 row.get::<_, String>(2)?,
4344 row.get::<_, String>(3)?,
4345 row.get::<_, i64>(4)?,
4346 row.get::<_, i64>(5)?,
4347 ))
4348 })?;
4349 let mut staged = BTreeMap::new();
4350 for row in metadata_rows {
4351 let (path, language, source_parser, fact_parser, symbol_count, relation_count) =
4352 row?;
4353 staged.insert(
4354 path.clone(),
4355 (
4356 SourceParseMetadata {
4357 path,
4358 language,
4359 parser: ParserKind::from_db(&source_parser),
4360 symbol_count: count_to_usize(
4361 "source_parse_metadata.symbol_count",
4362 symbol_count,
4363 )?,
4364 relation_count: count_to_usize(
4365 "source_parse_metadata.relation_count",
4366 relation_count,
4367 )?,
4368 },
4369 ParserKind::from_db(&fact_parser),
4370 Vec::new(),
4371 Vec::new(),
4372 ),
4373 );
4374 }
4375
4376 let symbol_sql = format!(
4377 "SELECT path, language, name, kind, signature, line_start, line_end,
4378 parent, parser, detail, exported, documentation,
4379 source_byte_start, source_byte_end,
4380 source_column_start, source_column_end
4381 FROM symbols
4382 WHERE path IN ({placeholders})
4383 ORDER BY path, line_start, line_end, name, kind"
4384 );
4385 for symbol in self.query_symbols(&symbol_sql, params_from_iter(chunk.iter()))? {
4386 let path = symbol.path.clone();
4387 let Some((_, _, symbols, _)) = staged.get_mut(&path) else {
4388 return Err(DbError::SymbolGraphRowShape {
4389 path,
4390 reason: "symbol rows require matching parser metadata",
4391 });
4392 };
4393 symbols.push(symbol);
4394 }
4395
4396 let relation_sql = format!(
4397 "SELECT path, source_name, target_name, kind, line, context, parser
4398 FROM symbol_relations
4399 WHERE path IN ({placeholders})
4400 ORDER BY path, line, source_name, target_name, kind"
4401 );
4402 for relation in self.query_relations(&relation_sql, params_from_iter(chunk.iter()))? {
4403 let path = relation.path.clone();
4404 let Some((_, _, _, relations)) = staged.get_mut(&path) else {
4405 return Err(DbError::SymbolGraphRowShape {
4406 path,
4407 reason: "relation rows require matching parser metadata",
4408 });
4409 };
4410 relations.push(relation);
4411 }
4412
4413 for (path, (metadata, fact_parser, symbols, relations)) in staged {
4414 if metadata.symbol_count != symbols.len()
4415 || metadata.relation_count != relations.len()
4416 {
4417 return Err(DbError::SymbolGraphRowShape {
4418 path,
4419 reason: "parser metadata counts do not match persisted rows",
4420 });
4421 }
4422 graphs.push(SymbolGraph {
4423 path: metadata.path,
4424 language: metadata.language,
4425 parser: fact_parser,
4426 symbols,
4427 relations,
4428 });
4429 }
4430 }
4431 Ok(graphs)
4432 }
4433
4434 pub fn load_source_parse_metadata(&self, path: &str) -> DbResult<Option<SourceParseMetadata>> {
4440 self.connection
4441 .query_row(
4442 "
4443 SELECT path, language, source_parser, symbol_count, relation_count
4444 FROM source_parse_metadata
4445 WHERE path = ?1
4446 ",
4447 [path],
4448 |row| {
4449 let symbol_count = row.get::<_, i64>(3)?;
4450 let relation_count = row.get::<_, i64>(4)?;
4451 Ok(SourceParseMetadata {
4452 path: row.get(0)?,
4453 language: row.get(1)?,
4454 parser: ParserKind::from_db(&row.get::<_, String>(2)?),
4455 symbol_count: i64_to_usize(symbol_count),
4456 relation_count: i64_to_usize(relation_count),
4457 })
4458 },
4459 )
4460 .optional()
4461 .map_err(Into::into)
4462 }
4463
4464 pub fn max_symbol_end_line_for_path(&self, path: &str) -> DbResult<usize> {
4470 let line = self.connection.query_row(
4471 "SELECT COALESCE(MAX(line_end), 0) FROM symbols WHERE path = ?1 AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')",
4472 [path],
4473 |row| row.get::<_, i64>(0),
4474 )?;
4475 Ok(i64_to_usize(line))
4476 }
4477
4478 fn query_symbols<P>(&self, sql: &str, params: P) -> DbResult<Vec<CodeSymbol>>
4480 where
4481 P: rusqlite::Params,
4482 {
4483 let mut statement = self.connection.prepare(sql)?;
4484 let rows = statement.query_map(params, code_symbol_from_row)?;
4485 let mut symbols = Vec::new();
4486 for row in rows {
4487 symbols.push(row?);
4488 }
4489 Ok(symbols)
4490 }
4491
4492 fn query_relations<P>(&self, sql: &str, params: P) -> DbResult<Vec<SymbolRelation>>
4494 where
4495 P: rusqlite::Params,
4496 {
4497 let mut statement = self.connection.prepare(sql)?;
4498 let rows = statement.query_map(params, |row| {
4499 let kind_value: String = row.get(3)?;
4500 let relation_kind = RelationKind::from_db(&kind_value).ok_or_else(|| {
4501 rusqlite::Error::FromSqlConversionFailure(
4502 3,
4503 rusqlite::types::Type::Text,
4504 Box::new(std::io::Error::other(format!(
4505 "invalid relation kind {kind_value}"
4506 ))),
4507 )
4508 })?;
4509 Ok(SymbolRelation {
4510 path: row.get(0)?,
4511 source_name: row.get(1)?,
4512 target_name: row.get(2)?,
4513 kind: relation_kind,
4514 line: i64_to_usize(row.get::<_, i64>(4)?),
4515 context: row.get(5)?,
4516 parser: ParserKind::from_db(&row.get::<_, String>(6)?),
4517 })
4518 })?;
4519 let mut relations = Vec::new();
4520 for row in rows {
4521 relations.push(row?);
4522 }
4523 Ok(relations)
4524 }
4525
4526 pub fn has_agent_approved_purpose(&self) -> DbResult<bool> {
4532 self.connection
4533 .query_row(
4534 "SELECT EXISTS(
4535 SELECT 1
4536 FROM purposes INDEXED BY idx_purposes_status
4537 WHERE status = 'approved' AND source = 'agent'
4538 LIMIT 1
4539 )",
4540 [],
4541 |row| row.get(0),
4542 )
4543 .map_err(Into::into)
4544 }
4545
4546 pub fn set_purpose(&self, path: &str, purpose: &str, source: PurposeSource) -> DbResult<()> {
4552 self.with_validated_write(|connection| {
4553 let node_id = self.node_id_for_path(path)?;
4554 let changed = connection
4555 .prepare_cached(
4556 "
4557 INSERT INTO purposes(node_id, purpose, source, status, updated_at)
4558 VALUES(?1, ?2, ?3, 'approved', CURRENT_TIMESTAMP)
4559 ON CONFLICT(node_id) DO UPDATE SET
4560 purpose = excluded.purpose,
4561 source = excluded.source,
4562 status = 'approved',
4563 updated_at = CURRENT_TIMESTAMP
4564 WHERE purposes.purpose <> excluded.purpose
4565 OR purposes.source <> excluded.source
4566 OR purposes.status <> 'approved'
4567 ",
4568 )?
4569 .execute(params![node_id, purpose, source.to_string()])?;
4570 if changed > 0 {
4571 advance_authored_purpose_revision(connection)?;
4572 }
4573 Ok(())
4574 })
4575 }
4576
4577 pub fn authored_purpose_revision(&self) -> DbResult<u64> {
4586 load_authored_purpose_revision(&self.connection)
4587 }
4588
4589 pub fn conditionally_set_purpose(
4600 &self,
4601 task: &str,
4602 path: &str,
4603 work_key: &str,
4604 state_token: &str,
4605 purpose: &str,
4606 ) -> DbResult<PurposeConditionalApplyState> {
4607 let request = PurposeConditionalApplyRequest {
4608 task: task.to_string(),
4609 path: path.to_string(),
4610 work_key: work_key.to_string(),
4611 state_token: state_token.to_string(),
4612 purpose: purpose.to_string(),
4613 };
4614 let mut results = self.conditionally_set_purposes(&[request])?;
4615 Ok(results
4616 .pop()
4617 .map_or(PurposeConditionalApplyState::PathUnavailable, |result| {
4618 result.state
4619 }))
4620 }
4621
4622 pub fn conditionally_set_purposes(
4634 &self,
4635 requests: &[PurposeConditionalApplyRequest],
4636 ) -> DbResult<Vec<PurposeConditionalApplyResult>> {
4637 if requests.is_empty() {
4638 return Ok(Vec::new());
4639 }
4640 if requests.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
4641 return Err(DbError::PurposeCurationBatchTooLarge {
4642 requested: requests.len(),
4643 maximum: MAX_PURPOSE_CURATION_BATCH_ROWS,
4644 });
4645 }
4646 let normalized = requests
4647 .iter()
4648 .map(|request| {
4649 normalize_purpose_curation_task(&request.task).map(|task| {
4650 PurposeConditionalApplyRequest {
4651 task,
4652 path: request.path.clone(),
4653 work_key: request.work_key.clone(),
4654 state_token: request.state_token.clone(),
4655 purpose: request.purpose.clone(),
4656 }
4657 })
4658 })
4659 .collect::<DbResult<Vec<_>>>()?;
4660 self.with_validated_write(|connection| {
4661 let (project_instance_id, active_generation) =
4662 self.purpose_curation_context(connection)?;
4663 let results = normalized
4664 .iter()
4665 .map(|request| {
4666 apply_conditional_purpose(
4667 connection,
4668 project_instance_id,
4669 active_generation,
4670 request,
4671 )
4672 })
4673 .collect::<DbResult<Vec<_>>>()?;
4674 if results
4675 .iter()
4676 .any(|result| matches!(result.state, PurposeConditionalApplyState::Applied))
4677 {
4678 advance_authored_purpose_revision(connection)?;
4679 }
4680 Ok(results)
4681 })
4682 }
4683
4684 pub fn set_suggested_purpose(&self, path: &str, purpose: &str) -> DbResult<()> {
4690 self.with_validated_write(|connection| {
4691 let node_id = self.node_id_for_path(path)?;
4692 connection
4693 .prepare_cached(
4694 "
4695 INSERT INTO purposes(node_id, purpose, source, status, updated_at)
4696 VALUES(?1, ?2, ?3, ?4, CURRENT_TIMESTAMP)
4697 ON CONFLICT(node_id) DO UPDATE SET
4698 purpose = excluded.purpose,
4699 source = excluded.source,
4700 status = excluded.status,
4701 updated_at = CURRENT_TIMESTAMP
4702 WHERE purposes.status IN (?5, ?6)
4703 ",
4704 )?
4705 .execute(params![
4706 node_id,
4707 purpose,
4708 PurposeSource::Generated.to_string(),
4709 PurposeStatus::Suggested.as_str(),
4710 PurposeStatus::Missing.as_str(),
4711 PurposeStatus::Suggested.as_str(),
4712 ])?;
4713 Ok(())
4714 })
4715 }
4716
4717 fn node_id_for_path(&self, path: &str) -> DbResult<i64> {
4719 self.connection
4720 .prepare_cached("SELECT id FROM nodes WHERE path = ?1 AND exists_now = 1")?
4721 .query_row([path], |row| row.get::<_, i64>(0))
4722 .optional()?
4723 .ok_or_else(|| DbError::PathNotIndexed {
4724 path: path.to_string(),
4725 })
4726 }
4727
4728 pub fn visit_source_nodes_controlled(
4739 &self,
4740 control: &IndexWorkControl,
4741 mut visit: impl FnMut(Node) -> DbResult<()>,
4742 ) -> DbResult<()> {
4743 with_sqlite_read_progress(
4744 &self.connection,
4745 Some(control),
4746 IndexWorkStage::RepositoryTraversal,
4747 || {
4748 let mut statement = self.connection.prepare_cached(
4749 "SELECT n.path, n.kind, n.parent_path, n.extension, n.language,
4750 n.size_bytes, n.mtime_ns, n.content_hash
4751 FROM nodes n JOIN purposes p ON p.node_id = n.id
4752 WHERE n.exists_now = 1 ORDER BY n.path",
4753 )?;
4754 let mut rows = statement.query([])?;
4755 while let Some(row) = rows.next()? {
4756 control.check(IndexWorkStage::RepositoryTraversal)?;
4757 let kind_value: String = row.get(1)?;
4758 let kind =
4759 NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
4760 field: "kind",
4761 value: kind_value,
4762 })?;
4763 visit(Node {
4764 path: row.get(0)?,
4765 kind,
4766 parent_path: row.get(2)?,
4767 extension: row.get(3)?,
4768 language: row.get(4)?,
4769 size_bytes: option_u64_from_sql(row, 5)?,
4770 mtime_ns: row.get(6)?,
4771 content_hash: row.get(7)?,
4772 })?;
4773 }
4774 Ok(())
4775 },
4776 )
4777 }
4778
4779 pub fn load_nodes(&self) -> DbResult<Vec<IndexedNode>> {
4785 let mut statement = self.connection.prepare(
4786 "
4787 SELECT
4788 n.path,
4789 n.kind,
4790 n.parent_path,
4791 n.extension,
4792 n.language,
4793 n.size_bytes,
4794 n.mtime_ns,
4795 n.content_hash,
4796 p.purpose,
4797 p.source,
4798 p.status,
4799 s.summary
4800 FROM nodes n
4801 JOIN purposes p ON p.node_id = n.id
4802 LEFT JOIN summaries s ON s.node_id = n.id
4803 AND s.summary_level = 'node'
4804 AND s.subject = ''
4805 WHERE n.exists_now = 1
4806 ORDER BY n.path
4807 ",
4808 )?;
4809 let rows = statement.query_map([], |row| {
4810 let kind_value: String = row.get(1)?;
4811 let source_value: String = row.get(9)?;
4812 let status_value: String = row.get(10)?;
4813 Ok((
4814 row.get::<_, String>(0)?,
4815 kind_value,
4816 row.get::<_, Option<String>>(2)?,
4817 row.get::<_, Option<String>>(3)?,
4818 row.get::<_, Option<String>>(4)?,
4819 option_u64_from_sql(row, 5)?,
4820 row.get::<_, Option<i64>>(6)?,
4821 row.get::<_, Option<String>>(7)?,
4822 row.get::<_, Option<String>>(8)?,
4823 source_value,
4824 status_value,
4825 row.get::<_, Option<String>>(11)?,
4826 ))
4827 })?;
4828 let mut nodes = Vec::new();
4829 for row in rows {
4830 let (
4831 path,
4832 kind_value,
4833 parent_path,
4834 extension,
4835 language,
4836 size_bytes,
4837 mtime_ns,
4838 content_hash,
4839 purpose,
4840 source_value,
4841 status_value,
4842 summary,
4843 ) = row?;
4844 let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
4845 field: "kind",
4846 value: kind_value,
4847 })?;
4848 let source = parse_source(&source_value)?;
4849 let status =
4850 PurposeStatus::from_db(&status_value).ok_or_else(|| DbError::InvalidEnum {
4851 field: "status",
4852 value: status_value,
4853 })?;
4854 nodes.push(IndexedNode {
4855 node: Node {
4856 path: path.clone(),
4857 kind,
4858 parent_path,
4859 extension,
4860 language,
4861 size_bytes,
4862 mtime_ns,
4863 content_hash,
4864 },
4865 purpose: Purpose {
4866 path,
4867 purpose,
4868 source,
4869 status,
4870 },
4871 summary,
4872 });
4873 }
4874 Ok(nodes)
4875 }
4876
4877 pub fn load_ranked_nodes(
4887 &self,
4888 query: &str,
4889 kind: NodeKind,
4890 folder: Option<&str>,
4891 limit: usize,
4892 offset: usize,
4893 ) -> DbResult<Vec<IndexedNode>> {
4894 let terms = normalize_query_terms(query);
4895 let exact_query = normalize_exact_ranked_query(query);
4896 let exact_name_enabled = !exact_query.is_empty() && !exact_query.contains('/');
4897 let exact_name_pattern = format!("%/{}", sqlite_like_escape(&exact_query));
4898 let reviewed_match_expression = reviewed_purpose_match_expression(terms.len());
4899 let score_expression = ranked_score_expression(terms.len());
4900 let mut sql = format!(
4901 "
4902 SELECT path, kind, parent_path, extension, language, size_bytes, mtime_ns,
4903 content_hash, purpose, source, status, summary
4904 FROM (
4905 SELECT
4906 n.path,
4907 n.kind,
4908 n.parent_path,
4909 n.extension,
4910 n.language,
4911 n.size_bytes,
4912 n.mtime_ns,
4913 n.content_hash,
4914 p.purpose,
4915 p.source,
4916 p.status,
4917 s.summary,
4918 CASE WHEN lower(n.path) = ? THEN 1 ELSE 0 END AS exact_path,
4919 CASE WHEN ? = 1
4920 AND (lower(n.path) = ? OR lower(n.path) LIKE ? ESCAPE '\\')
4921 THEN 1 ELSE 0 END AS exact_name,
4922 {reviewed_match_expression} AS reviewed_purpose,
4923 {score_expression} AS score
4924 FROM nodes n
4925 JOIN purposes p ON p.node_id = n.id
4926 LEFT JOIN summaries s ON s.node_id = n.id
4927 AND s.summary_level = 'node'
4928 AND s.subject = ''
4929 LEFT JOIN summaries symbol_summaries ON symbol_summaries.node_id = n.id
4930 AND symbol_summaries.summary_level = 'search'
4931 AND symbol_summaries.subject = 'symbols'
4932 WHERE n.exists_now = 1
4933 AND n.kind = ?
4934 "
4935 );
4936 let mut values = vec![
4937 Value::from(exact_query.clone()),
4938 Value::from(i64::from(exact_name_enabled)),
4939 Value::from(exact_query),
4940 Value::from(exact_name_pattern),
4941 ];
4942 for term in &terms {
4943 values.push(Value::from(sqlite_like_pattern(term)));
4944 }
4945 for term in &terms {
4946 let pattern = sqlite_like_pattern(term);
4947 values.push(Value::from(pattern.clone()));
4948 values.push(Value::from(pattern.clone()));
4949 values.push(Value::from(pattern.clone()));
4950 values.push(Value::from(pattern.clone()));
4951 values.push(Value::from(pattern));
4952 }
4953 values.push(Value::from(kind.to_string()));
4954 if kind == NodeKind::File
4955 && let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".")
4956 {
4957 sql.push_str(" AND (n.parent_path = ? OR n.parent_path LIKE ? ESCAPE '\\')");
4958 values.push(Value::from(folder.to_string()));
4959 values.push(Value::from(sqlite_descendant_pattern(folder)));
4960 }
4961 sql.push_str(
4962 "
4963 )
4964 WHERE score > 0 OR exact_path > 0 OR exact_name > 0 OR reviewed_purpose > 0
4965 ORDER BY exact_path DESC, exact_name DESC, reviewed_purpose DESC, score DESC, path
4966 LIMIT ?
4967 OFFSET ?
4968 ",
4969 );
4970 values.push(Value::from(usize_to_i64(limit.max(1))));
4971 values.push(Value::from(usize_to_i64(offset)));
4972
4973 let mut statement = self.connection.prepare(&sql)?;
4974 let mut rows = statement.query(params_from_iter(values))?;
4975 let mut nodes = Vec::new();
4976 while let Some(row) = rows.next()? {
4977 nodes.push(indexed_node_from_sql_row(row)?);
4978 }
4979 Ok(nodes)
4980 }
4981
4982 pub fn source_file_byte_count(&self, folder: Option<&str>) -> DbResult<usize> {
4988 let mut sql = String::from(
4989 "
4990 SELECT COALESCE(SUM(COALESCE(size_bytes, 0)), 0)
4991 FROM nodes
4992 WHERE exists_now = 1
4993 AND kind = 'file'
4994 ",
4995 );
4996 let mut values = Vec::new();
4997 if let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".") {
4998 sql.push_str(" AND (parent_path = ? OR parent_path LIKE ? ESCAPE '\\')");
4999 values.push(Value::from(folder.to_string()));
5000 values.push(Value::from(sqlite_descendant_pattern(folder)));
5001 }
5002 let count = self
5003 .connection
5004 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5005 count_to_usize("source_file_bytes", count)
5006 }
5007
5008 pub fn visit_file_token_estimates<F>(
5015 &self,
5016 folder: Option<&str>,
5017 mut visitor: F,
5018 ) -> DbResult<()>
5019 where
5020 F: FnMut(String, Option<u64>) -> DbResult<bool>,
5021 {
5022 let mut sql = String::from(
5023 "
5024 SELECT path, size_bytes
5025 FROM nodes
5026 WHERE exists_now = 1
5027 AND kind = 'file'
5028 ",
5029 );
5030 let mut values = Vec::new();
5031 if let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".") {
5032 sql.push_str(" AND (parent_path = ? OR parent_path LIKE ? ESCAPE '\\')");
5033 values.push(Value::from(folder.to_string()));
5034 values.push(Value::from(sqlite_descendant_pattern(folder)));
5035 }
5036 sql.push_str(" ORDER BY path");
5037 let mut statement = self.connection.prepare(&sql)?;
5038 let mut rows = statement.query(params_from_iter(values))?;
5039 while let Some(row) = rows.next()? {
5040 if !visitor(row.get::<_, String>(0)?, option_u64_from_sql(row, 1)?)? {
5041 return Ok(());
5042 }
5043 }
5044 Ok(())
5045 }
5046
5047 pub fn unresolved_health_findings(
5053 &self,
5054 resolved_ids: &[String],
5055 ) -> DbResult<Vec<HealthFinding>> {
5056 let mut findings = Vec::new();
5057 self.visit_unresolved_health_findings(resolved_ids, |finding| {
5058 findings.push(finding);
5059 Ok(true)
5060 })?;
5061 Ok(findings)
5062 }
5063
5064 pub fn unresolved_health_findings_page(
5070 &self,
5071 resolved_ids: &[String],
5072 query: &HealthQuery,
5073 ) -> DbResult<HealthFindingsPage> {
5074 self.unresolved_health_findings_page_with_filter(
5075 HealthResolutionFilter::Explicit(resolved_ids),
5076 query,
5077 )
5078 }
5079
5080 pub fn unresolved_health_findings_page_current(
5086 &self,
5087 query: &HealthQuery,
5088 ) -> DbResult<HealthFindingsPage> {
5089 self.unresolved_health_findings_page_with_filter(HealthResolutionFilter::Stored, query)
5090 }
5091
5092 pub fn purpose_curation_findings_page_current(
5101 &self,
5102 query: &HealthQuery,
5103 ) -> DbResult<HealthFindingsPage> {
5104 let specs = &PURPOSE_HEALTH_SPECS[..2];
5105 let unfiltered_total = specs.iter().try_fold(0_usize, |total, spec| {
5106 self.count_purpose_status_findings(
5107 *spec,
5108 None,
5109 HealthResolutionFilter::Stored,
5110 HealthScope::all(),
5111 )
5112 .map(|count| total + count)
5113 })?;
5114 if query
5115 .severity
5116 .is_some_and(|severity| severity != Severity::Warning)
5117 {
5118 return Ok(HealthFindingsPage {
5119 total: 0,
5120 unfiltered_total,
5121 returned: 0,
5122 start_index: query.start_index,
5123 limit: query.limit,
5124 findings: Vec::new(),
5125 });
5126 }
5127
5128 let matching_specs = query.category.as_deref().map_or(specs, |category| {
5129 specs
5130 .iter()
5131 .find(|spec| spec.category == category)
5132 .map_or(&[][..], std::slice::from_ref)
5133 });
5134 let total = self.count_purpose_lifecycle_findings(
5135 matching_specs,
5136 query.path_prefix.as_deref(),
5137 HealthResolutionFilter::Stored,
5138 query.scope,
5139 )?;
5140 let findings = if query.summary_only {
5141 Vec::new()
5142 } else {
5143 self.load_purpose_lifecycle_findings_page(
5144 matching_specs,
5145 query.path_prefix.as_deref(),
5146 HealthResolutionFilter::Stored,
5147 query.scope,
5148 query.start_index,
5149 query.limit,
5150 )?
5151 };
5152 Ok(HealthFindingsPage {
5153 total,
5154 unfiltered_total,
5155 returned: findings.len(),
5156 start_index: query.start_index,
5157 limit: query.limit,
5158 findings,
5159 })
5160 }
5161
5162 pub fn unresolved_health_finding_count_current(&self) -> DbResult<usize> {
5168 self.unresolved_health_findings_page_current(&HealthQuery {
5169 start_index: 0,
5170 limit: 0,
5171 category: None,
5172 severity: None,
5173 path_prefix: None,
5174 summary_only: true,
5175 scope: HealthScope::all(),
5176 })
5177 .map(|page| page.total)
5178 }
5179
5180 fn unresolved_health_findings_page_with_filter(
5182 &self,
5183 resolution_filter: HealthResolutionFilter<'_>,
5184 query: &HealthQuery,
5185 ) -> DbResult<HealthFindingsPage> {
5186 let mut unfiltered_total = 0_usize;
5187 let mut total = 0_usize;
5188 let mut findings = Vec::new();
5189
5190 for spec in PURPOSE_HEALTH_SPECS {
5191 unfiltered_total += self.count_purpose_status_findings(
5192 spec,
5193 None,
5194 resolution_filter,
5195 HealthScope::all(),
5196 )?;
5197 }
5198
5199 let scope = query.scope;
5200 if scope.high_impact_queue() && query.category.is_none() {
5201 let matching_count = if query
5202 .severity
5203 .is_none_or(|severity| severity == Severity::Warning)
5204 {
5205 self.count_purpose_lifecycle_findings(
5206 &PURPOSE_HEALTH_SPECS,
5207 query.path_prefix.as_deref(),
5208 resolution_filter,
5209 scope,
5210 )?
5211 } else {
5212 0
5213 };
5214 if !query.summary_only
5215 && findings.len() < query.limit
5216 && total + matching_count > query.start_index
5217 {
5218 let local_start = query.start_index.saturating_sub(total);
5219 let local_limit = query.limit - findings.len();
5220 findings.extend(self.load_purpose_lifecycle_findings_page(
5221 &PURPOSE_HEALTH_SPECS,
5222 query.path_prefix.as_deref(),
5223 resolution_filter,
5224 scope,
5225 local_start,
5226 local_limit,
5227 )?);
5228 }
5229 total += matching_count;
5230 } else {
5231 for spec in PURPOSE_HEALTH_SPECS {
5232 if !purpose_health_spec_matches_query(spec, query) {
5233 continue;
5234 }
5235
5236 let matching_count = self.count_purpose_status_findings(
5237 spec,
5238 query.path_prefix.as_deref(),
5239 resolution_filter,
5240 scope,
5241 )?;
5242 if !query.summary_only
5243 && findings.len() < query.limit
5244 && total + matching_count > query.start_index
5245 {
5246 let local_start = query.start_index.saturating_sub(total);
5247 let local_limit = query.limit - findings.len();
5248 findings.extend(self.load_purpose_status_findings_page(
5249 spec,
5250 query.path_prefix.as_deref(),
5251 resolution_filter,
5252 scope,
5253 local_start,
5254 local_limit,
5255 )?);
5256 }
5257 total += matching_count;
5258 }
5259 }
5260
5261 for category in STRUCTURAL_HEALTH_CATEGORIES {
5262 let unfiltered_scope = if category == CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED {
5263 HealthScope::purpose_strict()
5264 } else {
5265 HealthScope::all()
5266 };
5267 let unfiltered_count = self.count_structural_health_findings(
5268 category,
5269 None,
5270 resolution_filter,
5271 unfiltered_scope,
5272 )?;
5273 unfiltered_total += unfiltered_count;
5274 if !health_category_matches_query(category, Severity::Warning, query) {
5275 continue;
5276 }
5277 let matching_count = self.count_structural_health_findings(
5278 category,
5279 query.path_prefix.as_deref(),
5280 resolution_filter,
5281 scope,
5282 )?;
5283 if !query.summary_only
5284 && findings.len() < query.limit
5285 && total + matching_count > query.start_index
5286 {
5287 let local_start = query.start_index.saturating_sub(total);
5288 let local_limit = query.limit - findings.len();
5289 findings.extend(self.load_structural_health_findings_page(
5290 category,
5291 query.path_prefix.as_deref(),
5292 resolution_filter,
5293 scope,
5294 local_start,
5295 local_limit,
5296 )?);
5297 }
5298 total += matching_count;
5299 }
5300 Ok(HealthFindingsPage {
5301 total,
5302 unfiltered_total,
5303 returned: findings.len(),
5304 start_index: query.start_index,
5305 limit: query.limit,
5306 findings,
5307 })
5308 }
5309
5310 fn visit_unresolved_health_findings<F>(
5312 &self,
5313 resolved_ids: &[String],
5314 mut visitor: F,
5315 ) -> DbResult<()>
5316 where
5317 F: FnMut(HealthFinding) -> DbResult<bool>,
5318 {
5319 let resolved = resolved_ids.iter().cloned().collect::<HashSet<_>>();
5320 if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[0], &resolved, &mut visitor)? {
5321 return Ok(());
5322 }
5323 if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[1], &resolved, &mut visitor)? {
5324 return Ok(());
5325 }
5326 if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[2], &resolved, &mut visitor)? {
5327 return Ok(());
5328 }
5329 if !self.visit_agent_review_required_findings(&resolved, &mut visitor)? {
5330 return Ok(());
5331 }
5332 self.visit_structural_health_findings(&resolved, &mut visitor)
5333 }
5334
5335 fn visit_structural_health_findings<F>(
5337 &self,
5338 resolved_ids: &HashSet<String>,
5339 mut visitor: F,
5340 ) -> DbResult<()>
5341 where
5342 F: FnMut(HealthFinding) -> DbResult<bool>,
5343 {
5344 if !self.visit_duplicate_purpose_findings(resolved_ids, &mut visitor)? {
5345 return Ok(());
5346 }
5347 if !self.visit_repeated_temp_folder_findings(resolved_ids, &mut visitor)? {
5348 return Ok(());
5349 }
5350 Ok(())
5351 }
5352
5353 fn visit_purpose_status_findings<F>(
5355 &self,
5356 spec: PurposeHealthSpec,
5357 resolved_ids: &HashSet<String>,
5358 visitor: &mut F,
5359 ) -> DbResult<bool>
5360 where
5361 F: FnMut(HealthFinding) -> DbResult<bool>,
5362 {
5363 let mut statement = self.connection.prepare(
5364 "
5365 SELECT n.path
5366 FROM nodes n
5367 JOIN purposes p ON p.node_id = n.id
5368 WHERE n.exists_now = 1
5369 AND p.status = ?1
5370 ORDER BY n.path
5371 ",
5372 )?;
5373 let rows = statement.query_map([spec.status], |row| row.get::<_, String>(0))?;
5374 for row in rows {
5375 let path = row?;
5376 let finding = HealthFinding {
5377 id: finding_id(spec.category, &path, None),
5378 severity: Severity::Warning,
5379 category: spec.category.to_string(),
5380 path,
5381 related_path: None,
5382 message: spec.message.to_string(),
5383 recommendation: spec.recommendation.to_string(),
5384 };
5385 if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
5386 return Ok(false);
5387 }
5388 }
5389 Ok(true)
5390 }
5391
5392 fn count_purpose_lifecycle_findings(
5394 &self,
5395 specs: &[PurposeHealthSpec],
5396 path_prefix: Option<&str>,
5397 resolution_filter: HealthResolutionFilter<'_>,
5398 scope: HealthScope,
5399 ) -> DbResult<usize> {
5400 if specs.is_empty() {
5401 return Ok(0);
5402 }
5403 let (where_clause, values) =
5404 purpose_lifecycle_where_clause(specs, path_prefix, resolution_filter, scope);
5405 let sql = format!(
5406 "
5407 SELECT COUNT(*)
5408 FROM nodes n
5409 JOIN purposes p ON p.node_id = n.id
5410 WHERE {where_clause}
5411 "
5412 );
5413 let count = self
5414 .connection
5415 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5416 count_to_usize("health_purpose_lifecycle_count", count)
5417 }
5418
5419 fn load_purpose_lifecycle_findings_page(
5421 &self,
5422 specs: &[PurposeHealthSpec],
5423 path_prefix: Option<&str>,
5424 resolution_filter: HealthResolutionFilter<'_>,
5425 scope: HealthScope,
5426 start_index: usize,
5427 limit: usize,
5428 ) -> DbResult<Vec<HealthFinding>> {
5429 if limit == 0 || specs.is_empty() {
5430 return Ok(Vec::new());
5431 }
5432 let (where_clause, mut values) =
5433 purpose_lifecycle_where_clause(specs, path_prefix, resolution_filter, scope);
5434 let limit_placeholder = values.len() + 1;
5435 let offset_placeholder = values.len() + 2;
5436 values.push(Value::from(usize_to_i64(limit)));
5437 values.push(Value::from(usize_to_i64(start_index)));
5438 let order_by = purpose_default_queue_order_expression("n", "p");
5439 let sql = format!(
5440 "
5441 SELECT n.path, p.status
5442 FROM nodes n
5443 JOIN purposes p ON p.node_id = n.id
5444 WHERE {where_clause}
5445 ORDER BY {order_by}
5446 LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5447 "
5448 );
5449 let mut statement = self.connection.prepare(&sql)?;
5450 let rows = statement.query_map(params_from_iter(values), |row| {
5451 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5452 })?;
5453 let mut findings = Vec::new();
5454 for row in rows {
5455 let (path, status) = row?;
5456 let spec = purpose_health_spec_for_status(&status)?;
5457 findings.push(HealthFinding {
5458 id: finding_id(spec.category, &path, None),
5459 severity: Severity::Warning,
5460 category: spec.category.to_string(),
5461 path,
5462 related_path: None,
5463 message: spec.message.to_string(),
5464 recommendation: spec.recommendation.to_string(),
5465 });
5466 }
5467 Ok(findings)
5468 }
5469
5470 fn count_purpose_status_findings(
5472 &self,
5473 spec: PurposeHealthSpec,
5474 path_prefix: Option<&str>,
5475 resolution_filter: HealthResolutionFilter<'_>,
5476 scope: HealthScope,
5477 ) -> DbResult<usize> {
5478 let (where_clause, values) =
5479 purpose_status_where_clause(spec, path_prefix, resolution_filter, scope);
5480 let sql = format!(
5481 "
5482 SELECT COUNT(*)
5483 FROM nodes n
5484 JOIN purposes p ON p.node_id = n.id
5485 WHERE {where_clause}
5486 "
5487 );
5488 let count = self
5489 .connection
5490 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5491 count_to_usize("health_purpose_status_count", count)
5492 }
5493
5494 fn load_purpose_status_findings_page(
5496 &self,
5497 spec: PurposeHealthSpec,
5498 path_prefix: Option<&str>,
5499 resolution_filter: HealthResolutionFilter<'_>,
5500 scope: HealthScope,
5501 start_index: usize,
5502 limit: usize,
5503 ) -> DbResult<Vec<HealthFinding>> {
5504 if limit == 0 {
5505 return Ok(Vec::new());
5506 }
5507 let (where_clause, mut values) =
5508 purpose_status_where_clause(spec, path_prefix, resolution_filter, scope);
5509 let limit_placeholder = values.len() + 1;
5510 let offset_placeholder = values.len() + 2;
5511 values.push(Value::from(usize_to_i64(limit)));
5512 values.push(Value::from(usize_to_i64(start_index)));
5513 let order_by = if scope.high_impact_queue() {
5514 purpose_default_queue_order_expression("n", "p")
5515 } else {
5516 "n.path".to_string()
5517 };
5518 let sql = format!(
5519 "
5520 SELECT n.path
5521 FROM nodes n
5522 JOIN purposes p ON p.node_id = n.id
5523 WHERE {where_clause}
5524 ORDER BY {order_by}
5525 LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5526 "
5527 );
5528 let mut statement = self.connection.prepare(&sql)?;
5529 let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(0))?;
5530 let mut findings = Vec::new();
5531 for row in rows {
5532 let path = row?;
5533 findings.push(HealthFinding {
5534 id: finding_id(spec.category, &path, None),
5535 severity: Severity::Warning,
5536 category: spec.category.to_string(),
5537 path,
5538 related_path: None,
5539 message: spec.message.to_string(),
5540 recommendation: spec.recommendation.to_string(),
5541 });
5542 }
5543 Ok(findings)
5544 }
5545
5546 fn count_structural_health_findings(
5548 &self,
5549 category: &str,
5550 path_prefix: Option<&str>,
5551 resolution_filter: HealthResolutionFilter<'_>,
5552 scope: HealthScope,
5553 ) -> DbResult<usize> {
5554 match category {
5555 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => {
5556 self.count_agent_review_required_findings(path_prefix, resolution_filter, scope)
5557 }
5558 CATEGORY_DUPLICATE_PURPOSE => {
5559 self.count_duplicate_purpose_findings(path_prefix, resolution_filter, scope)
5560 }
5561 CATEGORY_REPEATED_TEMPORARY_FOLDER => {
5562 self.count_repeated_temp_folder_findings(path_prefix, resolution_filter, scope)
5563 }
5564 _ => Ok(0),
5565 }
5566 }
5567
5568 fn load_structural_health_findings_page(
5570 &self,
5571 category: &str,
5572 path_prefix: Option<&str>,
5573 resolution_filter: HealthResolutionFilter<'_>,
5574 scope: HealthScope,
5575 start_index: usize,
5576 limit: usize,
5577 ) -> DbResult<Vec<HealthFinding>> {
5578 match category {
5579 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => self
5580 .load_agent_review_required_findings_page(
5581 path_prefix,
5582 resolution_filter,
5583 scope,
5584 start_index,
5585 limit,
5586 ),
5587 CATEGORY_DUPLICATE_PURPOSE => self.load_duplicate_purpose_findings_page(
5588 path_prefix,
5589 resolution_filter,
5590 scope,
5591 start_index,
5592 limit,
5593 ),
5594 CATEGORY_REPEATED_TEMPORARY_FOLDER => self.load_repeated_temp_folder_findings_page(
5595 path_prefix,
5596 resolution_filter,
5597 scope,
5598 start_index,
5599 limit,
5600 ),
5601 _ => Ok(Vec::new()),
5602 }
5603 }
5604
5605 fn visit_agent_review_required_findings<F>(
5607 &self,
5608 resolved_ids: &HashSet<String>,
5609 visitor: &mut F,
5610 ) -> DbResult<bool>
5611 where
5612 F: FnMut(HealthFinding) -> DbResult<bool>,
5613 {
5614 let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
5615 let high_impact = high_impact_file_path_expression("lower(n.path)");
5616 let approved_status = PurposeStatus::Approved.as_str();
5617 let sql = format!(
5618 "
5619 SELECT n.path
5620 FROM nodes n
5621 JOIN purposes p ON p.node_id = n.id
5622 WHERE n.exists_now = 1
5623 AND p.status = '{approved_status}'
5624 AND p.source NOT IN ({reviewed_sources})
5625 AND (n.kind = 'folder' OR (n.kind = 'file' AND {high_impact}))
5626 ORDER BY CASE WHEN n.kind = 'folder' THEN 0 ELSE 1 END, n.path
5627 "
5628 );
5629 let mut statement = self.connection.prepare(&sql)?;
5630 let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
5631 for row in rows {
5632 let finding = agent_review_required_finding(row?);
5633 if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
5634 return Ok(false);
5635 }
5636 }
5637 Ok(true)
5638 }
5639
5640 fn count_agent_review_required_findings(
5642 &self,
5643 path_prefix: Option<&str>,
5644 resolution_filter: HealthResolutionFilter<'_>,
5645 scope: HealthScope,
5646 ) -> DbResult<usize> {
5647 let (where_clause, values) = structural_finding_where_clause(
5648 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
5649 path_prefix,
5650 resolution_filter,
5651 scope,
5652 1,
5653 );
5654 let source_relevant = source_relevant_node_expression("n");
5655 let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
5656 let review_candidate = purpose_review_candidate_expression("n", scope);
5657 let approved_status = PurposeStatus::Approved.as_str();
5658 let sql = format!(
5659 "
5660 WITH findings AS (
5661 SELECT n.path,
5662 n.kind,
5663 n.language,
5664 '' AS related_path,
5665 {source_relevant} AS source_relevant
5666 FROM nodes n
5667 JOIN purposes p ON p.node_id = n.id
5668 WHERE n.exists_now = 1
5669 AND p.status = '{approved_status}'
5670 AND p.source NOT IN ({reviewed_sources})
5671 AND {review_candidate}
5672 )
5673 SELECT COUNT(*)
5674 FROM findings
5675 {where_clause}
5676 "
5677 );
5678 let count = self
5679 .connection
5680 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5681 count_to_usize("health_agent_review_required_count", count)
5682 }
5683
5684 fn load_agent_review_required_findings_page(
5686 &self,
5687 path_prefix: Option<&str>,
5688 resolution_filter: HealthResolutionFilter<'_>,
5689 scope: HealthScope,
5690 start_index: usize,
5691 limit: usize,
5692 ) -> DbResult<Vec<HealthFinding>> {
5693 if limit == 0 {
5694 return Ok(Vec::new());
5695 }
5696 let (where_clause, mut values) = structural_finding_where_clause(
5697 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
5698 path_prefix,
5699 resolution_filter,
5700 scope,
5701 1,
5702 );
5703 let source_relevant = source_relevant_node_expression("n");
5704 let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
5705 let review_candidate = purpose_review_candidate_expression("n", scope);
5706 let approved_status = PurposeStatus::Approved.as_str();
5707 let limit_placeholder = values.len() + 1;
5708 let offset_placeholder = values.len() + 2;
5709 values.push(Value::from(usize_to_i64(limit)));
5710 values.push(Value::from(usize_to_i64(start_index)));
5711 let sql = format!(
5712 "
5713 WITH findings AS (
5714 SELECT n.path,
5715 n.kind,
5716 n.language,
5717 '' AS related_path,
5718 {source_relevant} AS source_relevant
5719 FROM nodes n
5720 JOIN purposes p ON p.node_id = n.id
5721 WHERE n.exists_now = 1
5722 AND p.status = '{approved_status}'
5723 AND p.source NOT IN ({reviewed_sources})
5724 AND {review_candidate}
5725 )
5726 SELECT path
5727 FROM findings
5728 {where_clause}
5729 ORDER BY CASE WHEN kind = 'folder' THEN 0 ELSE 1 END, path
5730 LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5731 "
5732 );
5733 let mut statement = self.connection.prepare(&sql)?;
5734 let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(0))?;
5735 let mut findings = Vec::new();
5736 for row in rows {
5737 findings.push(agent_review_required_finding(row?));
5738 }
5739 Ok(findings)
5740 }
5741
5742 fn count_duplicate_purpose_findings(
5744 &self,
5745 path_prefix: Option<&str>,
5746 resolution_filter: HealthResolutionFilter<'_>,
5747 scope: HealthScope,
5748 ) -> DbResult<usize> {
5749 let (where_clause, values) = structural_finding_where_clause(
5750 CATEGORY_DUPLICATE_PURPOSE,
5751 path_prefix,
5752 resolution_filter,
5753 scope,
5754 1,
5755 );
5756 let source_relevant = source_relevant_node_expression("n");
5757 let duplicate_scope =
5758 "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5759 let approved_status = PurposeStatus::Approved.as_str();
5760 let sql = format!(
5761 "
5762 WITH duplicate_rows AS (
5763 SELECT n.path,
5764 n.kind,
5765 n.language,
5766 p.purpose,
5767 {source_relevant} AS source_relevant,
5768 FIRST_VALUE(n.path) OVER (
5769 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5770 ORDER BY n.path
5771 ) AS related_path,
5772 ROW_NUMBER() OVER (
5773 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5774 ORDER BY n.path
5775 ) AS duplicate_rank,
5776 COUNT(*) OVER (
5777 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5778 ) AS duplicate_count
5779 FROM nodes n
5780 JOIN purposes p ON p.node_id = n.id
5781 WHERE n.exists_now = 1
5782 AND p.status = '{approved_status}'
5783 AND p.purpose IS NOT NULL
5784 ),
5785 findings AS (
5786 SELECT path, kind, language, purpose, related_path, source_relevant
5787 FROM duplicate_rows
5788 WHERE duplicate_count > 1
5789 AND duplicate_rank > 1
5790 )
5791 SELECT COUNT(*)
5792 FROM findings
5793 {where_clause}
5794 "
5795 );
5796 let count = self
5797 .connection
5798 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5799 count_to_usize("health_duplicate_purpose_count", count)
5800 }
5801
5802 fn load_duplicate_purpose_findings_page(
5804 &self,
5805 path_prefix: Option<&str>,
5806 resolution_filter: HealthResolutionFilter<'_>,
5807 scope: HealthScope,
5808 start_index: usize,
5809 limit: usize,
5810 ) -> DbResult<Vec<HealthFinding>> {
5811 if limit == 0 {
5812 return Ok(Vec::new());
5813 }
5814 let (where_clause, mut values) = structural_finding_where_clause(
5815 CATEGORY_DUPLICATE_PURPOSE,
5816 path_prefix,
5817 resolution_filter,
5818 scope,
5819 1,
5820 );
5821 let source_relevant = source_relevant_node_expression("n");
5822 let duplicate_scope =
5823 "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5824 let approved_status = PurposeStatus::Approved.as_str();
5825 let limit_placeholder = values.len() + 1;
5826 let offset_placeholder = values.len() + 2;
5827 values.push(Value::from(usize_to_i64(limit)));
5828 values.push(Value::from(usize_to_i64(start_index)));
5829 let sql = format!(
5830 "
5831 WITH duplicate_rows AS (
5832 SELECT n.path,
5833 n.kind,
5834 n.language,
5835 p.purpose,
5836 {source_relevant} AS source_relevant,
5837 FIRST_VALUE(n.path) OVER (
5838 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5839 ORDER BY n.path
5840 ) AS related_path,
5841 ROW_NUMBER() OVER (
5842 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5843 ORDER BY n.path
5844 ) AS duplicate_rank,
5845 COUNT(*) OVER (
5846 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5847 ) AS duplicate_count
5848 FROM nodes n
5849 JOIN purposes p ON p.node_id = n.id
5850 WHERE n.exists_now = 1
5851 AND p.status = '{approved_status}'
5852 AND p.purpose IS NOT NULL
5853 ),
5854 findings AS (
5855 SELECT path, kind, language, purpose, related_path, source_relevant
5856 FROM duplicate_rows
5857 WHERE duplicate_count > 1
5858 AND duplicate_rank > 1
5859 )
5860 SELECT path, kind, related_path
5861 FROM findings
5862 {where_clause}
5863 ORDER BY kind, lower(purpose), path
5864 LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5865 "
5866 );
5867 let mut statement = self.connection.prepare(&sql)?;
5868 let rows = statement.query_map(params_from_iter(values), |row| {
5869 Ok((
5870 row.get::<_, String>(0)?,
5871 row.get::<_, String>(1)?,
5872 row.get::<_, String>(2)?,
5873 ))
5874 })?;
5875 let mut findings = Vec::new();
5876 for row in rows {
5877 let (path, kind_value, related_path) = row?;
5878 let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
5879 field: "kind",
5880 value: kind_value,
5881 })?;
5882 findings.push(HealthFinding {
5883 id: finding_id(CATEGORY_DUPLICATE_PURPOSE, &path, Some(&related_path)),
5884 severity: Severity::Warning,
5885 category: CATEGORY_DUPLICATE_PURPOSE.to_string(),
5886 path,
5887 related_path: Some(related_path),
5888 message: format!("Multiple {kind} nodes share the same purpose."),
5889 recommendation: RECOMMENDATION_DUPLICATE_PURPOSE.to_string(),
5890 });
5891 }
5892 Ok(findings)
5893 }
5894
5895 fn count_repeated_temp_folder_findings(
5897 &self,
5898 path_prefix: Option<&str>,
5899 resolution_filter: HealthResolutionFilter<'_>,
5900 scope: HealthScope,
5901 ) -> DbResult<usize> {
5902 let mut total = 0_usize;
5903 for bucket in TEMP_FOLDER_BUCKETS {
5904 total += self.count_repeated_temp_folder_bucket_findings(
5905 bucket,
5906 path_prefix,
5907 resolution_filter,
5908 scope,
5909 )?;
5910 }
5911 Ok(total)
5912 }
5913
5914 fn count_repeated_temp_folder_bucket_findings(
5916 &self,
5917 bucket: &str,
5918 path_prefix: Option<&str>,
5919 resolution_filter: HealthResolutionFilter<'_>,
5920 scope: HealthScope,
5921 ) -> DbResult<usize> {
5922 let exact = bucket.to_string();
5923 let suffix = format!("%/{bucket}");
5924 let (where_clause, mut filter_values) = structural_finding_where_clause(
5925 CATEGORY_REPEATED_TEMPORARY_FOLDER,
5926 path_prefix,
5927 resolution_filter,
5928 scope,
5929 3,
5930 );
5931 let mut values = vec![Value::from(exact), Value::from(suffix)];
5932 values.append(&mut filter_values);
5933 let source_relevant = source_relevant_node_expression("n");
5934 let sql = format!(
5935 "
5936 WITH bucket_rows AS (
5937 SELECT n.path,
5938 n.kind,
5939 n.language,
5940 {source_relevant} AS source_relevant,
5941 FIRST_VALUE(n.path) OVER (ORDER BY n.path) AS related_path,
5942 ROW_NUMBER() OVER (ORDER BY path) AS duplicate_rank,
5943 COUNT(*) OVER () AS duplicate_count
5944 FROM nodes n
5945 WHERE n.exists_now = 1
5946 AND n.kind = 'folder'
5947 AND (lower(n.path) = ?1 OR lower(n.path) LIKE ?2)
5948 ),
5949 findings AS (
5950 SELECT path, kind, language, related_path, source_relevant
5951 FROM bucket_rows
5952 WHERE duplicate_count > 1
5953 AND duplicate_rank > 1
5954 )
5955 SELECT COUNT(*)
5956 FROM findings
5957 {where_clause}
5958 "
5959 );
5960 let count = self
5961 .connection
5962 .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5963 count_to_usize("health_repeated_temp_count", count)
5964 }
5965
5966 fn load_repeated_temp_folder_findings_page(
5968 &self,
5969 path_prefix: Option<&str>,
5970 resolution_filter: HealthResolutionFilter<'_>,
5971 scope: HealthScope,
5972 start_index: usize,
5973 limit: usize,
5974 ) -> DbResult<Vec<HealthFinding>> {
5975 if limit == 0 {
5976 return Ok(Vec::new());
5977 }
5978 let mut total = 0_usize;
5979 let mut findings = Vec::new();
5980 for bucket in TEMP_FOLDER_BUCKETS {
5981 let matching_count = self.count_repeated_temp_folder_bucket_findings(
5982 bucket,
5983 path_prefix,
5984 resolution_filter,
5985 scope,
5986 )?;
5987 if findings.len() < limit && total + matching_count > start_index {
5988 let local_start = start_index.saturating_sub(total);
5989 let local_limit = limit - findings.len();
5990 findings.extend(self.load_repeated_temp_folder_bucket_findings_page(
5991 bucket,
5992 path_prefix,
5993 resolution_filter,
5994 scope,
5995 local_start,
5996 local_limit,
5997 )?);
5998 }
5999 total += matching_count;
6000 if findings.len() >= limit {
6001 break;
6002 }
6003 }
6004 Ok(findings)
6005 }
6006
6007 fn load_repeated_temp_folder_bucket_findings_page(
6009 &self,
6010 bucket: &str,
6011 path_prefix: Option<&str>,
6012 resolution_filter: HealthResolutionFilter<'_>,
6013 scope: HealthScope,
6014 start_index: usize,
6015 limit: usize,
6016 ) -> DbResult<Vec<HealthFinding>> {
6017 if limit == 0 {
6018 return Ok(Vec::new());
6019 }
6020 let exact = bucket.to_string();
6021 let suffix = format!("%/{bucket}");
6022 let (where_clause, mut filter_values) = structural_finding_where_clause(
6023 CATEGORY_REPEATED_TEMPORARY_FOLDER,
6024 path_prefix,
6025 resolution_filter,
6026 scope,
6027 3,
6028 );
6029 let mut values = vec![Value::from(exact), Value::from(suffix)];
6030 values.append(&mut filter_values);
6031 let source_relevant = source_relevant_node_expression("n");
6032 let limit_placeholder = values.len() + 1;
6033 let offset_placeholder = values.len() + 2;
6034 values.push(Value::from(usize_to_i64(limit)));
6035 values.push(Value::from(usize_to_i64(start_index)));
6036 let sql = format!(
6037 "
6038 WITH bucket_rows AS (
6039 SELECT n.path,
6040 n.kind,
6041 n.language,
6042 {source_relevant} AS source_relevant,
6043 FIRST_VALUE(n.path) OVER (ORDER BY n.path) AS related_path,
6044 ROW_NUMBER() OVER (ORDER BY n.path) AS duplicate_rank,
6045 COUNT(*) OVER () AS duplicate_count
6046 FROM nodes n
6047 WHERE n.exists_now = 1
6048 AND n.kind = 'folder'
6049 AND (lower(n.path) = ?1 OR lower(n.path) LIKE ?2)
6050 ),
6051 findings AS (
6052 SELECT path, kind, language, related_path, source_relevant
6053 FROM bucket_rows
6054 WHERE duplicate_count > 1
6055 AND duplicate_rank > 1
6056 )
6057 SELECT path, related_path
6058 FROM findings
6059 {where_clause}
6060 ORDER BY path
6061 LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
6062 "
6063 );
6064 let mut statement = self.connection.prepare(&sql)?;
6065 let rows = statement.query_map(params_from_iter(values), |row| {
6066 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6067 })?;
6068 let mut findings = Vec::new();
6069 for row in rows {
6070 let (path, related_path) = row?;
6071 findings.push(HealthFinding {
6072 id: finding_id(
6073 CATEGORY_REPEATED_TEMPORARY_FOLDER,
6074 &path,
6075 Some(&related_path),
6076 ),
6077 severity: Severity::Warning,
6078 category: CATEGORY_REPEATED_TEMPORARY_FOLDER.to_string(),
6079 path,
6080 related_path: Some(related_path),
6081 message: format!("Repeated temporary/generated folder name `{bucket}` found."),
6082 recommendation: RECOMMENDATION_REPEATED_TEMPORARY_FOLDER.to_string(),
6083 });
6084 }
6085 Ok(findings)
6086 }
6087
6088 fn visit_duplicate_purpose_findings<F>(
6090 &self,
6091 resolved_ids: &HashSet<String>,
6092 visitor: &mut F,
6093 ) -> DbResult<bool>
6094 where
6095 F: FnMut(HealthFinding) -> DbResult<bool>,
6096 {
6097 let duplicate_scope =
6098 "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
6099 let approved_status = PurposeStatus::Approved.as_str();
6100 let sql = format!(
6101 "
6102 WITH duplicate_rows AS (
6103 SELECT n.path,
6104 n.kind,
6105 p.purpose,
6106 FIRST_VALUE(n.path) OVER (
6107 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
6108 ORDER BY n.path
6109 ) AS related_path,
6110 ROW_NUMBER() OVER (
6111 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
6112 ORDER BY n.path
6113 ) AS duplicate_rank,
6114 COUNT(*) OVER (
6115 PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
6116 ) AS duplicate_count
6117 FROM nodes n
6118 JOIN purposes p ON p.node_id = n.id
6119 WHERE n.exists_now = 1
6120 AND p.status = '{approved_status}'
6121 AND p.purpose IS NOT NULL
6122 )
6123 SELECT path, kind, purpose, related_path
6124 FROM duplicate_rows
6125 WHERE duplicate_count > 1
6126 AND duplicate_rank > 1
6127 ORDER BY kind, lower(purpose), path
6128 "
6129 );
6130 let mut statement = self.connection.prepare(&sql)?;
6131 let rows = statement.query_map([], |row| {
6132 Ok((
6133 row.get::<_, String>(0)?,
6134 row.get::<_, String>(1)?,
6135 row.get::<_, String>(2)?,
6136 row.get::<_, String>(3)?,
6137 ))
6138 })?;
6139 for row in rows {
6140 let (path, kind_value, _purpose, related_path) = row?;
6141 let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
6142 field: "kind",
6143 value: kind_value.clone(),
6144 })?;
6145 let finding = HealthFinding {
6146 id: finding_id(CATEGORY_DUPLICATE_PURPOSE, &path, Some(&related_path)),
6147 severity: Severity::Warning,
6148 category: CATEGORY_DUPLICATE_PURPOSE.to_string(),
6149 path,
6150 related_path: Some(related_path),
6151 message: format!("Multiple {kind} nodes share the same purpose."),
6152 recommendation: RECOMMENDATION_DUPLICATE_PURPOSE.to_string(),
6153 };
6154 if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
6155 return Ok(false);
6156 }
6157 }
6158 Ok(true)
6159 }
6160
6161 fn visit_repeated_temp_folder_findings<F>(
6163 &self,
6164 resolved_ids: &HashSet<String>,
6165 visitor: &mut F,
6166 ) -> DbResult<bool>
6167 where
6168 F: FnMut(HealthFinding) -> DbResult<bool>,
6169 {
6170 for bucket in TEMP_FOLDER_BUCKETS {
6171 let exact = bucket.to_string();
6172 let suffix = format!("%/{bucket}");
6173 let mut statement = self.connection.prepare(
6174 "
6175 SELECT path
6176 FROM nodes
6177 WHERE exists_now = 1
6178 AND kind = 'folder'
6179 AND (lower(path) = ?1 OR lower(path) LIKE ?2)
6180 ORDER BY path
6181 ",
6182 )?;
6183 let rows =
6184 statement.query_map(params![exact, suffix], |row| row.get::<_, String>(0))?;
6185 let mut first_path = None;
6186 for row in rows {
6187 let path = row?;
6188 let Some(first_path) = first_path.as_ref() else {
6189 first_path = Some(path);
6190 continue;
6191 };
6192 let finding = HealthFinding {
6193 id: finding_id(
6194 CATEGORY_REPEATED_TEMPORARY_FOLDER,
6195 &path,
6196 Some(first_path.as_str()),
6197 ),
6198 severity: Severity::Warning,
6199 category: CATEGORY_REPEATED_TEMPORARY_FOLDER.to_string(),
6200 path,
6201 related_path: Some(first_path.clone()),
6202 message: format!("Repeated temporary/generated folder name `{bucket}` found."),
6203 recommendation: RECOMMENDATION_REPEATED_TEMPORARY_FOLDER.to_string(),
6204 };
6205 if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
6206 return Ok(false);
6207 }
6208 }
6209 }
6210 Ok(true)
6211 }
6212
6213 pub fn overview(&self) -> DbResult<Overview> {
6219 let missing_status = PurposeStatus::Missing.as_str();
6220 let stale_status = PurposeStatus::Stale.as_str();
6221 let approved_status = PurposeStatus::Approved.as_str();
6222 let suggested_status = PurposeStatus::Suggested.as_str();
6223 let sql = format!(
6224 "
6225 SELECT
6226 COALESCE(SUM(CASE WHEN n.kind = 'file' THEN 1 ELSE 0 END), 0),
6227 COALESCE(SUM(CASE WHEN n.kind = 'folder' THEN 1 ELSE 0 END), 0),
6228 COALESCE(SUM(CASE WHEN p.status = '{missing_status}' THEN 1 ELSE 0 END), 0),
6229 COALESCE(SUM(CASE WHEN p.status = '{stale_status}' THEN 1 ELSE 0 END), 0),
6230 COALESCE(SUM(CASE WHEN p.status = '{approved_status}' THEN 1 ELSE 0 END), 0),
6231 COALESCE(SUM(CASE WHEN p.status = '{suggested_status}' THEN 1 ELSE 0 END), 0)
6232 FROM nodes n
6233 JOIN purposes p ON p.node_id = n.id
6234 WHERE n.exists_now = 1
6235 "
6236 );
6237 let counts = self.connection.query_row(&sql, [], |row| {
6238 Ok((
6239 row.get::<_, i64>(0)?,
6240 row.get::<_, i64>(1)?,
6241 row.get::<_, i64>(2)?,
6242 row.get::<_, i64>(3)?,
6243 row.get::<_, i64>(4)?,
6244 row.get::<_, i64>(5)?,
6245 ))
6246 })?;
6247 Ok(Overview {
6248 files: count_to_usize("files", counts.0)?,
6249 folders: count_to_usize("folders", counts.1)?,
6250 missing_purposes: count_to_usize("missing_purposes", counts.2)?,
6251 stale_purposes: count_to_usize("stale_purposes", counts.3)?,
6252 approved_purposes: count_to_usize("approved_purposes", counts.4)?,
6253 suggested_purposes: count_to_usize("suggested_purposes", counts.5)?,
6254 })
6255 }
6256
6257 pub fn record_usage(&self, event: &UsageEvent) -> DbResult<()> {
6263 telemetry::validate_event(event, TelemetryRetentionPolicy::default())?;
6264 self.validated_project_root_identity
6265 .as_ref()
6266 .ok_or(DbError::ProjectRootIdentityMissing)?;
6267 let instance_id = self.library_usage_instance(&event.session_id, false)?;
6268 match self.record_usage_for_instance(
6269 instance_id,
6270 UsageInstanceOwner::LibraryHandle,
6271 event,
6272 false,
6273 ) {
6274 Err(DbError::TelemetryInstanceInactive) => {
6275 let replacement = self.library_usage_instance(&event.session_id, true)?;
6276 self.record_usage_for_instance(
6277 replacement,
6278 UsageInstanceOwner::LibraryHandle,
6279 event,
6280 false,
6281 )
6282 }
6283 Err(DbError::TelemetryBaselineCapacity) => {
6284 let replacement = telemetry::generate_usage_instance_id()?;
6285 self.seal_usage_instance(instance_id)?;
6286 self.library_usage_instances
6287 .borrow_mut()
6288 .insert(event.session_id.clone(), Some(replacement));
6289 self.record_usage_for_instance(
6290 replacement,
6291 UsageInstanceOwner::LibraryHandle,
6292 event,
6293 false,
6294 )
6295 }
6296 result => result,
6297 }
6298 }
6299
6300 fn library_usage_instance(
6302 &self,
6303 caller_label: &str,
6304 rotate: bool,
6305 ) -> DbResult<UsageInstanceId> {
6306 let mut instances = self.library_usage_instances.borrow_mut();
6307 if !rotate && let Some(instance) = instances.get(caller_label) {
6308 return instance.ok_or(DbError::TelemetryIdentityUnavailable);
6309 }
6310 if !instances.contains_key(caller_label)
6311 && instances.len() >= TelemetryRetentionPolicy::default().max_retained_labels
6312 {
6313 return Err(DbError::TelemetryInstanceCapacity);
6314 }
6315 let instance = telemetry::generate_usage_instance_id().ok();
6316 instances.insert(caller_label.to_string(), instance);
6317 instance.ok_or(DbError::TelemetryIdentityUnavailable)
6318 }
6319
6320 pub fn record_usage_for_instance(
6331 &self,
6332 instance_id: UsageInstanceId,
6333 owner: UsageInstanceOwner,
6334 event: &UsageEvent,
6335 seal_after_record: bool,
6336 ) -> DbResult<()> {
6337 self.record_usage_for_instance_origin(instance_id, owner, None, event, seal_after_record)
6338 }
6339
6340 pub fn record_usage_for_worktree_instance(
6348 &self,
6349 instance_id: UsageInstanceId,
6350 owner: UsageInstanceOwner,
6351 registration_id: i64,
6352 event: &UsageEvent,
6353 seal_after_record: bool,
6354 ) -> DbResult<()> {
6355 self.record_usage_for_instance_origin(
6356 instance_id,
6357 owner,
6358 Some(registration_id),
6359 event,
6360 seal_after_record,
6361 )
6362 }
6363
6364 fn record_usage_for_instance_origin(
6366 &self,
6367 instance_id: UsageInstanceId,
6368 owner: UsageInstanceOwner,
6369 registration_id: Option<i64>,
6370 event: &UsageEvent,
6371 seal_after_record: bool,
6372 ) -> DbResult<()> {
6373 let policy = TelemetryRetentionPolicy::default();
6374 let project_instance_id = self
6375 .validated_project_instance_id
6376 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
6377 let project_root_identity = self
6378 .validated_project_root_identity
6379 .as_ref()
6380 .ok_or(DbError::ProjectRootIdentityMissing)?;
6381 self.with_telemetry_connection(|connection| {
6382 with_validated_native_write_transaction(
6383 connection,
6384 Some(project_root_identity),
6385 Some(project_instance_id),
6386 |transaction| {
6387 telemetry::record_usage_for_project(
6388 transaction,
6389 project_instance_id,
6390 instance_id,
6391 owner,
6392 registration_id,
6393 event,
6394 policy,
6395 seal_after_record,
6396 )
6397 },
6398 )?;
6399 drop(telemetry::maintain_after_commit_for_native_project(
6403 connection,
6404 Some(project_root_identity),
6405 project_instance_id,
6406 policy,
6407 ));
6408 Ok(())
6409 })
6410 }
6411
6412 pub fn seal_usage_instance(&self, instance_id: UsageInstanceId) -> DbResult<()> {
6419 let project_root_identity = self
6420 .validated_project_root_identity
6421 .as_ref()
6422 .ok_or(DbError::ProjectRootIdentityMissing)?;
6423 self.with_telemetry_connection(|connection| {
6424 with_validated_native_write_transaction(
6425 connection,
6426 Some(project_root_identity),
6427 self.validated_project_instance_id,
6428 |transaction| telemetry::seal_usage_instance(transaction, instance_id),
6429 )
6430 })
6431 }
6432
6433 pub fn telemetry_retention_state(&self) -> DbResult<TelemetryRetentionState> {
6439 telemetry::retention_state(&self.connection)
6440 }
6441
6442 pub fn usage_events(&self, session_id: Option<&str>) -> DbResult<Vec<UsageEvent>> {
6448 telemetry::usage_events(&self.connection, session_id)
6449 }
6450
6451 pub fn token_overview(&self, session_id: Option<&str>) -> DbResult<TokenOverview> {
6457 telemetry::token_overview(&self.connection, session_id)
6458 }
6459
6460 pub fn repository_token_overview(&self) -> DbResult<TokenOverview> {
6467 telemetry::repository_token_overview(&self.connection)
6468 }
6469
6470 pub fn registered_worktree_token_overview(
6477 &self,
6478 alias: &WorktreeAlias,
6479 ) -> DbResult<TokenOverview> {
6480 let registration = self.worktree_registration(alias)?;
6481 telemetry::worktree_token_overview(&self.connection, registration.registration_id)
6482 }
6483
6484 pub fn token_trends(
6490 &self,
6491 session_id: Option<&str>,
6492 window: TokenTrendWindow,
6493 ) -> DbResult<TokenTrendReport> {
6494 telemetry::token_trends(&self.connection, session_id, window)
6495 }
6496
6497 pub fn repository_token_trends(&self, window: TokenTrendWindow) -> DbResult<TokenTrendReport> {
6504 telemetry::repository_token_trends(&self.connection, window)
6505 }
6506
6507 pub fn registered_worktree_token_trends(
6514 &self,
6515 alias: &WorktreeAlias,
6516 window: TokenTrendWindow,
6517 ) -> DbResult<TokenTrendReport> {
6518 let registration = self.worktree_registration(alias)?;
6519 telemetry::worktree_token_trends(&self.connection, registration.registration_id, window)
6520 }
6521
6522 pub fn resolve_health_finding(&self, resolution: &HealthResolution) -> DbResult<()> {
6528 self.with_validated_write(|connection| {
6529 if !self.active_health_finding_matches(resolution)? {
6530 return Err(DbError::HealthFindingNotActive {
6531 finding_id: resolution.finding_id.clone(),
6532 category: resolution.category.clone(),
6533 path: resolution.path.clone(),
6534 });
6535 }
6536 connection.execute(
6537 "
6538 INSERT INTO health_resolutions(
6539 finding_id,
6540 category,
6541 path,
6542 related_path,
6543 rationale,
6544 resolved_by,
6545 resolved_at
6546 )
6547 VALUES(?1, ?2, ?3, ?4, ?5, 'agent', CURRENT_TIMESTAMP)
6548 ON CONFLICT(finding_id) DO UPDATE SET
6549 category = excluded.category,
6550 path = excluded.path,
6551 related_path = excluded.related_path,
6552 rationale = excluded.rationale,
6553 resolved_by = 'agent',
6554 resolved_at = CURRENT_TIMESTAMP
6555 ",
6556 params![
6557 resolution.finding_id,
6558 resolution.category,
6559 resolution.path,
6560 resolution.related_path,
6561 resolution.rationale,
6562 ],
6563 )?;
6564 Ok(())
6565 })
6566 }
6567
6568 fn active_health_finding_matches(&self, resolution: &HealthResolution) -> DbResult<bool> {
6570 const PAGE_SIZE: usize = 256;
6571 let mut start_index = 0_usize;
6572 loop {
6573 let page = self.unresolved_health_findings_page_current(&HealthQuery {
6574 start_index,
6575 limit: PAGE_SIZE,
6576 category: Some(resolution.category.clone()),
6577 severity: Some(Severity::Warning),
6578 path_prefix: Some(resolution.path.clone()),
6579 summary_only: false,
6580 scope: HealthScope::all(),
6581 })?;
6582 if page.findings.iter().any(|finding| {
6583 finding.id == resolution.finding_id
6584 && finding.category == resolution.category
6585 && finding.path == resolution.path
6586 && finding.related_path == resolution.related_path
6587 }) {
6588 return Ok(true);
6589 }
6590 if page.returned == 0 || start_index + page.returned >= page.total {
6591 return Ok(false);
6592 }
6593 start_index += page.returned;
6594 }
6595 }
6596
6597 pub fn resolved_health_ids(&self) -> DbResult<Vec<String>> {
6603 let mut statement = self
6604 .connection
6605 .prepare("SELECT finding_id FROM health_resolutions ORDER BY finding_id")?;
6606 let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
6607 let mut ids = Vec::new();
6608 for row in rows {
6609 ids.push(row?);
6610 }
6611 Ok(ids)
6612 }
6613}
6614
6615fn begin_immediate_publication(connection: &Connection) -> DbResult<()> {
6617 connection.busy_timeout(SQLITE_PUBLICATION_ACQUIRE_TIMEOUT)?;
6618 let begin_result = connection.execute_batch("BEGIN IMMEDIATE");
6619 let restore_result = connection.busy_timeout(SQLITE_BUSY_TIMEOUT);
6620 match (begin_result, restore_result) {
6621 (Ok(()), Ok(())) => Ok(()),
6622 (Err(error), Ok(())) => Err(error.into()),
6623 (Ok(()), Err(error)) => Err(schema::rollback_after_error(connection, error.into())),
6624 (Err(operation), Err(restore)) => Err(DbError::PublicationAcquirePolicyRestore {
6625 operation: Box::new(operation),
6626 restore: Box::new(restore),
6627 }),
6628 }
6629}
6630
6631pub fn read_project_root_read_only(path: &Path) -> DbResult<Option<String>> {
6637 schema::read_project_root(path)
6638}
6639
6640pub fn read_legacy_project_root_candidate_read_only(path: &Path) -> DbResult<Option<String>> {
6656 let (preflight, _) = schema::inspect_compatibility(path, None)?;
6657 if preflight.state != SchemaState::UpgradeRequired {
6658 return Ok(None);
6659 }
6660 if schema::legacy_root_requires_native_authority(preflight.project_root.as_deref()) {
6661 return Err(DbError::ProjectRootIdentityMissing);
6662 }
6663 Ok(preflight.project_root)
6664}
6665
6666pub fn preflight_project_binding_read_only(path: &Path, root: &Path) -> DbResult<()> {
6678 let expected_identity = CanonicalProjectRoot::from_path(root)?;
6679 schema::preflight_for_project(path, &expected_identity).map(|_| ())
6680}
6681
6682pub fn read_project_root_identity_read_only(path: &Path) -> DbResult<Option<CanonicalProjectRoot>> {
6689 let location = sqlite_profile::inspect_database_location(path)?;
6690 if !location.database_exists {
6691 return Ok(None);
6692 }
6693 let (preflight, _) = schema::inspect_compatibility(path, None)?;
6694 if preflight.state != schema::SchemaState::Current {
6695 return Ok(None);
6696 }
6697 let connection = schema::open_current_read_only(path, None)?.0;
6698 let identity = project_identity::load_project_root_identity(&connection)?;
6699 connection.execute_batch("ROLLBACK")?;
6700 Ok(identity)
6701}
6702
6703pub fn verify_project_database(path: &Path, project_root: &Path) -> DbResult<()> {
6710 let identity = CanonicalProjectRoot::from_path(project_root)?;
6711 schema::verify_current_integrity_for_project(path, &identity)
6712}
6713
6714fn normalize_metadata_path(path: &Path) -> String {
6716 normalize_native_path_display(path)
6717}
6718
6719fn set_metadata(connection: &Connection, key: &str, value: &str) -> DbResult<()> {
6721 connection.execute(
6722 "
6723 INSERT INTO metadata(key, value)
6724 VALUES(?1, ?2)
6725 ON CONFLICT(key) DO UPDATE SET value = excluded.value
6726 ",
6727 [key, value],
6728 )?;
6729 Ok(())
6730}
6731
6732fn load_authored_purpose_revision(connection: &Connection) -> DbResult<u64> {
6734 let value = connection
6735 .prepare_cached("SELECT value FROM metadata WHERE key = ?1")?
6736 .query_row([AUTHORED_PURPOSE_REVISION_KEY], |row| {
6737 row.get::<_, String>(0)
6738 })
6739 .optional()?;
6740 value.map_or(Ok(0), |value| {
6741 value
6742 .parse::<u64>()
6743 .map_err(|source| DbError::InvalidInteger {
6744 field: AUTHORED_PURPOSE_REVISION_KEY,
6745 value,
6746 source,
6747 })
6748 })
6749}
6750
6751fn advance_authored_purpose_revision(connection: &Connection) -> DbResult<u64> {
6753 let current = load_authored_purpose_revision(connection)?;
6754 let revision = current
6755 .checked_add(1)
6756 .ok_or(DbError::IntegerMetadataOverflow {
6757 field: AUTHORED_PURPOSE_REVISION_KEY,
6758 value: current,
6759 })?;
6760 set_metadata(
6761 connection,
6762 AUTHORED_PURPOSE_REVISION_KEY,
6763 &revision.to_string(),
6764 )?;
6765 Ok(revision)
6766}
6767
6768fn load_file_text_fts_revisions(connection: &Connection) -> DbResult<Option<(u64, u64)>> {
6770 let (source, projection) = connection.query_row(
6771 "
6772 SELECT
6773 (SELECT value FROM metadata WHERE key = ?1),
6774 (SELECT value FROM metadata WHERE key = ?2)
6775 ",
6776 params![
6777 FILE_TEXT_FTS_SOURCE_REVISION_KEY,
6778 FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
6779 ],
6780 |row| {
6781 Ok((
6782 row.get::<_, Option<String>>(0)?,
6783 row.get::<_, Option<String>>(1)?,
6784 ))
6785 },
6786 )?;
6787 if source.is_none() && projection.is_none() {
6788 return Ok(None);
6789 }
6790 let (Some(source), Some(projection)) = (source, projection) else {
6791 return Err(DbError::FileTextFtsStateInvalid {
6792 reason: "only one FTS revision is present",
6793 });
6794 };
6795 let source_revision =
6796 source
6797 .parse::<u64>()
6798 .map_err(|source_error| DbError::InvalidInteger {
6799 field: FILE_TEXT_FTS_SOURCE_REVISION_KEY,
6800 value: source,
6801 source: source_error,
6802 })?;
6803 let projection_revision =
6804 projection
6805 .parse::<u64>()
6806 .map_err(|source_error| DbError::InvalidInteger {
6807 field: FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
6808 value: projection,
6809 source: source_error,
6810 })?;
6811 Ok(Some((source_revision, projection_revision)))
6812}
6813
6814fn begin_file_text_fts_update(connection: &Connection) -> DbResult<u64> {
6816 let (source, projection) =
6817 load_file_text_fts_revisions(connection)?.ok_or(DbError::FileTextFtsStateInvalid {
6818 reason: "FTS revisions are missing",
6819 })?;
6820 if source != projection {
6821 return Err(DbError::FileTextFtsStateInvalid {
6822 reason: "an earlier FTS revision is incomplete",
6823 });
6824 }
6825 let revision = source
6826 .checked_add(1)
6827 .ok_or(DbError::FileTextFtsStateInvalid {
6828 reason: "FTS revision overflowed",
6829 })?;
6830 set_metadata(
6831 connection,
6832 FILE_TEXT_FTS_SOURCE_REVISION_KEY,
6833 &revision.to_string(),
6834 )?;
6835 Ok(revision)
6836}
6837
6838fn complete_file_text_fts_update(connection: &Connection, revision: u64) -> DbResult<()> {
6840 let (source, projection) =
6841 load_file_text_fts_revisions(connection)?.ok_or(DbError::FileTextFtsStateInvalid {
6842 reason: "FTS revisions disappeared during an update",
6843 })?;
6844 if source != revision || projection.checked_add(1) != Some(revision) {
6845 return Err(DbError::FileTextFtsStateInvalid {
6846 reason: "FTS revisions changed during an update",
6847 });
6848 }
6849 set_metadata(
6850 connection,
6851 FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
6852 &revision.to_string(),
6853 )
6854}
6855
6856#[cfg(test)]
6858fn record_released_schema_eight_usage_event(
6859 connection: &Connection,
6860 event: &UsageEvent,
6861) -> DbResult<()> {
6862 connection.execute(
6863 "
6864 INSERT INTO usage_events(
6865 session_id,
6866 command,
6867 path,
6868 query,
6869 estimated_tokens_without_projectatlas,
6870 estimated_tokens_with_projectatlas,
6871 estimated_tokens_saved,
6872 token_savings_bucket,
6873 provider,
6874 model,
6875 tokenizer_backend,
6876 accuracy,
6877 baseline_kind,
6878 confidence,
6879 calculation_trace,
6880 accounting_layer,
6881 estimate_method,
6882 denominator_kind,
6883 baseline_identity,
6884 baseline_fingerprint,
6885 dedupe_scope,
6886 created_at
6887 )
6888 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, CURRENT_TIMESTAMP)
6889 ",
6890 params![
6891 event.session_id,
6892 event.command,
6893 event.path,
6894 event.query,
6895 option_usize_to_i64(
6896 "estimated_tokens_without_projectatlas",
6897 event.estimated_tokens_without_projectatlas,
6898 )?,
6899 option_usize_to_i64(
6900 "estimated_tokens_with_projectatlas",
6901 event.estimated_tokens_with_projectatlas,
6902 )?,
6903 event.estimated_tokens_saved,
6904 event.token_savings_bucket,
6905 event.provider,
6906 event.model,
6907 event.tokenizer_backend,
6908 event.accuracy,
6909 event.baseline_kind,
6910 event.confidence,
6911 event.calculation_trace,
6912 event.accounting_layer,
6913 event.estimate_method,
6914 event.denominator_kind,
6915 event.baseline_identity,
6916 event.baseline_fingerprint,
6917 event.dedupe_scope
6918 ],
6919 )?;
6920 Ok(())
6921}
6922
6923fn load_index_publication(connection: &Connection) -> DbResult<Option<IndexPublication>> {
6925 let row = connection
6926 .query_row(
6927 "
6928 SELECT state.value, fingerprint.value, generation.value
6929 FROM metadata AS state
6930 LEFT JOIN metadata AS fingerprint ON fingerprint.key = ?2
6931 LEFT JOIN metadata AS generation ON generation.key = ?3
6932 WHERE state.key = ?1
6933 ",
6934 params![
6935 INDEX_PUBLICATION_STATE_KEY,
6936 INDEX_PUBLICATION_FINGERPRINT_KEY,
6937 INDEX_PUBLICATION_GENERATION_KEY,
6938 ],
6939 |row| {
6940 Ok((
6941 row.get::<_, String>(0)?,
6942 row.get::<_, Option<String>>(1)?,
6943 row.get::<_, Option<String>>(2)?,
6944 ))
6945 },
6946 )
6947 .optional()?;
6948 let Some((state, contract_fingerprint, generation)) = row else {
6949 return Ok(None);
6950 };
6951 let generation = generation.map_or(Ok(IndexGeneration::ZERO), |value| {
6952 value
6953 .parse::<u64>()
6954 .map(IndexGeneration::new)
6955 .map_err(|source| DbError::InvalidInteger {
6956 field: INDEX_PUBLICATION_GENERATION_KEY,
6957 value,
6958 source,
6959 })
6960 })?;
6961 Ok(Some(IndexPublication {
6962 state: IndexPublicationState::from_db(state)?,
6963 contract_fingerprint,
6964 generation,
6965 }))
6966}
6967
6968fn mark_all_scan_nodes_absent(connection: &Connection) -> DbResult<()> {
6970 connection.execute("UPDATE nodes SET exists_now = 0", [])?;
6971 Ok(())
6972}
6973
6974fn delete_absent_scan_projections(connection: &Connection) -> DbResult<()> {
6976 content_classification::delete_absent_file_content_classifications(connection)?;
6977 let fts_revision = begin_file_text_fts_update(connection)?;
6978 connection.execute(
6979 "DELETE FROM symbol_relations WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6980 [],
6981 )?;
6982 connection.execute(
6983 "DELETE FROM symbols WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6984 [],
6985 )?;
6986 connection.execute(
6987 "DELETE FROM source_parse_metadata WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6988 [],
6989 )?;
6990 connection.execute(
6991 "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
6992 SELECT 'delete', rowid, content FROM file_texts \
6993 WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6994 [],
6995 )?;
6996 connection.execute(
6997 "DELETE FROM file_texts WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6998 [],
6999 )?;
7000 complete_file_text_fts_update(connection, fts_revision)?;
7001 Ok(())
7002}
7003
7004fn upsert_nodes(connection: &Connection, nodes: &[Node]) -> DbResult<()> {
7006 content_classification::delete_non_file_content_classifications(connection, nodes)?;
7007 let mut select_existing = connection.prepare_cached(
7008 "
7009 SELECT content_hash
7010 FROM nodes
7011 WHERE path = ?1
7012 ",
7013 )?;
7014 let mut upsert_node = connection.prepare_cached(
7015 "
7016 INSERT INTO nodes(path, kind, parent_path, extension, language, size_bytes, mtime_ns, content_hash, exists_now)
7017 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)
7018 ON CONFLICT(path) DO UPDATE SET
7019 kind = excluded.kind,
7020 parent_path = excluded.parent_path,
7021 extension = excluded.extension,
7022 language = excluded.language,
7023 size_bytes = excluded.size_bytes,
7024 mtime_ns = excluded.mtime_ns,
7025 content_hash = excluded.content_hash,
7026 exists_now = 1,
7027 last_seen_at = CURRENT_TIMESTAMP,
7028 last_indexed_at = CURRENT_TIMESTAMP
7029 ",
7030 )?;
7031 let mut ensure_file_classification = connection.prepare_cached(
7032 "INSERT INTO file_content_classifications(path, classification)
7033 SELECT ?1, 'opaque'
7034 WHERE ?2 = 'file'
7035 ON CONFLICT(path) DO NOTHING",
7036 )?;
7037 let mut select_node_id = connection.prepare_cached("SELECT id FROM nodes WHERE path = ?1")?;
7038 let mut ensure_purpose = connection.prepare_cached(
7039 "
7040 INSERT INTO purposes(node_id, purpose, source, status)
7041 VALUES(?1, NULL, 'missing', 'missing')
7042 ON CONFLICT(node_id) DO NOTHING
7043 ",
7044 )?;
7045 let mut upsert_summary = connection.prepare_cached(
7046 "
7047 INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
7048 VALUES(?1, 'node', '', ?2, CURRENT_TIMESTAMP)
7049 ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
7050 summary = CASE WHEN ?3 THEN excluded.summary ELSE summaries.summary END,
7051 updated_at = CURRENT_TIMESTAMP
7052 ",
7053 )?;
7054 for node in nodes {
7055 let size_bytes = node
7056 .size_bytes
7057 .map(|value| {
7058 i64::try_from(value).map_err(|_source| DbError::GraphCountOverflow {
7059 field: "nodes.size_bytes",
7060 value,
7061 })
7062 })
7063 .transpose()?;
7064 let existing = select_existing
7065 .query_row([&node.path], |row| row.get::<_, Option<String>>(0))
7066 .optional()?;
7067 let content_changed = existing.as_ref().is_some_and(|old_hash| {
7068 node.kind == NodeKind::File
7069 && old_hash.is_some()
7070 && node.content_hash.is_some()
7071 && old_hash != &node.content_hash
7072 });
7073 upsert_node.execute(params![
7074 node.path,
7075 node.kind.to_string(),
7076 node.parent_path,
7077 node.extension,
7078 node.language,
7079 size_bytes,
7080 node.mtime_ns,
7081 node.content_hash
7082 ])?;
7083 ensure_file_classification.execute(params![node.path, node.kind.to_string()])?;
7084 let node_id = select_node_id.query_row([&node.path], |row| row.get::<_, i64>(0))?;
7085 ensure_purpose.execute([node_id])?;
7086 upsert_summary.execute(params![
7087 node_id,
7088 generate_node_summary(node),
7089 content_changed
7090 ])?;
7091 }
7092 Ok(())
7093}
7094
7095fn file_text_from_row(row: &rusqlite::Row<'_>) -> DbResult<IndexedFileText> {
7097 let byte_count = count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?;
7098 let line_count = count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?;
7099 let text = IndexedFileText {
7100 path: row.get(0)?,
7101 content_hash: row.get(1)?,
7102 byte_count,
7103 line_count,
7104 content: row.get(4)?,
7105 };
7106 validate_indexed_file_text(&text)?;
7107 Ok(text)
7108}
7109
7110fn validate_indexed_file_text(text: &IndexedFileText) -> DbResult<()> {
7112 let actual_bytes = text.content.len();
7113 if text.byte_count != actual_bytes {
7114 return Err(DbError::FileTextMetadataMismatch {
7115 path: text.path.clone(),
7116 field: "byte_count",
7117 recorded: text.byte_count,
7118 actual: actual_bytes,
7119 });
7120 }
7121 let actual_lines = text.content.lines().count();
7122 if text.line_count != actual_lines {
7123 return Err(DbError::FileTextMetadataMismatch {
7124 path: text.path.clone(),
7125 field: "line_count",
7126 recorded: text.line_count,
7127 actual: actual_lines,
7128 });
7129 }
7130 Ok(())
7131}
7132
7133const SQLITE_READ_PROGRESS_OPS: i32 = 1_000;
7135
7136#[cfg(feature = "sqlite-progress-test-observer")]
7138pub mod sqlite_progress_test_observer {
7139 use projectatlas_core::IndexWorkStage;
7140 use std::cell::RefCell;
7141
7142 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
7144 pub enum SqliteReadProgressEvent {
7145 RepositoryRelationFamilyQueryEntered,
7147 RepositoryRelationFamilyQueryExited,
7149 CallbackEntered {
7151 stage: IndexWorkStage,
7153 },
7154 CallbackEvaluated {
7156 stage: IndexWorkStage,
7158 interrupted: bool,
7160 },
7161 }
7162
7163 type Observer = Box<dyn FnMut(SqliteReadProgressEvent)>;
7165
7166 thread_local! {
7167 static OBSERVER: RefCell<Option<Observer>> = RefCell::new(None);
7169 }
7170
7171 struct ObserverGuard {
7173 previous: Option<Observer>,
7175 }
7176
7177 impl Drop for ObserverGuard {
7178 fn drop(&mut self) {
7179 OBSERVER.with(|slot| {
7180 drop(slot.replace(self.previous.take()));
7181 });
7182 }
7183 }
7184
7185 pub fn observe_sqlite_read_progress<T>(
7187 observer: impl FnMut(SqliteReadProgressEvent) + 'static,
7188 operation: impl FnOnce() -> T,
7189 ) -> T {
7190 let previous = OBSERVER.with(|slot| slot.replace(Some(Box::new(observer))));
7191 let _guard = ObserverGuard { previous };
7192 operation()
7193 }
7194
7195 pub(crate) fn notify(event: SqliteReadProgressEvent) {
7197 OBSERVER.with(|slot| {
7198 if let Some(observer) = slot.borrow_mut().as_mut() {
7199 observer(event);
7200 }
7201 });
7202 }
7203}
7204
7205pub(crate) struct SqliteReadProgressGuard<'connection> {
7207 connection: &'connection Connection,
7209 armed: bool,
7211}
7212
7213impl<'connection> SqliteReadProgressGuard<'connection> {
7214 pub(crate) fn new(
7216 connection: &'connection Connection,
7217 control: Option<&IndexWorkControl>,
7218 stage: IndexWorkStage,
7219 ) -> DbResult<Self> {
7220 let armed = if let Some(control) = control {
7221 control.check(stage)?;
7222 let progress_control = control.clone();
7223 connection.progress_handler(
7224 SQLITE_READ_PROGRESS_OPS,
7225 Some(move || {
7226 #[cfg(feature = "sqlite-progress-test-observer")]
7227 sqlite_progress_test_observer::notify(
7228 sqlite_progress_test_observer::SqliteReadProgressEvent::CallbackEntered {
7229 stage,
7230 },
7231 );
7232 let interrupted = progress_control.check(stage).is_err();
7233 #[cfg(feature = "sqlite-progress-test-observer")]
7234 sqlite_progress_test_observer::notify(
7235 sqlite_progress_test_observer::SqliteReadProgressEvent::CallbackEvaluated {
7236 stage,
7237 interrupted,
7238 },
7239 );
7240 interrupted
7241 }),
7242 )?;
7243 true
7244 } else {
7245 false
7246 };
7247 Ok(Self { connection, armed })
7248 }
7249}
7250
7251impl Drop for SqliteReadProgressGuard<'_> {
7252 fn drop(&mut self) {
7253 if self.armed {
7254 drop(self.connection.progress_handler(0, None::<fn() -> bool>));
7255 }
7256 }
7257}
7258
7259pub(crate) fn with_sqlite_read_progress<T>(
7261 connection: &Connection,
7262 control: Option<&IndexWorkControl>,
7263 stage: IndexWorkStage,
7264 operation: impl FnOnce() -> DbResult<T>,
7265) -> DbResult<T> {
7266 let guard = SqliteReadProgressGuard::new(connection, control, stage)?;
7267 let result = operation();
7268 drop(guard);
7269 if result.as_ref().is_err_and(|error| {
7270 matches!(
7271 error,
7272 DbError::Sqlite(sqlite)
7273 if sqlite.sqlite_error_code() == Some(ErrorCode::OperationInterrupted)
7274 )
7275 }) && let Some(control) = control
7276 {
7277 control.check(stage)?;
7278 }
7279 result
7280}
7281
7282fn with_file_text_progress<T>(
7284 connection: &Connection,
7285 control: Option<&IndexWorkControl>,
7286 operation: impl FnOnce() -> DbResult<T>,
7287) -> DbResult<T> {
7288 with_sqlite_read_progress(connection, control, IndexWorkStage::TextIndex, operation)
7289}
7290
7291fn validate_file_text_fts_token(token: &str) -> DbResult<()> {
7293 if token.len() < 3 {
7294 return Err(DbError::FileTextFtsTokenUnsafe {
7295 reason: "token is shorter than three ASCII bytes",
7296 });
7297 }
7298 if !token.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
7299 return Err(DbError::FileTextFtsTokenUnsafe {
7300 reason: "token is not exclusively ASCII alphanumeric",
7301 });
7302 }
7303 Ok(())
7304}
7305
7306fn normalized_file_text_path_prefix(path_prefix: Option<&str>) -> Option<&str> {
7308 path_prefix
7309 .map(|prefix| prefix.trim_end_matches('/'))
7310 .filter(|prefix| !prefix.is_empty() && *prefix != ".")
7311}
7312
7313fn file_text_descendant_range(path_prefix: &str) -> (String, String) {
7315 (format!("{path_prefix}/"), format!("{path_prefix}0"))
7316}
7317
7318fn collect_file_text_fts_candidates(
7320 rows: &mut rusqlite::Rows<'_>,
7321 control: Option<&IndexWorkControl>,
7322 candidates: &mut Vec<FileTextFtsCandidate>,
7323) -> DbResult<()> {
7324 while let Some(row) = rows.next()? {
7325 if let Some(control) = control {
7326 control.check(IndexWorkStage::TextIndex)?;
7327 }
7328 let path = row.get::<_, String>(0)?;
7329 let bm25 = row.get::<_, f64>(4)?;
7330 if !bm25.is_finite() {
7331 return Err(DbError::FileTextFtsScoreInvalid { path });
7332 }
7333 candidates.push(FileTextFtsCandidate {
7334 path,
7335 content_hash: row.get(1)?,
7336 byte_count: count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?,
7337 line_count: count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?,
7338 bm25,
7339 });
7340 }
7341 Ok(())
7342}
7343
7344fn file_text_metadata_from_row(row: &rusqlite::Row<'_>) -> DbResult<FileTextMetadata> {
7346 let path = row.get::<_, String>(0)?;
7347 let classification = row
7348 .get::<_, Option<String>>(4)?
7349 .ok_or_else(|| DbError::FileContentClassificationMissing { path: path.clone() })?;
7350 Ok(FileTextMetadata {
7351 path,
7352 content_hash: row.get(1)?,
7353 byte_count: count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?,
7354 line_count: count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?,
7355 classification: ContentClassification::from_db(&classification).ok_or(
7356 DbError::InvalidEnum {
7357 field: "file_content_classifications.classification",
7358 value: classification,
7359 },
7360 )?,
7361 })
7362}
7363
7364fn visit_file_text_fallback_rows<A, V>(
7366 rows: &mut rusqlite::Rows<'_>,
7367 content_statement: &mut rusqlite::CachedStatement<'_>,
7368 control: Option<&IndexWorkControl>,
7369 admit: &mut A,
7370 visitor: &mut V,
7371) -> DbResult<()>
7372where
7373 A: FnMut(&FileTextMetadata) -> DbResult<FileTextAdmission>,
7374 V: FnMut(IndexedFileText) -> DbResult<bool>,
7375{
7376 while let Some(row) = rows.next()? {
7377 if let Some(control) = control {
7378 control.check(IndexWorkStage::TextIndex)?;
7379 }
7380 let metadata = file_text_metadata_from_row(row)?;
7381 match admit(&metadata)? {
7382 FileTextAdmission::Skip => continue,
7383 FileTextAdmission::Stop => return Ok(()),
7384 FileTextAdmission::Read => {}
7385 }
7386 let content =
7387 content_statement.query_row([&metadata.path], |content_row| content_row.get(0))?;
7388 let text = IndexedFileText {
7389 path: metadata.path,
7390 content_hash: metadata.content_hash,
7391 byte_count: metadata.byte_count,
7392 line_count: metadata.line_count,
7393 content,
7394 };
7395 validate_indexed_file_text(&text)?;
7396 if !visitor(text)? {
7397 return Ok(());
7398 }
7399 }
7400 Ok(())
7401}
7402
7403type IndexedNodeParts = (
7405 String,
7406 String,
7407 Option<String>,
7408 Option<String>,
7409 Option<String>,
7410 Option<u64>,
7411 Option<i64>,
7412 Option<String>,
7413 Option<String>,
7414 String,
7415 String,
7416 Option<String>,
7417);
7418
7419fn indexed_node_parts_from_sql_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<IndexedNodeParts> {
7421 let kind_value: String = row.get(1)?;
7422 let source_value: String = row.get(9)?;
7423 let status_value: String = row.get(10)?;
7424 Ok((
7425 row.get::<_, String>(0)?,
7426 kind_value,
7427 row.get::<_, Option<String>>(2)?,
7428 row.get::<_, Option<String>>(3)?,
7429 row.get::<_, Option<String>>(4)?,
7430 option_u64_from_sql(row, 5)?,
7431 row.get::<_, Option<i64>>(6)?,
7432 row.get::<_, Option<String>>(7)?,
7433 row.get::<_, Option<String>>(8)?,
7434 source_value,
7435 status_value,
7436 row.get::<_, Option<String>>(11)?,
7437 ))
7438}
7439
7440fn indexed_node_from_sql_row(row: &rusqlite::Row<'_>) -> DbResult<IndexedNode> {
7442 indexed_node_from_parts(indexed_node_parts_from_sql_row(row)?)
7443}
7444
7445fn indexed_node_parts_decoded_bytes(row: &IndexedNodeParts) -> DbResult<u64> {
7447 let lengths = [
7448 row.0.len(),
7449 row.1.len(),
7450 row.2.as_ref().map_or(0, String::len),
7451 row.3.as_ref().map_or(0, String::len),
7452 row.4.as_ref().map_or(0, String::len),
7453 row.7.as_ref().map_or(0, String::len),
7454 row.8.as_ref().map_or(0, String::len),
7455 row.9.len(),
7456 row.10.len(),
7457 row.11.as_ref().map_or(0, String::len),
7458 ];
7459 let mut bytes = 16_u64;
7460 for length in lengths {
7461 let length =
7462 u64::try_from(length).map_err(|_source| GraphContractError::InvalidLimits {
7463 reason: "purpose-owner decoded field length overflowed",
7464 })?;
7465 bytes = bytes
7466 .checked_add(length)
7467 .ok_or(GraphContractError::InvalidLimits {
7468 reason: "purpose-owner decoded row size overflowed",
7469 })?;
7470 }
7471 Ok(bytes)
7472}
7473
7474fn indexed_node_from_parts(row: IndexedNodeParts) -> DbResult<IndexedNode> {
7476 let (
7477 path,
7478 kind_value,
7479 parent_path,
7480 extension,
7481 language,
7482 size_bytes,
7483 mtime_ns,
7484 content_hash,
7485 purpose,
7486 source_value,
7487 status_value,
7488 summary,
7489 ) = row;
7490 let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
7491 field: "kind",
7492 value: kind_value,
7493 })?;
7494 let source = parse_source(&source_value)?;
7495 let status = PurposeStatus::from_db(&status_value).ok_or_else(|| DbError::InvalidEnum {
7496 field: "status",
7497 value: status_value,
7498 })?;
7499 Ok(IndexedNode {
7500 node: Node {
7501 path: path.clone(),
7502 kind,
7503 parent_path,
7504 extension,
7505 language,
7506 size_bytes,
7507 mtime_ns,
7508 content_hash,
7509 },
7510 purpose: Purpose {
7511 path,
7512 purpose,
7513 source,
7514 status,
7515 },
7516 summary,
7517 })
7518}
7519
7520fn normalize_query_terms(query: &str) -> Vec<String> {
7522 query
7523 .split(|character: char| !character.is_alphanumeric())
7524 .filter(|term| !term.is_empty())
7525 .map(str::to_lowercase)
7526 .collect()
7527}
7528
7529fn normalize_exact_ranked_query(query: &str) -> String {
7531 query.trim().replace('\\', "/").to_lowercase()
7532}
7533
7534fn reviewed_purpose_match_expression(term_count: usize) -> String {
7536 if term_count == 0 {
7537 return "0".to_string();
7538 }
7539 let matches = std::iter::repeat_n(
7540 "lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\'",
7541 term_count,
7542 )
7543 .collect::<Vec<_>>()
7544 .join(" OR ");
7545 format!(
7546 "CASE WHEN p.status = 'approved' AND p.source IN ('agent', 'human') \
7547 AND ({matches}) THEN 1 ELSE 0 END"
7548 )
7549}
7550
7551fn ranked_score_expression(term_count: usize) -> String {
7553 if term_count == 0 {
7554 return "1".to_string();
7555 }
7556 (0..term_count)
7557 .map(|_| {
7558 "(CASE WHEN lower(n.path) LIKE ? ESCAPE '\\' THEN 20 ELSE 0 END \
7559 + CASE WHEN p.status = 'approved' AND p.source IN ('agent', 'human') \
7560 AND lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\' THEN 30 ELSE 0 END \
7561 + CASE WHEN NOT (p.status = 'approved' AND p.source IN ('agent', 'human')) \
7562 AND lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\' THEN 2 ELSE 0 END \
7563 + CASE WHEN lower(COALESCE(s.summary, '')) LIKE ? ESCAPE '\\' THEN 10 ELSE 0 END \
7564 + CASE WHEN lower(COALESCE(symbol_summaries.summary, '')) LIKE ? ESCAPE '\\' THEN 25 ELSE 0 END)"
7565 .to_string()
7566 })
7567 .collect::<Vec<_>>()
7568 .join(" + ")
7569}
7570
7571fn sqlite_like_pattern(term: &str) -> String {
7573 format!("%{}%", sqlite_like_escape(term))
7574}
7575
7576fn sqlite_descendant_pattern(path: &str) -> String {
7578 format!("{}/%", sqlite_like_escape(path))
7579}
7580
7581fn sqlite_like_escape(value: &str) -> String {
7583 value
7584 .replace('\\', "\\\\")
7585 .replace('%', "\\%")
7586 .replace('_', "\\_")
7587}
7588
7589fn replace_symbol_search_summary(
7591 connection: &Connection,
7592 node_id: i64,
7593 summary: Option<&str>,
7594) -> DbResult<()> {
7595 if let Some(summary) = summary {
7596 connection
7597 .prepare_cached(
7598 "
7599 INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
7600 VALUES(?1, 'search', 'symbols', ?2, CURRENT_TIMESTAMP)
7601 ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
7602 summary = excluded.summary,
7603 updated_at = CURRENT_TIMESTAMP
7604 ",
7605 )?
7606 .execute(params![node_id, summary])?;
7607 } else {
7608 connection
7609 .prepare_cached(
7610 "
7611 DELETE FROM summaries
7612 WHERE node_id = ?1
7613 AND summary_level = 'search'
7614 AND subject = 'symbols'
7615 ",
7616 )?
7617 .execute([node_id])?;
7618 }
7619 Ok(())
7620}
7621
7622fn symbol_search_summary(graph: &SymbolGraph) -> Option<String> {
7624 let mut names = graph
7625 .symbols
7626 .iter()
7627 .filter(|symbol| !matches!(symbol.kind, SymbolKind::Import | SymbolKind::Unknown))
7628 .map(|symbol| symbol.name.trim())
7629 .filter(|name| !name.is_empty())
7630 .map(ToString::to_string)
7631 .collect::<Vec<_>>();
7632 names.sort();
7633 names.dedup();
7634 if names.is_empty() {
7635 return None;
7636 }
7637 let summary = format!("symbols {}", names.join(" "));
7638 Some(truncate_summary_chars(
7639 &summary,
7640 MAX_SYMBOL_SEARCH_SUMMARY_CHARS,
7641 ))
7642}
7643
7644fn truncate_summary_chars(value: &str, max_chars: usize) -> String {
7646 if value.chars().count() <= max_chars {
7647 return value.to_string();
7648 }
7649 value.chars().take(max_chars).collect()
7650}
7651
7652fn parse_source(value: &str) -> DbResult<PurposeSource> {
7654 let source = match value {
7655 value if value == PurposeSource::Missing.as_str() => PurposeSource::Missing,
7656 value if value == PurposeSource::Imported.as_str() => PurposeSource::Imported,
7657 value if value == PurposeSource::Generated.as_str() => PurposeSource::Generated,
7658 value if value == PurposeSource::Agent.as_str() || value == LEGACY_HUMAN_PURPOSE_SOURCE => {
7661 PurposeSource::Agent
7662 }
7663 _ => {
7664 return Err(DbError::InvalidEnum {
7665 field: "source",
7666 value: value.to_string(),
7667 });
7668 }
7669 };
7670 Ok(source)
7671}
7672
7673fn normalize_purpose_curation_task(task: &str) -> DbResult<String> {
7675 let normalized = task.split_whitespace().collect::<Vec<_>>().join(" ");
7676 if normalized.is_empty() {
7677 return Err(DbError::PurposeCurationTaskInvalid {
7678 reason: "task must not be blank",
7679 });
7680 }
7681 if normalized.len() > MAX_PURPOSE_CURATION_TASK_BYTES {
7682 return Err(DbError::PurposeCurationTaskInvalid {
7683 reason: "task exceeds the UTF-8 byte limit",
7684 });
7685 }
7686 if normalized.chars().any(char::is_control) {
7687 return Err(DbError::PurposeCurationTaskInvalid {
7688 reason: "task contains control characters",
7689 });
7690 }
7691 Ok(normalized)
7692}
7693
7694fn load_current_purpose_state(
7696 connection: &Connection,
7697 path: &str,
7698) -> DbResult<Option<(i64, Purpose)>> {
7699 let row = connection
7700 .prepare_cached(
7701 "
7702 SELECT n.id, p.purpose, p.source, p.status
7703 FROM nodes n
7704 JOIN purposes p ON p.node_id = n.id
7705 WHERE n.exists_now = 1 AND n.path = ?1
7706 ",
7707 )?
7708 .query_row([path], |row| {
7709 Ok((
7710 row.get::<_, i64>(0)?,
7711 row.get::<_, Option<String>>(1)?,
7712 row.get::<_, String>(2)?,
7713 row.get::<_, String>(3)?,
7714 ))
7715 })
7716 .optional()?;
7717 row.map(|(node_id, purpose, source, status)| {
7718 let source = parse_source(&source)?;
7719 let status = PurposeStatus::from_db(&status).ok_or_else(|| DbError::InvalidEnum {
7720 field: "purpose_status",
7721 value: status,
7722 })?;
7723 Ok((
7724 node_id,
7725 Purpose {
7726 path: path.to_string(),
7727 purpose,
7728 source,
7729 status,
7730 },
7731 ))
7732 })
7733 .transpose()
7734}
7735
7736fn apply_conditional_purpose(
7738 connection: &Connection,
7739 project: ProjectInstanceId,
7740 generation: IndexGeneration,
7741 request: &PurposeConditionalApplyRequest,
7742) -> DbResult<PurposeConditionalApplyResult> {
7743 let current = load_current_purpose_state(connection, &request.path)?;
7744 let Some((node_id, current_purpose)) = current else {
7745 return Ok(PurposeConditionalApplyResult {
7746 path: request.path.clone(),
7747 state: PurposeConditionalApplyState::PathUnavailable,
7748 current_purpose: None,
7749 });
7750 };
7751 if !matches!(
7752 current_purpose.status,
7753 PurposeStatus::Missing | PurposeStatus::Suggested
7754 ) {
7755 return Ok(PurposeConditionalApplyResult {
7756 path: request.path.clone(),
7757 state: PurposeConditionalApplyState::Accepted,
7758 current_purpose: Some(current_purpose),
7759 });
7760 }
7761 let current_work_key =
7762 purpose_curation_item_work_key(project, generation, &request.task, &request.path);
7763 let current_state_token = purpose_curation_state_token(¤t_work_key, ¤t_purpose);
7764 if current_work_key != request.work_key || current_state_token != request.state_token {
7765 return Ok(PurposeConditionalApplyResult {
7766 path: request.path.clone(),
7767 state: PurposeConditionalApplyState::Stale,
7768 current_purpose: Some(current_purpose),
7769 });
7770 }
7771 let generation_sql =
7772 i64::try_from(generation.get()).map_err(|_source| DbError::GraphCountOverflow {
7773 field: "project_identity.active_generation",
7774 value: generation.get(),
7775 })?;
7776 let changed = connection
7777 .prepare_cached(
7778 "
7779 UPDATE purposes
7780 SET purpose = ?2,
7781 source = ?3,
7782 status = ?4,
7783 updated_at = CURRENT_TIMESTAMP
7784 WHERE node_id = ?1
7785 AND status = ?5
7786 AND source = ?6
7787 AND purpose IS ?7
7788 AND EXISTS (
7789 SELECT 1
7790 FROM nodes n
7791 JOIN project_identity pi ON pi.singleton = 1
7792 WHERE n.id = ?1
7793 AND n.exists_now = 1
7794 AND n.path = ?8
7795 AND pi.project_instance_id = ?9
7796 AND pi.active_generation = ?10
7797 )
7798 ",
7799 )?
7800 .execute(params![
7801 node_id,
7802 request.purpose,
7803 PurposeSource::Agent.as_str(),
7804 PurposeStatus::Approved.as_str(),
7805 current_purpose.status.as_str(),
7806 current_purpose.source.as_str(),
7807 current_purpose.purpose,
7808 request.path,
7809 &project.as_bytes()[..],
7810 generation_sql,
7811 ])?;
7812 if changed == 1 {
7813 Ok(PurposeConditionalApplyResult {
7814 path: request.path.clone(),
7815 state: PurposeConditionalApplyState::Applied,
7816 current_purpose: Some(Purpose {
7817 path: request.path.clone(),
7818 purpose: Some(request.purpose.clone()),
7819 source: PurposeSource::Agent,
7820 status: PurposeStatus::Approved,
7821 }),
7822 })
7823 } else {
7824 Ok(PurposeConditionalApplyResult {
7825 path: request.path.clone(),
7826 state: PurposeConditionalApplyState::Stale,
7827 current_purpose: Some(current_purpose),
7828 })
7829 }
7830}
7831
7832fn purpose_curation_candidate(
7834 project: ProjectInstanceId,
7835 generation: IndexGeneration,
7836 task: &str,
7837 node: IndexedNode,
7838) -> PurposeCurationCandidate {
7839 let work_key = purpose_curation_item_work_key(project, generation, task, &node.node.path);
7840 let state_token = purpose_curation_state_token(&work_key, &node.purpose);
7841 PurposeCurationCandidate {
7842 node,
7843 work_key,
7844 state_token,
7845 }
7846}
7847
7848fn purpose_curation_item_work_key(
7850 project: ProjectInstanceId,
7851 generation: IndexGeneration,
7852 task: &str,
7853 path: &str,
7854) -> String {
7855 digest_fields(
7856 PURPOSE_CURATION_ITEM_KEY_DOMAIN,
7857 &[
7858 &project.as_bytes(),
7859 &generation.get().to_le_bytes(),
7860 task.as_bytes(),
7861 path.as_bytes(),
7862 ],
7863 )
7864}
7865
7866fn purpose_curation_state_token(work_key: &str, purpose: &Purpose) -> String {
7868 let purpose_presence = [u8::from(purpose.purpose.is_some())];
7869 digest_fields(
7870 PURPOSE_CURATION_STATE_TOKEN_DOMAIN,
7871 &[
7872 work_key.as_bytes(),
7873 &purpose_presence,
7874 purpose.purpose.as_deref().unwrap_or_default().as_bytes(),
7875 purpose.source.as_str().as_bytes(),
7876 purpose.status.as_str().as_bytes(),
7877 ],
7878 )
7879}
7880
7881fn purpose_curation_batch_work_key(
7883 project: ProjectInstanceId,
7884 generation: IndexGeneration,
7885 task: &str,
7886 items: &[PurposeCurationCandidate],
7887) -> String {
7888 let mut hasher = Hasher::new();
7889 digest_field(&mut hasher, PURPOSE_CURATION_BATCH_KEY_DOMAIN.as_bytes());
7890 digest_field(&mut hasher, &project.as_bytes());
7891 digest_field(&mut hasher, &generation.get().to_le_bytes());
7892 digest_field(&mut hasher, task.as_bytes());
7893 for item in items {
7894 digest_field(&mut hasher, item.work_key.as_bytes());
7895 digest_field(&mut hasher, item.state_token.as_bytes());
7896 }
7897 hasher.finalize().to_hex().to_string()
7898}
7899
7900fn digest_fields(domain: &str, fields: &[&[u8]]) -> String {
7902 let mut hasher = Hasher::new();
7903 digest_field(&mut hasher, domain.as_bytes());
7904 for field in fields {
7905 digest_field(&mut hasher, field);
7906 }
7907 hasher.finalize().to_hex().to_string()
7908}
7909
7910fn digest_field(hasher: &mut Hasher, value: &[u8]) {
7912 let length = u64::try_from(value.len()).unwrap_or(u64::MAX);
7913 hasher.update(&length.to_le_bytes());
7914 hasher.update(value);
7915}
7916
7917fn count_to_usize(field: &'static str, value: i64) -> DbResult<usize> {
7919 usize::try_from(value).map_err(|source| DbError::InvalidCount {
7920 field,
7921 value,
7922 source,
7923 })
7924}
7925
7926fn usize_to_i64(value: usize) -> i64 {
7928 i64::try_from(value).unwrap_or(i64::MAX)
7929}
7930
7931fn i64_to_usize(value: i64) -> usize {
7933 usize::try_from(value.max(0)).unwrap_or(usize::MAX)
7934}
7935
7936fn option_u64_from_sql(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<Option<u64>> {
7938 row.get::<_, Option<i64>>(index)?
7939 .map(|value| {
7940 u64::try_from(value).map_err(|source| {
7941 rusqlite::Error::FromSqlConversionFailure(
7942 index,
7943 rusqlite::types::Type::Integer,
7944 Box::new(source),
7945 )
7946 })
7947 })
7948 .transpose()
7949}
7950
7951#[cfg(test)]
7953fn option_usize_to_i64(field: &'static str, value: Option<usize>) -> DbResult<Option<i64>> {
7954 value
7955 .map(|value| {
7956 i64::try_from(value).map_err(|_source| DbError::TelemetryIntegerOverflow { field })
7957 })
7958 .transpose()
7959}
7960
7961fn classified_symbols_sql(
7963 file: Option<&str>,
7964 query: Option<&str>,
7965 selection: ContentSelection,
7966 limit: usize,
7967) -> (String, Vec<Value>) {
7968 let mut predicates = vec!["NOT (symbol.language IN ('docx', 'pdf') AND symbol.kind = 'unknown' AND symbol.name = 'document-text-coverage')".to_owned()];
7970 let mut bindings = Vec::new();
7971 if let Some(file) = file {
7972 bindings.push(Value::Text(file.to_string()));
7973 predicates.push(format!("symbol.path = ?{}", bindings.len()));
7974 }
7975 if let Some(query) = query {
7976 bindings.push(Value::Text(like_query(query)));
7977 let placeholder = bindings.len();
7978 let path_predicate = if file.is_some() {
7979 String::new()
7980 } else {
7981 format!(" OR symbol.path LIKE ?{placeholder}")
7982 };
7983 predicates.push(format!(
7984 "(symbol.name LIKE ?{placeholder} OR symbol.signature LIKE ?{placeholder} \
7985 OR symbol.documentation LIKE ?{placeholder}{path_predicate})"
7986 ));
7987 }
7988 match selection {
7989 ContentSelection::UnspecifiedLegacy => {}
7990 ContentSelection::Source => {
7991 bindings.push(Value::Text(
7992 ContentClassification::Source.as_str().to_string(),
7993 ));
7994 predicates.push(format!(
7995 "(classification.classification = ?{} OR classification.path IS NULL)",
7996 bindings.len()
7997 ));
7998 }
7999 ContentSelection::Documentation => {
8000 bindings.push(Value::Text(
8001 ContentClassification::Documentation.as_str().to_string(),
8002 ));
8003 predicates.push(format!(
8004 "(classification.classification = ?{} OR classification.path IS NULL)",
8005 bindings.len()
8006 ));
8007 }
8008 ContentSelection::Both => {
8009 bindings.push(Value::Text(
8010 ContentClassification::Source.as_str().to_string(),
8011 ));
8012 let source = bindings.len();
8013 bindings.push(Value::Text(
8014 ContentClassification::Documentation.as_str().to_string(),
8015 ));
8016 let documentation = bindings.len();
8017 predicates.push(format!(
8018 "(classification.classification IN (?{source}, ?{documentation}) \
8019 OR classification.path IS NULL)"
8020 ));
8021 }
8022 }
8023 bindings.push(Value::Integer(usize_to_i64(limit.max(1))));
8024 let limit = bindings.len();
8025 let where_clause = if predicates.is_empty() {
8026 String::new()
8027 } else {
8028 format!("WHERE {}", predicates.join(" AND "))
8029 };
8030 let sql = format!(
8031 "SELECT symbol.path, symbol.language, symbol.name, symbol.kind,
8032 symbol.signature, symbol.line_start, symbol.line_end, symbol.parent,
8033 symbol.parser, symbol.detail, symbol.exported, symbol.documentation,
8034 symbol.source_byte_start, symbol.source_byte_end,
8035 symbol.source_column_start, symbol.source_column_end,
8036 classification.classification
8037 FROM symbols AS symbol INDEXED BY idx_symbols_path
8038 LEFT JOIN file_content_classifications AS classification
8039 ON classification.path = symbol.path
8040 {where_clause}
8041 ORDER BY symbol.path, symbol.line_start, symbol.name
8042 LIMIT ?{limit}"
8043 );
8044 (sql, bindings)
8045}
8046
8047fn symbol_source_selector_values(symbol: &CodeSymbol) -> DbResult<[Option<i64>; 4]> {
8049 let Some(selector) = symbol.source_selector else {
8050 return Ok([None; 4]);
8051 };
8052 if symbol.line_start == 0
8053 || symbol.line_end < symbol.line_start
8054 || selector.byte_end < selector.byte_start
8055 || (symbol.line_start == symbol.line_end && selector.column_end < selector.column_start)
8056 {
8057 return Err(DbError::SymbolGraphRowShape {
8058 path: symbol.path.clone(),
8059 reason: "symbol source selector range is invalid",
8060 });
8061 }
8062 let narrow = |value| match i64::try_from(value) {
8063 Ok(value) => Ok(Some(value)),
8064 Err(_) => Err(DbError::SymbolGraphRowShape {
8065 path: symbol.path.clone(),
8066 reason: "symbol source selector exceeds the SQLite integer range",
8067 }),
8068 };
8069 Ok([
8070 narrow(selector.byte_start)?,
8071 narrow(selector.byte_end)?,
8072 narrow(selector.column_start)?,
8073 narrow(selector.column_end)?,
8074 ])
8075}
8076
8077fn symbol_source_selector_from_row(
8079 row: &rusqlite::Row<'_>,
8080 line_start: usize,
8081 line_end: usize,
8082) -> rusqlite::Result<Option<SymbolSourceSelector>> {
8083 let raw = [
8084 row.get::<_, Option<i64>>(12)?,
8085 row.get::<_, Option<i64>>(13)?,
8086 row.get::<_, Option<i64>>(14)?,
8087 row.get::<_, Option<i64>>(15)?,
8088 ];
8089 let [
8090 Some(byte_start),
8091 Some(byte_end),
8092 Some(column_start),
8093 Some(column_end),
8094 ] = raw
8095 else {
8096 if raw.iter().all(Option::is_none) {
8097 return Ok(None);
8098 }
8099 return Err(invalid_symbol_source_selector(
8100 "symbol source selector columns are only partially populated",
8101 ));
8102 };
8103 if byte_start < 0
8104 || byte_end < byte_start
8105 || column_start < 0
8106 || column_end < 0
8107 || line_start == 0
8108 || line_end < line_start
8109 || (line_start == line_end && column_end < column_start)
8110 {
8111 return Err(invalid_symbol_source_selector(
8112 "symbol source selector range is invalid",
8113 ));
8114 }
8115 Ok(Some(SymbolSourceSelector {
8116 byte_start: symbol_source_selector_usize(byte_start)?,
8117 byte_end: symbol_source_selector_usize(byte_end)?,
8118 column_start: symbol_source_selector_usize(column_start)?,
8119 column_end: symbol_source_selector_usize(column_end)?,
8120 }))
8121}
8122
8123fn symbol_source_selector_usize(value: i64) -> rusqlite::Result<usize> {
8125 usize::try_from(value).map_err(|source| {
8126 rusqlite::Error::FromSqlConversionFailure(
8127 12,
8128 rusqlite::types::Type::Integer,
8129 Box::new(source),
8130 )
8131 })
8132}
8133
8134fn invalid_symbol_source_selector(message: &'static str) -> rusqlite::Error {
8136 rusqlite::Error::FromSqlConversionFailure(
8137 12,
8138 rusqlite::types::Type::Integer,
8139 Box::new(std::io::Error::other(message)),
8140 )
8141}
8142
8143fn code_symbol_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CodeSymbol> {
8145 let line_start = i64_to_usize(row.get::<_, i64>(5)?);
8146 let line_end = i64_to_usize(row.get::<_, i64>(6)?);
8147 Ok(CodeSymbol {
8148 path: row.get(0)?,
8149 language: row.get(1)?,
8150 name: row.get(2)?,
8151 kind: SymbolKind::from_db(row.get_ref(3)?.as_str()?),
8152 signature: row.get(4)?,
8153 line_start,
8154 line_end,
8155 source_selector: symbol_source_selector_from_row(row, line_start, line_end)?,
8156 parent: row.get(7)?,
8157 parser: ParserKind::from_db(row.get_ref(8)?.as_str()?),
8158 detail: row.get(9)?,
8159 exported: row.get::<_, i64>(10)? != 0,
8160 documentation: row.get(11)?,
8161 })
8162}
8163
8164fn classified_symbol_from_row(row: &rusqlite::Row<'_>) -> DbResult<ClassifiedSymbol> {
8166 let symbol = code_symbol_from_row(row)?;
8167 let classification = row.get::<_, Option<String>>(16)?.ok_or_else(|| {
8168 DbError::FileContentClassificationMissing {
8169 path: symbol.path.clone(),
8170 }
8171 })?;
8172 Ok(ClassifiedSymbol {
8173 symbol,
8174 classification: content_classification::parse_classification(classification)?,
8175 })
8176}
8177
8178fn code_symbol_preflight_bytes(row: &rusqlite::Row<'_>) -> DbResult<u64> {
8180 let string_bytes = row.get::<_, i64>(16)?;
8181 let string_bytes = u64::try_from(string_bytes).map_err(|source| DbError::InvalidCount {
8182 field: "symbol persisted field bytes",
8183 value: string_bytes,
8184 source,
8185 })?;
8186 let row_bytes = u64::try_from(std::mem::size_of::<CodeSymbol>()).map_err(|source| {
8187 DbError::InvalidCount {
8188 field: "symbol persisted field bytes",
8189 value: i64::MAX,
8190 source,
8191 }
8192 })?;
8193 row_bytes.checked_add(string_bytes).ok_or_else(|| {
8194 GraphContractError::InvalidLimits {
8195 reason: "symbol persisted row bytes overflowed",
8196 }
8197 .into()
8198 })
8199}
8200
8201fn stored_import_relation_from_row(
8203 row: &rusqlite::Row<'_>,
8204) -> rusqlite::Result<StoredImportRelation> {
8205 Ok(StoredImportRelation {
8206 path: row.get(0)?,
8207 source_name: row.get(1)?,
8208 target_name: row.get(2)?,
8209 line: i64_to_usize(row.get::<_, i64>(3)?),
8210 })
8211}
8212
8213fn code_symbol_decoded_bytes(symbol: &CodeSymbol) -> DbResult<u64> {
8215 let capacities = [
8216 symbol.path.capacity(),
8217 symbol.language.as_ref().map_or(0, String::capacity),
8218 symbol.name.capacity(),
8219 symbol.signature.capacity(),
8220 symbol.documentation.as_ref().map_or(0, String::capacity),
8221 symbol.parent.as_ref().map_or(0, String::capacity),
8222 symbol.detail.as_ref().map_or(0, String::capacity),
8223 ];
8224 let mut bytes = u64::try_from(std::mem::size_of::<CodeSymbol>()).map_err(|source| {
8225 DbError::InvalidCount {
8226 field: "symbol decoded field bytes",
8227 value: i64::MAX,
8228 source,
8229 }
8230 })?;
8231 for capacity in capacities {
8232 let capacity = u64::try_from(capacity).map_err(|source| DbError::InvalidCount {
8233 field: "symbol decoded field bytes",
8234 value: i64::MAX,
8235 source,
8236 })?;
8237 bytes = bytes
8238 .checked_add(capacity)
8239 .ok_or(GraphContractError::InvalidLimits {
8240 reason: "symbol decoded row bytes overflowed",
8241 })?;
8242 }
8243 Ok(bytes)
8244}
8245
8246fn like_query(query: &str) -> String {
8248 format!("%{query}%")
8249}
8250
8251fn load_nodes_by_paths_sql(path_count: usize) -> String {
8253 let placeholders = numbered_placeholders(1, path_count);
8254 format!(
8255 "
8256 SELECT
8257 n.path,
8258 n.kind,
8259 n.parent_path,
8260 n.extension,
8261 n.language,
8262 n.size_bytes,
8263 n.mtime_ns,
8264 n.content_hash,
8265 p.purpose,
8266 p.source,
8267 p.status,
8268 s.summary
8269 FROM nodes n
8270 JOIN purposes p ON p.node_id = n.id
8271 LEFT JOIN summaries s ON s.node_id = n.id
8272 AND s.summary_level = 'node'
8273 AND s.subject = ''
8274 WHERE n.exists_now = 1 AND n.path IN ({placeholders})
8275 ORDER BY n.path
8276 "
8277 )
8278}
8279
8280fn numbered_placeholders(start: usize, count: usize) -> String {
8282 (start..start + count)
8283 .map(|index| format!("?{index}"))
8284 .collect::<Vec<_>>()
8285 .join(", ")
8286}
8287
8288fn generate_node_summary(node: &Node) -> String {
8290 match node.kind {
8291 NodeKind::Folder => format!("Folder for {}", path_label(&node.path)),
8292 NodeKind::File => file_summary(node),
8293 }
8294}
8295
8296fn file_summary(node: &Node) -> String {
8298 let language = node
8299 .language
8300 .as_deref()
8301 .or(node.extension.as_deref())
8302 .unwrap_or("unknown");
8303 let size = node.size_bytes.map_or_else(
8304 || "unknown size".to_string(),
8305 |bytes| format!("{bytes} bytes"),
8306 );
8307 format!("{language} file, {size}")
8308}
8309
8310fn path_label(path: &str) -> String {
8312 if path == "." {
8313 return "repository root".to_string();
8314 }
8315 path.rsplit('/')
8316 .next()
8317 .filter(|value| !value.is_empty())
8318 .unwrap_or(path)
8319 .replace(['-', '_'], " ")
8320}
8321
8322fn emit_unresolved_finding<F>(
8324 finding: HealthFinding,
8325 resolved_ids: &HashSet<String>,
8326 visitor: &mut F,
8327) -> DbResult<bool>
8328where
8329 F: FnMut(HealthFinding) -> DbResult<bool>,
8330{
8331 if resolved_ids.contains(&finding.id) {
8332 return Ok(true);
8333 }
8334 visitor(finding)
8335}
8336
8337fn purpose_health_spec_matches_query(spec: PurposeHealthSpec, query: &HealthQuery) -> bool {
8339 health_category_matches_query(spec.category, Severity::Warning, query)
8340}
8341
8342fn health_category_matches_query(category: &str, severity: Severity, query: &HealthQuery) -> bool {
8344 query
8345 .category
8346 .as_deref()
8347 .is_none_or(|requested| category.eq_ignore_ascii_case(requested))
8348 && query.severity.is_none_or(|requested| severity == requested)
8349}
8350
8351fn purpose_health_spec_for_status(status: &str) -> DbResult<PurposeHealthSpec> {
8353 PURPOSE_HEALTH_SPECS
8354 .iter()
8355 .copied()
8356 .find(|spec| spec.status == status)
8357 .ok_or_else(|| DbError::InvalidEnum {
8358 field: "status",
8359 value: status.to_string(),
8360 })
8361}
8362
8363fn agent_review_required_finding(path: String) -> HealthFinding {
8365 HealthFinding {
8366 id: finding_id(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED, &path, None),
8367 severity: Severity::Warning,
8368 category: CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
8369 path,
8370 related_path: None,
8371 message: MESSAGE_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
8372 recommendation: RECOMMENDATION_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
8373 }
8374}
8375
8376fn purpose_lifecycle_where_clause(
8378 specs: &[PurposeHealthSpec],
8379 path_prefix: Option<&str>,
8380 resolution_filter: HealthResolutionFilter<'_>,
8381 scope: HealthScope,
8382) -> (String, Vec<Value>) {
8383 let statuses = specs
8384 .iter()
8385 .map(|spec| format!("'{}'", spec.status))
8386 .collect::<Vec<_>>()
8387 .join(", ");
8388 let mut clauses = vec![
8389 "n.exists_now = 1".to_string(),
8390 format!("p.status IN ({statuses})"),
8391 ];
8392 let mut values = Vec::new();
8393
8394 if source_filter_applies_before_queue(scope) {
8395 clauses.push(source_relevant_node_expression("n"));
8396 }
8397 if scope.high_impact_queue() {
8398 clauses.push(purpose_default_queue_node_expression("n", "p", scope));
8399 }
8400
8401 let normalized_prefix = path_prefix
8402 .map(normalize_repo_path_prefix)
8403 .filter(|prefix| prefix != ".");
8404 if let Some(prefix) = normalized_prefix {
8405 clauses.push(format!(
8406 "(n.path = ?{} OR n.path LIKE ?{} ESCAPE '\\')",
8407 values.len() + 1,
8408 values.len() + 2
8409 ));
8410 values.push(Value::from(prefix.clone()));
8411 values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8412 }
8413
8414 match resolution_filter {
8415 HealthResolutionFilter::Explicit(resolved_ids) => {
8416 for spec in specs {
8417 let resolved_paths = resolved_purpose_paths(resolved_ids, spec.category);
8418 if !resolved_paths.is_empty() {
8419 clauses.push(format!(
8420 "NOT (p.status = '{}' AND n.path IN ({}))",
8421 spec.status,
8422 numbered_placeholders(values.len() + 1, resolved_paths.len())
8423 ));
8424 values.extend(resolved_paths.into_iter().map(Value::from));
8425 }
8426 }
8427 }
8428 HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(
8429 &purpose_lifecycle_finding_id_expression(specs, "n", "p"),
8430 )),
8431 }
8432
8433 (clauses.join(" AND "), values)
8434}
8435
8436fn purpose_status_where_clause(
8438 spec: PurposeHealthSpec,
8439 path_prefix: Option<&str>,
8440 resolution_filter: HealthResolutionFilter<'_>,
8441 scope: HealthScope,
8442) -> (String, Vec<Value>) {
8443 let mut clauses = vec!["n.exists_now = 1".to_string(), "p.status = ?1".to_string()];
8444 let mut values = vec![Value::from(spec.status.to_string())];
8445
8446 if source_filter_applies_before_queue(scope) {
8447 clauses.push(source_relevant_node_expression("n"));
8448 }
8449 if scope.high_impact_queue() {
8450 clauses.push(purpose_default_queue_node_expression("n", "p", scope));
8451 }
8452
8453 let normalized_prefix = path_prefix
8454 .map(normalize_repo_path_prefix)
8455 .filter(|prefix| prefix != ".");
8456 if let Some(prefix) = normalized_prefix {
8457 clauses.push(format!(
8458 "(n.path = ?{} OR n.path LIKE ?{} ESCAPE '\\')",
8459 values.len() + 1,
8460 values.len() + 2
8461 ));
8462 values.push(Value::from(prefix.clone()));
8463 values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8464 }
8465
8466 match resolution_filter {
8467 HealthResolutionFilter::Explicit(resolved_ids) => {
8468 let resolved_paths = resolved_purpose_paths(resolved_ids, spec.category);
8469 if !resolved_paths.is_empty() {
8470 clauses.push(format!(
8471 "n.path NOT IN ({})",
8472 numbered_placeholders(values.len() + 1, resolved_paths.len())
8473 ));
8474 values.extend(resolved_paths.into_iter().map(Value::from));
8475 }
8476 }
8477 HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(&format!(
8478 "'{}:' || n.path || ':'",
8479 spec.category
8480 ))),
8481 }
8482
8483 (clauses.join(" AND "), values)
8484}
8485
8486fn structural_finding_where_clause(
8488 category: &str,
8489 path_prefix: Option<&str>,
8490 resolution_filter: HealthResolutionFilter<'_>,
8491 scope: HealthScope,
8492 first_placeholder: usize,
8493) -> (String, Vec<Value>) {
8494 let mut placeholder = first_placeholder;
8495 let mut clauses = Vec::new();
8496 let mut values = Vec::new();
8497
8498 if source_filter_applies_before_queue(scope) {
8499 clauses.push("source_relevant = 1".to_string());
8500 }
8501 if scope.high_impact_queue() {
8502 clauses.push(purpose_default_queue_finding_expression(scope));
8503 }
8504
8505 let normalized_prefix = path_prefix
8506 .map(normalize_repo_path_prefix)
8507 .filter(|prefix| prefix != ".");
8508 if let Some(prefix) = normalized_prefix {
8509 clauses.push(format!(
8510 "((path = ?{path_exact} OR path LIKE ?{path_descendant} ESCAPE '\\') \
8511 OR (related_path = ?{related_exact} OR related_path LIKE ?{related_descendant} ESCAPE '\\'))",
8512 path_exact = placeholder,
8513 path_descendant = placeholder + 1,
8514 related_exact = placeholder + 2,
8515 related_descendant = placeholder + 3
8516 ));
8517 values.push(Value::from(prefix.clone()));
8518 values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8519 values.push(Value::from(prefix.clone()));
8520 values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8521 placeholder += 4;
8522 }
8523
8524 match resolution_filter {
8525 HealthResolutionFilter::Explicit(resolved_ids) => {
8526 let resolved_ids = resolved_ids_for_category(resolved_ids, category);
8527 if !resolved_ids.is_empty() {
8528 clauses.push(format!(
8529 "('{category}:' || path || ':' || related_path) NOT IN ({})",
8530 numbered_placeholders(placeholder, resolved_ids.len())
8531 ));
8532 values.extend(resolved_ids.into_iter().map(Value::from));
8533 }
8534 }
8535 HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(&format!(
8536 "'{category}:' || path || ':' || related_path"
8537 ))),
8538 }
8539
8540 if clauses.is_empty() {
8541 (String::new(), values)
8542 } else {
8543 (format!("WHERE {}", clauses.join(" AND ")), values)
8544 }
8545}
8546
8547fn purpose_lifecycle_finding_id_expression(
8549 specs: &[PurposeHealthSpec],
8550 node_alias: &str,
8551 purpose_alias: &str,
8552) -> String {
8553 let category_cases = specs
8554 .iter()
8555 .map(|spec| format!("WHEN '{}' THEN '{}:'", spec.status, spec.category))
8556 .collect::<Vec<_>>()
8557 .join(" ");
8558 format!(
8559 "(CASE {purpose_alias}.status {category_cases} ELSE '' END || {node_alias}.path || ':')"
8560 )
8561}
8562
8563fn stored_resolution_filter_clause(finding_id_expression: &str) -> String {
8565 format!(
8566 "NOT EXISTS (SELECT 1 FROM health_resolutions hr WHERE hr.finding_id = {finding_id_expression})"
8567 )
8568}
8569
8570fn purpose_review_candidate_expression(node_alias: &str, scope: HealthScope) -> String {
8572 let scope = match scope {
8573 HealthScope::All => HealthScope::PurposeStrict,
8574 other => other,
8575 };
8576 purpose_default_queue_node_expression(node_alias, "p", scope)
8577}
8578
8579fn purpose_default_queue_node_expression(
8581 node_alias: &str,
8582 purpose_alias: &str,
8583 scope: HealthScope,
8584) -> String {
8585 let asset_clause = if scope.include_assets() {
8586 format!(
8587 " OR ({node_alias}.kind = 'file' AND NOT ({}))",
8588 source_relevant_node_expression(node_alias)
8589 )
8590 } else {
8591 String::new()
8592 };
8593 let source_file_clause = if scope.include_source_files() {
8594 format!(" OR ({node_alias}.kind = 'file' AND COALESCE({node_alias}.language, '') <> '')")
8595 } else {
8596 String::new()
8597 };
8598 let all_file_clause = if scope.include_all_files() {
8599 format!(" OR {node_alias}.kind = 'file'")
8600 } else {
8601 String::new()
8602 };
8603 let stale_queue_sources = sql_string_literals(STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES);
8604 format!(
8605 "({node_alias}.kind = 'folder' \
8606 OR ({node_alias}.kind = 'file' \
8607 AND {purpose_alias}.status = 'stale' \
8608 AND {purpose_alias}.source IN ({stale_queue_sources})) \
8609 OR ({node_alias}.kind = 'file' AND {}){source_file_clause}{all_file_clause}{asset_clause})",
8610 high_impact_file_path_expression(&format!("lower({node_alias}.path)")),
8611 )
8612}
8613
8614fn purpose_default_queue_finding_expression(scope: HealthScope) -> String {
8616 let asset_clause = if scope.include_assets() {
8617 " OR (kind = 'file' AND COALESCE(language, '') = '')"
8618 } else {
8619 ""
8620 };
8621 let source_file_clause = if scope.include_source_files() {
8622 " OR (kind = 'file' AND COALESCE(language, '') <> '')"
8623 } else {
8624 ""
8625 };
8626 let all_file_clause = if scope.include_all_files() {
8627 " OR kind = 'file'"
8628 } else {
8629 ""
8630 };
8631 format!(
8632 "(kind = 'folder' OR (kind = 'file' AND {}){source_file_clause}{all_file_clause}{asset_clause})",
8633 high_impact_file_path_expression("lower(path)")
8634 )
8635}
8636
8637fn purpose_default_queue_order_expression(node_alias: &str, purpose_alias: &str) -> String {
8639 let stale_queue_sources = sql_string_literals(STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES);
8640 format!(
8641 "CASE \
8642 WHEN {node_alias}.kind = 'folder' THEN 0 \
8643 WHEN {node_alias}.kind = 'file' \
8644 AND {purpose_alias}.status = 'stale' \
8645 AND ({purpose_alias}.source IN ({stale_queue_sources}) OR {}) THEN 1 \
8646 WHEN {node_alias}.kind = 'file' AND {} THEN 2 \
8647 ELSE 3 \
8648 END, {node_alias}.path",
8649 high_impact_file_path_expression(&format!("lower({node_alias}.path)")),
8650 high_impact_file_path_expression(&format!("lower({node_alias}.path)"))
8651 )
8652}
8653
8654const STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES: &[&str] = &["human", "imported"];
8656
8657fn source_filter_applies_before_queue(scope: HealthScope) -> bool {
8659 scope.source_only_filter() && !scope.high_impact_queue()
8660}
8661
8662fn sql_string_literals(values: &[&str]) -> String {
8664 values
8665 .iter()
8666 .map(|value| format!("'{}'", value.replace('\'', "''")))
8667 .collect::<Vec<_>>()
8668 .join(", ")
8669}
8670
8671fn high_impact_file_path_expression(lower_path: &str) -> String {
8673 let name_matches = HIGH_IMPACT_FILE_NAMES
8674 .iter()
8675 .map(|name| format!("{lower_path} = '{name}' OR {lower_path} LIKE '%/{name}'"))
8676 .collect::<Vec<_>>()
8677 .join(" OR ");
8678 let prefix_matches = HIGH_IMPACT_PATH_PREFIXES
8679 .iter()
8680 .map(|prefix| format!("{lower_path} LIKE '{prefix}%'"))
8681 .collect::<Vec<_>>()
8682 .join(" OR ");
8683 let segment_matches = HIGH_IMPACT_PATH_SEGMENTS
8684 .iter()
8685 .map(|segment| format!("{lower_path} LIKE '%{segment}%'"))
8686 .collect::<Vec<_>>()
8687 .join(" OR ");
8688 format!("({name_matches} OR {prefix_matches} OR {segment_matches})")
8689}
8690
8691fn source_relevant_node_expression(alias: &str) -> String {
8693 format!(
8694 "(({alias}.kind = 'file' AND COALESCE({alias}.language, '') <> '') \
8695 OR ({alias}.kind = 'folder' AND EXISTS (\
8696 SELECT 1 FROM nodes source_child \
8697 WHERE source_child.exists_now = 1 \
8698 AND source_child.kind = 'file' \
8699 AND COALESCE(source_child.language, '') <> '' \
8700 AND (\
8701 {alias}.path = '.' \
8702 OR source_child.parent_path = {alias}.path \
8703 OR substr(source_child.parent_path, 1, length({alias}.path) + 1) = {alias}.path || '/'\
8704 )\
8705 )))"
8706 )
8707}
8708
8709fn resolved_purpose_paths(resolved_ids: &[String], category: &str) -> Vec<String> {
8711 let prefix = format!("{category}:");
8712 resolved_ids
8713 .iter()
8714 .filter_map(|id| {
8715 id.strip_prefix(&prefix)
8716 .and_then(|rest| rest.strip_suffix(':'))
8717 .filter(|path| !path.is_empty())
8718 .map(ToOwned::to_owned)
8719 })
8720 .collect()
8721}
8722
8723fn resolved_ids_for_category(resolved_ids: &[String], category: &str) -> Vec<String> {
8725 let prefix = format!("{category}:");
8726 resolved_ids
8727 .iter()
8728 .filter(|id| id.starts_with(&prefix))
8729 .cloned()
8730 .collect()
8731}
8732
8733#[cfg(test)]
8734mod tests {
8735 use super::*;
8736 use projectatlas_core::telemetry::{
8737 READ_AVOIDANCE_CONFIDENCE_MODELED, READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED,
8738 READ_AVOIDANCE_CONFIDENCE_OBSERVED, TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
8739 TOKEN_ACCURACY_HEURISTIC, TOKEN_BASELINE_SELECTED_CANDIDATES,
8740 TOKEN_BUCKET_FULL_FILE_COMPRESSION, TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
8741 TOKEN_COMMAND_SEARCH, TOKEN_CONFIDENCE_INFERRED, TOKEN_DEDUPE_SCOPE_EVENT,
8742 usage_from_estimates, usage_from_estimates_with_accounting,
8743 usage_from_estimates_with_context, usage_from_text,
8744 };
8745 use projectatlas_core::{NodeKind, normalized_parent};
8746 use std::error::Error;
8747 use std::fmt::Debug;
8748 use std::fs;
8749 use std::io;
8750 use std::time::Instant;
8751
8752 #[test]
8753 fn unsupported_schema_versions_follow_the_migration_inventory() -> Result<(), Box<dyn Error>> {
8754 for found in schema::PREVIOUS_SCHEMA_VERSION..=schema::SCHEMA_VERSION {
8755 let error = DbError::SchemaVersion {
8756 found,
8757 expected: schema::SCHEMA_VERSION,
8758 };
8759 let expected_migration = if found < schema::SCHEMA_VERSION {
8760 Some((
8761 found,
8762 schema::SCHEMA_VERSION,
8763 u32::try_from(schema::SCHEMA_VERSION - found)?,
8764 ))
8765 } else {
8766 None
8767 };
8768 require_eq(
8769 &error.supported_schema_migration(),
8770 &expected_migration,
8771 "admitted schema migration",
8772 )?;
8773 require_eq(
8774 &error.unsupported_schema_version(),
8775 &None,
8776 "admitted schema version",
8777 )?;
8778 }
8779 for found in [
8780 schema::PREVIOUS_SCHEMA_VERSION - 1,
8781 schema::SCHEMA_VERSION + 1,
8782 ] {
8783 let error = DbError::SchemaVersion {
8784 found,
8785 expected: schema::SCHEMA_VERSION,
8786 };
8787 require_eq(
8788 &error.unsupported_schema_version(),
8789 &Some((found, schema::SCHEMA_VERSION)),
8790 "unsupported schema version",
8791 )?;
8792 require_eq(
8793 &error.supported_schema_migration(),
8794 &None,
8795 "unsupported schema migration",
8796 )?;
8797 }
8798 require_eq(
8799 &DbError::SchemaVersionMissing.unsupported_schema_version(),
8800 &None,
8801 "unrelated database error",
8802 )?;
8803 require_eq(
8804 &DbError::SchemaVersionMissing.supported_schema_migration(),
8805 &None,
8806 "unrelated migration error",
8807 )?;
8808 Ok(())
8809 }
8810
8811 #[test]
8812 fn stores_nodes_and_overview() -> Result<(), Box<dyn Error>> {
8813 let mut store = AtlasStore::in_memory()?;
8814 let node = Node {
8815 path: "src/main.rs".to_string(),
8816 kind: NodeKind::File,
8817 parent_path: normalized_parent("src/main.rs"),
8818 extension: Some(".rs".to_string()),
8819 language: Some("rust".to_string()),
8820 size_bytes: Some(12),
8821 mtime_ns: Some(10),
8822 content_hash: Some("abc".to_string()),
8823 };
8824 store.replace_scan(&[node])?;
8825 let overview = store.overview()?;
8826 require_eq(&overview.files, &1, "file count")?;
8827 require_eq(&overview.missing_purposes, &1, "missing purpose count")?;
8828 let nodes = store.load_nodes()?;
8829 require_eq(
8830 &nodes[0].purpose.purpose,
8831 &None,
8832 "purpose remains separate from summary",
8833 )?;
8834 require_eq(
8835 &nodes[0].summary,
8836 &Some("rust file, 12 bytes".to_string()),
8837 "node-level summary",
8838 )?;
8839 let loaded = store
8840 .load_node_by_path("src/main.rs")?
8841 .ok_or_else(|| io::Error::other("indexed node was not found by path"))?;
8842 require_eq(
8843 &loaded.node.path,
8844 &"src/main.rs".to_string(),
8845 "targeted path lookup",
8846 )?;
8847 require_eq(
8848 &store.load_node_by_path("src/missing.rs")?.is_none(),
8849 &true,
8850 "missing targeted path lookup",
8851 )?;
8852 Ok(())
8853 }
8854
8855 #[test]
8856 fn source_node_cursor_omits_metadata_and_preserves_order_and_cancellation()
8857 -> Result<(), Box<dyn Error>> {
8858 let mut store = AtlasStore::in_memory()?;
8859 let expected = [
8860 test_file_node("src/a.rs", "a"),
8861 test_file_node("src/z.rs", "z"),
8862 test_file_node("src/é.rs", "unicode"),
8863 ];
8864 store.replace_scan(&expected)?;
8865 store
8867 .connection
8868 .execute("UPDATE purposes SET status = 'invalid'", [])?;
8869 let control = IndexWorkControl::new(projectatlas_core::IndexCancellation::new(), None);
8870 let mut visited = Vec::new();
8871 store.visit_source_nodes_controlled(&control, |node| {
8872 visited.push(node);
8873 Ok(())
8874 })?;
8875 require_eq(&visited, &expected.to_vec(), "ordered source-only rows")?;
8876 require(
8877 store.load_nodes().is_err(),
8878 "full rows still validate authored state",
8879 )?;
8880
8881 let cancellation = projectatlas_core::IndexCancellation::new();
8882 let control = IndexWorkControl::new(cancellation.clone(), None);
8883 let mut count = 0;
8884 let result = store.visit_source_nodes_controlled(&control, |_| {
8885 count += 1;
8886 cancellation.cancel();
8887 Ok(())
8888 });
8889 require_eq(&count, &1, "source rows consumed before cancellation")?;
8890 require(
8891 matches!(
8892 result,
8893 Err(DbError::IndexWork(IndexWorkFailure::Cancelled { .. }))
8894 ),
8895 "source cursor cancellation remains typed",
8896 )?;
8897 store.visit_source_nodes_controlled(
8899 &IndexWorkControl::new(projectatlas_core::IndexCancellation::new(), None),
8900 |_| Ok(()),
8901 )?;
8902 Ok(())
8903 }
8904
8905 #[test]
8906 fn source_node_cursor_keeps_a_read_snapshot_across_writer_commit() -> Result<(), Box<dyn Error>>
8907 {
8908 let temp = tempfile::tempdir()?;
8909 let database = temp.path().join("projectatlas.db");
8910 let mut writer = AtlasStore::open(&database)?;
8911 let previous = test_file_node("lib.rs", "previous");
8912 writer.replace_scan(std::slice::from_ref(&previous))?;
8913 let reader = AtlasStore::open_read_only(&database)?;
8914 let control = IndexWorkControl::new(projectatlas_core::IndexCancellation::new(), None);
8915 let mut observed = Vec::new();
8916 reader.visit_source_nodes_controlled(&control, |node| {
8917 observed.push(node);
8918 Ok(())
8919 })?;
8920 writer.upsert_scan_nodes(&[test_file_node("lib.rs", "updated")])?;
8921 observed.clear();
8922 reader.visit_source_nodes_controlled(&control, |node| {
8923 observed.push(node);
8924 Ok(())
8925 })?;
8926 require_eq(
8927 &observed,
8928 &vec![previous],
8929 "cursor keeps captured source snapshot",
8930 )?;
8931 reader.finish_index_read_snapshot()?;
8932 observed.clear();
8933 let current = AtlasStore::open_read_only(&database)?;
8934 current.visit_source_nodes_controlled(&control, |node| {
8935 observed.push(node);
8936 Ok(())
8937 })?;
8938 require_eq(
8939 &observed,
8940 &vec![test_file_node("lib.rs", "updated")],
8941 "new snapshot sees committed source",
8942 )?;
8943 current.finish_index_read_snapshot()?;
8944 Ok(())
8945 }
8946
8947 #[test]
8948 fn source_node_cursor_rejects_corrupt_fields_and_uses_the_path_index()
8949 -> Result<(), Box<dyn Error>> {
8950 let mut store = AtlasStore::in_memory()?;
8951 store.replace_scan(&[test_file_node("src/a.rs", "a")])?;
8952 let plan = store
8953 .connection
8954 .prepare(
8955 "EXPLAIN QUERY PLAN SELECT n.path, n.kind, n.parent_path, n.extension, n.language,
8956 n.size_bytes, n.mtime_ns, n.content_hash
8957 FROM nodes n JOIN purposes p ON p.node_id = n.id
8958 WHERE n.exists_now = 1 ORDER BY n.path",
8959 )?
8960 .query_map([], |row| row.get::<_, String>(3))?
8961 .collect::<Result<Vec<_>, _>>()?;
8962 require(
8963 plan.iter().any(|line| line.contains("USING INDEX"))
8964 && !plan.iter().any(|line| line.contains("TEMP B-TREE")),
8965 "source cursor streams path index order without a temporary sort",
8966 )?;
8967 store
8968 .connection
8969 .execute("UPDATE nodes SET size_bytes = -1", [])?;
8970 let control = IndexWorkControl::new(projectatlas_core::IndexCancellation::new(), None);
8971 let mut visited = 0;
8972 let result = store.visit_source_nodes_controlled(&control, |_| {
8973 visited += 1;
8974 Ok(())
8975 });
8976 require(result.is_err(), "invalid source integer is refused")?;
8977 require_eq(&visited, &0, "corrupt source row does not reach visitor")?;
8978 Ok(())
8979 }
8980
8981 #[test]
8982 fn load_nodes_rejects_negative_size_bytes_without_partial_page() -> Result<(), Box<dyn Error>> {
8983 let mut store = AtlasStore::in_memory()?;
8984 store.replace_scan(&[
8985 test_file_node("src/a.rs", "hash-a"),
8986 test_file_node("src/b.rs", "hash-b"),
8987 ])?;
8988 store.connection.execute(
8989 "UPDATE nodes SET size_bytes = -1 WHERE path = 'src/b.rs'",
8990 [],
8991 )?;
8992
8993 let Err(error) = store.load_nodes() else {
8994 return Err(io::Error::other("negative node size returned a partial page").into());
8995 };
8996 require(
8997 matches!(
8998 error,
8999 DbError::Sqlite(rusqlite::Error::FromSqlConversionFailure(
9000 5,
9001 rusqlite::types::Type::Integer,
9002 _
9003 ))
9004 ),
9005 "negative node size returned the wrong conversion error",
9006 )?;
9007 Ok(())
9008 }
9009
9010 #[test]
9011 fn existing_project_open_refuses_fresh_storage_and_preserves_supported_state()
9012 -> Result<(), Box<dyn Error>> {
9013 let temp = tempfile::tempdir()?;
9014 let root = temp.path();
9015 let database = root.join("existing.db");
9016 require(
9017 matches!(
9018 AtlasStore::open_existing_for_project(&database, root),
9019 Err(DbError::InitializationRequired)
9020 ),
9021 "missing database was admitted",
9022 )?;
9023 require(
9024 !database.exists(),
9025 "existing open created an absent database",
9026 )?;
9027 fs::write(&database, [])?;
9028 require(
9029 matches!(
9030 AtlasStore::open_existing_for_project(&database, root),
9031 Err(DbError::InitializationRequired)
9032 ),
9033 "empty database was admitted",
9034 )?;
9035 require_eq(
9036 &fs::read(&database)?,
9037 &Vec::<u8>::new(),
9038 "empty database bytes unchanged",
9039 )?;
9040 require_eq(
9041 &fs::read_dir(root)?.count(),
9042 &1,
9043 "no fresh journal or sidecar",
9044 )?;
9045 {
9046 let connection = Connection::open(&database)?;
9047 require(
9048 matches!(
9049 schema::migrate_existing_with_project_root(&connection, None, None),
9050 Err(DbError::InitializationRequired)
9051 ),
9052 "migration admitted fresh replacement",
9053 )?;
9054 require_eq(
9055 &connection.query_row(
9056 "SELECT count(*) FROM sqlite_schema WHERE type = 'table'",
9057 [],
9058 |row| row.get::<_, i64>(0),
9059 )?,
9060 &0,
9061 "migration did not create a schema",
9062 )?;
9063 }
9064 let identity = {
9065 let mut store = AtlasStore::open_for_project(&database, root)?;
9066 store.replace_scan(&[test_file_node("lib.rs", "source-hash")])?;
9067 store.set_purpose("lib.rs", "Preserve authored state", PurposeSource::Agent)?;
9068 store.project_instance_id()?
9069 };
9070 let current = AtlasStore::open_existing_for_project(&database, root)?;
9071 require_eq(
9072 ¤t.project_instance_id()?,
9073 &identity,
9074 "existing project identity retained",
9075 )?;
9076 require_eq(
9077 ¤t
9078 .load_node_by_path("lib.rs")?
9079 .and_then(|node| node.purpose.purpose),
9080 &Some("Preserve authored state".to_string()),
9081 "existing purpose retained",
9082 )?;
9083 schema::drop_worktree_native_identity_schema(¤t.connection)?;
9084 current.connection.execute_batch(
9085 "DROP TABLE graph_identity_rejections;
9086 UPDATE metadata SET value = '20' WHERE key = 'schema_version';",
9087 )?;
9088 drop(current);
9089 let upgraded = AtlasStore::open_existing_for_project(&database, root)?;
9090 require_eq(
9091 &upgraded
9092 .load_node_by_path("lib.rs")?
9093 .and_then(|node| node.purpose.purpose),
9094 &Some("Preserve authored state".to_string()),
9095 "supported migration preserved purpose",
9096 )?;
9097 require_eq(
9098 &schema::preflight(&database, None)?.0.state,
9099 &SchemaState::Current,
9100 "supported migration completed",
9101 )?;
9102 Ok(())
9103 }
9104
9105 #[test]
9106 fn validated_existing_database_paths_are_never_recreated() -> Result<(), Box<dyn Error>> {
9107 let temp = tempfile::tempdir()?;
9108
9109 let current_path = temp.path().join("current.db");
9110 drop(AtlasStore::open(¤t_path)?);
9111 let (current, current_location) = schema::preflight(¤t_path, None)?;
9112 require_eq(
9113 ¤t.state,
9114 &SchemaState::Current,
9115 "current preflight state",
9116 )?;
9117 fs::remove_file(¤t_path)?;
9118 if Connection::open_with_flags(
9119 ¤t_path,
9120 writable_open_flags(current.state, current_location.database_exists),
9121 )
9122 .is_ok()
9123 {
9124 return Err(io::Error::other("current database path was recreated").into());
9125 }
9126 require_eq(¤t_path.exists(), &false, "current path stays absent")?;
9127
9128 let released_path = temp.path().join("released.db");
9129 let released_root = temp.path().join("released-root");
9130 write_released_schema_eight_compatibility_fixture(&released_path, &released_root)?;
9131 let (released, released_location) = schema::preflight(&released_path, None)?;
9132 require_eq(
9133 &released.state,
9134 &SchemaState::UpgradeRequired,
9135 "released preflight state",
9136 )?;
9137 fs::remove_file(&released_path)?;
9138 if Connection::open_with_flags(
9139 &released_path,
9140 writable_open_flags(released.state, released_location.database_exists),
9141 )
9142 .is_ok()
9143 {
9144 return Err(io::Error::other("released database path was recreated").into());
9145 }
9146 require_eq(
9147 &released_path.exists(),
9148 &false,
9149 "released path stays absent",
9150 )?;
9151
9152 let fresh_path = temp.path().join("fresh.db");
9153 let (fresh, _) = schema::preflight(&fresh_path, None)?;
9154 require_eq(&fresh.state, &SchemaState::Fresh, "fresh preflight state")?;
9155 drop(AtlasStore::open(&fresh_path)?);
9156 require_eq(&fresh_path.is_file(), &true, "fresh path is created")?;
9157 drop(AtlasStore::open_read_only(&fresh_path)?);
9158 Ok(())
9159 }
9160
9161 #[cfg(windows)]
9162 #[test]
9163 fn released_schema_layouts_upgrade_without_losing_local_state() -> Result<(), Box<dyn Error>> {
9164 let layouts: [(&str, fn(&Path, &Path) -> Result<(), Box<dyn Error>>); 2] = [
9165 (
9166 "fresh-v0.3.26",
9167 write_released_schema_eight_compatibility_fixture,
9168 ),
9169 (
9170 "evolved-v0.3.11-to-v0.3.26",
9171 write_evolved_released_schema_eight_compatibility_fixture,
9172 ),
9173 ];
9174 let mut previous_binding = None;
9175 for (label, write_fixture) in layouts {
9176 let binding =
9177 assert_released_schema_upgrade_preserves_local_state(label, write_fixture)?;
9178 if previous_binding
9179 .as_ref()
9180 .is_some_and(|previous| previous == &binding)
9181 {
9182 return Err(io::Error::other(
9183 "independent released-schema databases shared one project binding",
9184 )
9185 .into());
9186 }
9187 previous_binding = Some(binding);
9188 }
9189 Ok(())
9190 }
9191
9192 #[cfg(windows)]
9194 fn assert_released_schema_upgrade_preserves_local_state(
9195 label: &str,
9196 write_fixture: fn(&Path, &Path) -> Result<(), Box<dyn Error>>,
9197 ) -> Result<CapturedProjectBinding, Box<dyn Error>> {
9198 let temp = tempfile::tempdir()?;
9199 let db_path = temp.path().join("projectatlas.db");
9200 let root = temp.path().join("repository");
9201 write_fixture(&db_path, &root)?;
9202 fs::create_dir_all(&root)?;
9203 let database_before_read = fs::read(&db_path)?;
9204
9205 let Err(read_error) = AtlasStore::open_read_only(&db_path) else {
9206 return Err(io::Error::other("schema-8 read-only open unexpectedly succeeded").into());
9207 };
9208 require_eq(
9209 &matches!(
9210 read_error,
9211 DbError::SchemaVersion {
9212 found: PREVIOUS_SCHEMA_VERSION,
9213 expected: SCHEMA_VERSION,
9214 }
9215 ),
9216 &true,
9217 "schema-8 read-only rejection",
9218 )?;
9219 require_eq(
9220 &fs::read(&db_path)?,
9221 &database_before_read,
9222 "read-only rejection leaves database unchanged",
9223 )?;
9224
9225 let store = AtlasStore::open(&db_path)?;
9226 let stored_schema = store.connection.query_row(
9227 "SELECT value FROM metadata WHERE key = ?1",
9228 [SCHEMA_VERSION_KEY],
9229 |row| row.get::<_, String>(0),
9230 )?;
9231 require_eq(
9232 &stored_schema,
9233 &SCHEMA_VERSION.to_string(),
9234 "upgraded schema version",
9235 )?;
9236 let migrated_binding = store.captured_project_binding()?;
9237 drop(store);
9238 schema::verify_current_integrity(&db_path, Some(&normalize_native_path_display(&root)))?;
9239 let mut store = AtlasStore::open_for_project(&db_path, &root)?;
9240 require_eq(
9241 &store.captured_project_binding()?,
9242 &migrated_binding,
9243 &format!("{label} identity survives reopen"),
9244 )?;
9245 require_eq(
9246 &store.project_root()?,
9247 &Some(normalize_native_path_display(&root)),
9248 "upgraded project root",
9249 )?;
9250 let node = store
9251 .load_node_by_path("src/lib.rs")?
9252 .ok_or_else(|| io::Error::other("upgraded source node missing"))?;
9253 require_eq(
9254 &node.node.content_hash,
9255 &Some("hash-legacy".to_string()),
9256 "upgraded source row",
9257 )?;
9258 require_eq(
9259 &node.purpose.purpose,
9260 &Some("Schema compatibility source".to_string()),
9261 "upgraded purpose text",
9262 )?;
9263 require_eq(
9264 &node.purpose.source,
9265 &PurposeSource::Agent,
9266 "upgraded purpose source",
9267 )?;
9268 require_eq(
9269 &node.purpose.status,
9270 &PurposeStatus::Approved,
9271 "upgraded purpose review state",
9272 )?;
9273 require_eq(
9274 &store.resolved_health_ids()?,
9275 &vec!["schema-review".to_string()],
9276 "upgraded authored review",
9277 )?;
9278 let custom_setting = store.connection.query_row(
9279 "SELECT value FROM metadata WHERE key = 'custom_setting'",
9280 [],
9281 |row| row.get::<_, String>(0),
9282 )?;
9283 require_eq(
9284 &custom_setting,
9285 &"preserved".to_string(),
9286 "upgraded compatible metadata",
9287 )?;
9288 let telemetry = store.token_overview(Some("schema-session"))?;
9289 require_eq(&telemetry.calls, &1, "upgraded telemetry call count")?;
9290 require_eq(
9291 &telemetry.estimated_saved,
9292 &80,
9293 "upgraded telemetry savings",
9294 )?;
9295 require_eq(
9296 &store.index_publication()?,
9297 &None,
9298 "untrusted publication metadata invalidated",
9299 )?;
9300 let remaining_publication_keys = store.connection.query_row(
9301 "SELECT COUNT(*) FROM metadata WHERE key IN (?1, ?2, ?3)",
9302 params![
9303 INDEX_PUBLICATION_STATE_KEY,
9304 INDEX_PUBLICATION_FINGERPRINT_KEY,
9305 INDEX_PUBLICATION_GENERATION_KEY,
9306 ],
9307 |row| row.get::<_, i64>(0),
9308 )?;
9309 require_eq(
9310 &remaining_publication_keys,
9311 &0,
9312 "all untrusted publication keys removed",
9313 )?;
9314
9315 {
9316 let mut publication = store.begin_index_publication("schema-9-contract")?;
9317 write_test_projection(&mut publication, "fresh")?;
9318 publication.complete()?;
9319 }
9320 require_test_projection(&store, 1, "fresh")?;
9321 require_eq(
9322 &store
9323 .index_publication()?
9324 .and_then(|publication| publication.contract_fingerprint),
9325 &Some("schema-9-contract".to_string()),
9326 &format!("{label} fresh publication contract"),
9327 )?;
9328 Ok(migrated_binding)
9329 }
9330
9331 #[test]
9332 fn future_schema_rejection_preserves_source_and_authored_rows() -> Result<(), Box<dyn Error>> {
9333 let temp = tempfile::tempdir()?;
9334 let db_path = temp.path().join("projectatlas.db");
9335 let root = temp.path().join("repository");
9336 fs::create_dir(&root)?;
9337 let future_schema = SCHEMA_VERSION + 1;
9338 write_schema_compatibility_fixture(&db_path, &root, future_schema, "future")?;
9339 let database_before = fs::read(&db_path)?;
9340 let Err(open_error) = AtlasStore::open(&db_path) else {
9341 return Err(
9342 io::Error::other("future schema writable open unexpectedly succeeded").into(),
9343 );
9344 };
9345 require_eq(
9346 &matches!(
9347 open_error,
9348 DbError::SchemaVersion {
9349 found,
9350 expected: SCHEMA_VERSION,
9351 } if found == future_schema
9352 ),
9353 &true,
9354 "future schema rejection",
9355 )?;
9356 require_eq(
9357 &fs::read(&db_path)?,
9358 &database_before,
9359 "future schema bytes remain unchanged",
9360 )?;
9361 let connection = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
9362 let stored_schema = connection.query_row(
9363 "SELECT value FROM metadata WHERE key = ?1",
9364 [SCHEMA_VERSION_KEY],
9365 |row| row.get::<_, String>(0),
9366 )?;
9367 require_eq(
9368 &stored_schema,
9369 &future_schema.to_string(),
9370 "future schema remains unchanged",
9371 )?;
9372 let stored_root = connection.query_row(
9373 "SELECT value FROM metadata WHERE key = 'project_root'",
9374 [],
9375 |row| row.get::<_, String>(0),
9376 )?;
9377 require_eq(
9378 &stored_root,
9379 &normalize_native_path_display(&root),
9380 "future project root remains unchanged",
9381 )?;
9382 let source_state = connection.query_row(
9383 "
9384 SELECT n.content_hash, p.purpose, p.source, p.status
9385 FROM nodes AS n
9386 JOIN purposes AS p ON p.node_id = n.id
9387 WHERE n.path = 'src/lib.rs'
9388 ",
9389 [],
9390 |row| {
9391 Ok((
9392 row.get::<_, Option<String>>(0)?,
9393 row.get::<_, Option<String>>(1)?,
9394 row.get::<_, String>(2)?,
9395 row.get::<_, String>(3)?,
9396 ))
9397 },
9398 )?;
9399 require_eq(
9400 &source_state,
9401 &(
9402 Some("hash-future".to_string()),
9403 Some("Schema compatibility source".to_string()),
9404 PurposeSource::Agent.to_string(),
9405 PurposeStatus::Approved.as_str().to_string(),
9406 ),
9407 "future source and purpose rows remain unchanged",
9408 )?;
9409 let review_rationale = connection.query_row(
9410 "SELECT rationale FROM health_resolutions WHERE finding_id = 'schema-review'",
9411 [],
9412 |row| row.get::<_, String>(0),
9413 )?;
9414 require_eq(
9415 &review_rationale,
9416 &"Reviewed schema fixture".to_string(),
9417 "future authored review remains unchanged",
9418 )?;
9419 let telemetry_state = connection.query_row(
9420 "
9421 SELECT COUNT(*), SUM(e.estimated_tokens_saved)
9422 FROM usage_events AS e
9423 JOIN usage_instances AS i USING(instance_row_id)
9424 WHERE i.caller_label = 'schema-session'
9425 ",
9426 [],
9427 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<i64>>(1)?)),
9428 )?;
9429 require_eq(
9430 &telemetry_state,
9431 &(1, Some(80)),
9432 "future telemetry remains unchanged",
9433 )?;
9434 Ok(())
9435 }
9436
9437 #[test]
9438 fn projection_refresh_cannot_replace_publication_contract() -> Result<(), Box<dyn Error>> {
9439 let mut store = AtlasStore::in_memory()?;
9440 store.begin_index_publication("contract-a")?.complete()?;
9441
9442 store
9443 .begin_index_projection_refresh("contract-a")?
9444 .complete()?;
9445 require_eq(
9446 &store.index_publication()?,
9447 &Some(IndexPublication {
9448 state: IndexPublicationState::Complete,
9449 contract_fingerprint: Some("contract-a".to_string()),
9450 generation: IndexGeneration::new(2),
9451 }),
9452 "matching projection refresh",
9453 )?;
9454
9455 let publication = store.begin_index_projection_refresh("contract-a")?;
9456 set_metadata(
9457 &publication.connection,
9458 INDEX_PUBLICATION_FINGERPRINT_KEY,
9459 "contract-b",
9460 )?;
9461 let Err(mismatch) = publication.complete() else {
9462 return Err(io::Error::other(
9463 "projection refresh replaced the global publication contract",
9464 )
9465 .into());
9466 };
9467 if !matches!(mismatch, DbError::PublicationContractChanged) {
9468 return Err(io::Error::other(format!(
9469 "unexpected projection refresh mismatch: {mismatch}"
9470 ))
9471 .into());
9472 }
9473 require_eq(
9474 &store.index_publication()?,
9475 &Some(IndexPublication {
9476 state: IndexPublicationState::Complete,
9477 contract_fingerprint: Some("contract-a".to_string()),
9478 generation: IndexGeneration::new(2),
9479 }),
9480 "mismatched projection refresh rolls back",
9481 )?;
9482 Ok(())
9483 }
9484
9485 #[test]
9486 fn read_snapshots_expose_only_complete_publications() -> Result<(), Box<dyn Error>> {
9487 let temp = tempfile::tempdir()?;
9488 let db_path = temp.path().join("projectatlas.db");
9489 let independent_db_path = temp.path().join("independent.db");
9490 let mut writer_a = AtlasStore::open(&db_path)?;
9491 require_writable_connection_profile(&writer_a.connection)?;
9492 {
9493 let mut publication = writer_a.begin_index_publication("contract")?;
9494 write_test_projection(&mut publication, "old")?;
9495 publication.complete()?;
9496 }
9497 let mut writer_b = AtlasStore::open(&db_path)?;
9498 let mut independent_writer = AtlasStore::open(&independent_db_path)?;
9499 let old_reader = AtlasStore::open_read_only(&db_path)?;
9500 require_read_connection_profile(&old_reader.connection)?;
9501 if old_reader
9502 .connection
9503 .execute("DELETE FROM metadata", [])
9504 .is_ok()
9505 {
9506 return Err(io::Error::other("read-only connection accepted a mutation").into());
9507 }
9508 require_test_projection(&old_reader, 1, "old")?;
9509
9510 {
9511 let mut publication = writer_a.begin_index_publication("contract")?;
9512 write_test_projection(&mut publication, "new")?;
9513 require_test_projection(&old_reader, 1, "old")?;
9514
9515 let probe_started = std::time::Instant::now();
9516 let Err(probe_contention) = writer_b.probe_index_publication_writer() else {
9517 return Err(io::Error::other(
9518 "writer availability probe entered an active publication transaction",
9519 )
9520 .into());
9521 };
9522 require_eq(
9523 &matches!(
9524 probe_contention,
9525 DbError::Sqlite(ref error)
9526 if matches!(
9527 error.sqlite_error_code(),
9528 Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
9529 )
9530 ),
9531 &true,
9532 "same-database writer availability probe",
9533 )?;
9534 require_eq(
9535 &(probe_started.elapsed() < Duration::from_secs(2)),
9536 &true,
9537 "fail-fast same-database writer availability probe",
9538 )?;
9539
9540 let started = std::time::Instant::now();
9541 let Err(contention) = writer_b.begin_index_publication("contract") else {
9542 return Err(io::Error::other(
9543 "second writer entered an active publication transaction",
9544 )
9545 .into());
9546 };
9547 require_eq(
9548 &matches!(
9549 contention,
9550 DbError::Sqlite(ref error)
9551 if matches!(
9552 error.sqlite_error_code(),
9553 Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
9554 )
9555 ),
9556 &true,
9557 "same-database writer contention",
9558 )?;
9559 require_eq(
9560 &(started.elapsed() < Duration::from_secs(2)),
9561 &true,
9562 "fail-fast same-database writer acquisition",
9563 )?;
9564 let restored_busy_timeout =
9565 writer_b
9566 .connection
9567 .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, i64>(0))?;
9568 require_eq(
9569 &u128::try_from(restored_busy_timeout)?,
9570 &SQLITE_BUSY_TIMEOUT.as_millis(),
9571 "ordinary busy timeout after failed publication acquisition",
9572 )?;
9573
9574 independent_writer
9575 .begin_index_publication("independent-contract")?
9576 .complete()?;
9577 require_eq(
9578 &independent_writer
9579 .index_publication()?
9580 .ok_or_else(|| io::Error::other("independent publication missing"))?
9581 .generation,
9582 &IndexGeneration::new(1),
9583 "independent database generation",
9584 )?;
9585
9586 drop(publication);
9587 }
9588 let rolled_back_reader = AtlasStore::open_read_only(&db_path)?;
9589 require_test_projection(&rolled_back_reader, 1, "old")?;
9590 rolled_back_reader.finish_index_read_snapshot()?;
9591
9592 {
9593 let mut publication = writer_a.begin_index_publication("contract")?;
9594 write_test_projection(&mut publication, "new")?;
9595 publication.complete()?;
9596 }
9597 let restored_busy_timeout =
9598 writer_a
9599 .connection
9600 .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, i64>(0))?;
9601 require_eq(
9602 &u128::try_from(restored_busy_timeout)?,
9603 &SQLITE_BUSY_TIMEOUT.as_millis(),
9604 "ordinary busy timeout after successful publication acquisition",
9605 )?;
9606 require_test_projection(&old_reader, 1, "old")?;
9607 let new_reader = AtlasStore::open_read_only(&db_path)?;
9608 require_test_projection(&new_reader, 2, "new")?;
9609 new_reader.finish_index_read_snapshot()?;
9610 old_reader.finish_index_read_snapshot()?;
9611 Ok(())
9612 }
9613
9614 #[test]
9615 fn authored_write_waits_for_the_existing_writer_before_validation() -> Result<(), Box<dyn Error>>
9616 {
9617 let temp = tempfile::tempdir()?;
9618 let root = temp.path().join("repository");
9619 fs::create_dir_all(&root)?;
9620 let db_path = temp.path().join("projectatlas.db");
9621 let mut store = AtlasStore::open_for_project(&db_path, &root)?;
9622 store.replace_scan(&[test_file_node("src/lib.rs", "initial")])?;
9623
9624 let blocking_writer = Connection::open(&db_path)?;
9625 blocking_writer.busy_timeout(SQLITE_BUSY_TIMEOUT)?;
9626 blocking_writer.execute_batch("BEGIN IMMEDIATE")?;
9627 let release = std::thread::spawn(move || {
9628 std::thread::sleep(Duration::from_millis(1_250));
9629 blocking_writer.execute_batch("ROLLBACK")
9630 });
9631
9632 let started = Instant::now();
9633 store.set_purpose(
9634 "src/lib.rs",
9635 "Own the library source.",
9636 PurposeSource::Agent,
9637 )?;
9638 let elapsed = started.elapsed();
9639 release
9640 .join()
9641 .map_err(|_panic| io::Error::other("blocking writer thread panicked"))??;
9642
9643 require_eq(
9644 &(elapsed >= Duration::from_secs(1)),
9645 &true,
9646 "authored write waited for the existing writer",
9647 )?;
9648 require_eq(
9649 &(elapsed < SQLITE_BUSY_TIMEOUT),
9650 &true,
9651 "authored write stayed within the ordinary busy timeout",
9652 )?;
9653 require_eq(
9654 &store
9655 .load_node_by_path("src/lib.rs")?
9656 .ok_or_else(|| io::Error::other("purpose node missing"))?
9657 .purpose
9658 .purpose,
9659 &Some("Own the library source.".to_string()),
9660 "purpose after bounded writer contention",
9661 )
9662 }
9663
9664 #[test]
9665 fn writable_mutators_reject_an_active_read_snapshot() -> Result<(), Box<dyn Error>> {
9666 let temp = tempfile::tempdir()?;
9667 let db_path = temp.path().join("projectatlas.db");
9668 let mut store = AtlasStore::open(&db_path)?;
9669 store.replace_scan(&[test_file_node("src/lib.rs", "initial")])?;
9670 store.begin_index_read_snapshot()?;
9671
9672 let Err(purpose_error) = store.set_purpose(
9673 "src/lib.rs",
9674 "Must not be written through a read snapshot.",
9675 PurposeSource::Agent,
9676 ) else {
9677 return Err(io::Error::other("purpose wrote through a read snapshot").into());
9678 };
9679 require_eq(
9680 &matches!(purpose_error, DbError::IndexReadSnapshotActive),
9681 &true,
9682 "read-snapshot purpose rejection",
9683 )?;
9684
9685 let Err(scan_error) = store.replace_scan(&[]) else {
9686 return Err(io::Error::other("scan wrote through a read snapshot").into());
9687 };
9688 require_eq(
9689 &matches!(scan_error, DbError::IndexReadSnapshotActive),
9690 &true,
9691 "read-snapshot scan rejection",
9692 )?;
9693
9694 store.finish_index_read_snapshot()?;
9695 require_eq(
9696 &store.load_node_by_path("src/lib.rs")?.is_some(),
9697 &true,
9698 "read-snapshot rejected writes preserved indexed state",
9699 )?;
9700 Ok(())
9701 }
9702
9703 #[test]
9704 fn stale_publication_base_is_rejected_before_batch_mutation() -> Result<(), Box<dyn Error>> {
9705 let temp = tempfile::tempdir()?;
9706 let db_path = temp.path().join("projectatlas.db");
9707 let mut writer_a = AtlasStore::open(&db_path)?;
9708 {
9709 let mut publication =
9710 writer_a.begin_index_publication_from("contract", IndexGeneration::ZERO)?;
9711 write_test_projection(&mut publication, "base")?;
9712 publication.complete()?;
9713 }
9714 let prepared_base = writer_a
9715 .index_publication()?
9716 .ok_or_else(|| io::Error::other("prepared base publication missing"))?
9717 .generation;
9718
9719 let incomplete_result = {
9720 let mut publication =
9721 writer_a.begin_index_publication_from("contract", prepared_base)?;
9722 publication.begin_scan_replacement()?;
9723 publication.complete()
9724 };
9725 if !matches!(incomplete_result, Err(DbError::ScanReplacementIncomplete)) {
9726 return Err(io::Error::other(
9727 "unfinished scan replacement was allowed to complete publication",
9728 )
9729 .into());
9730 }
9731 require_test_projection(&writer_a, 1, "base")?;
9732
9733 let mut writer_b = AtlasStore::open(&db_path)?;
9734 {
9735 let mut publication = writer_b.begin_index_publication("contract")?;
9736 write_test_projection(&mut publication, "winner")?;
9737 publication.complete()?;
9738 }
9739
9740 let Err(conflict) = writer_a.begin_index_publication_from("contract", prepared_base) else {
9741 return Err(io::Error::other("stale prepared publication was accepted").into());
9742 };
9743 require_eq(
9744 &matches!(
9745 conflict,
9746 DbError::PublicationBaseGenerationChanged { expected, found }
9747 if expected == IndexGeneration::new(1)
9748 && found == IndexGeneration::new(2)
9749 ),
9750 &true,
9751 "stale publication base conflict",
9752 )?;
9753 require_test_projection(&writer_a, 2, "winner")?;
9754 require_eq(
9755 &writer_a.load_symbols(Some("src/lib.rs"), None, 10)?.len(),
9756 &1,
9757 "rejected batch symbol row count",
9758 )?;
9759 require_eq(
9760 &writer_a
9761 .load_symbol_relations(Some("src/lib.rs"), None, 10)?
9762 .len(),
9763 &1,
9764 "rejected batch relation row count",
9765 )?;
9766
9767 let Err(zero_conflict) =
9768 writer_a.begin_index_publication_from("contract", IndexGeneration::ZERO)
9769 else {
9770 return Err(io::Error::other("zero base matched an initialized store").into());
9771 };
9772 require_eq(
9773 &matches!(
9774 zero_conflict,
9775 DbError::PublicationBaseGenerationChanged { expected, found }
9776 if expected == IndexGeneration::ZERO
9777 && found == IndexGeneration::new(2)
9778 ),
9779 &true,
9780 "zero publication base conflict",
9781 )?;
9782 require_test_projection(&writer_a, 2, "winner")?;
9783 Ok(())
9784 }
9785
9786 #[test]
9787 fn records_token_overview() -> Result<(), Box<dyn Error>> {
9788 let project = tempfile::tempdir()?;
9789 let mut store = AtlasStore::in_memory()?;
9790 store.set_project_root(project.path())?;
9791 let mut session_event = usage_from_estimates(
9792 "session",
9793 "outline",
9794 Some("src/main.rs".to_string()),
9795 None,
9796 100,
9797 20,
9798 );
9799 session_event.estimated_tokens_saved = Some(1);
9800 store.record_usage(&session_event)?;
9801 let mut unknown_event = usage_from_estimates("session", "unknown", None, None, 0, 0);
9802 unknown_event.estimated_tokens_without_projectatlas = None;
9803 unknown_event.estimated_tokens_with_projectatlas = None;
9804 unknown_event.estimated_tokens_saved = None;
9805 store.record_usage(&unknown_event)?;
9806 store.record_usage(&usage_from_estimates(
9807 "other-session",
9808 "outline",
9809 Some("src/lib.rs".to_string()),
9810 None,
9811 200,
9812 50,
9813 ))?;
9814 let overview = store.token_overview(Some("session"))?;
9815 require_eq(&overview.calls, &1, "usage call count")?;
9816 require_eq(&overview.estimated_saved, &80, "saved token count")?;
9817 require_eq(&overview.buckets.len(), &1, "usage bucket count")?;
9818 require_eq(
9819 &overview.buckets[0].accuracy,
9820 &TOKEN_ACCURACY_HEURISTIC.to_string(),
9821 "usage bucket accuracy",
9822 )?;
9823 let all_sessions = store.token_overview(None)?;
9824 require_eq(&all_sessions.calls, &2, "all-session usage call count")?;
9825 require_eq(
9826 &all_sessions.estimated_without_projectatlas,
9827 &300,
9828 "all-session baseline tokens",
9829 )?;
9830 require_eq(
9831 &all_sessions.estimated_with_projectatlas,
9832 &70,
9833 "all-session atlas tokens",
9834 )?;
9835 require_eq(
9836 &all_sessions.estimated_saved,
9837 &230,
9838 "all-session saved tokens",
9839 )?;
9840 require_eq(
9841 &all_sessions.likely_file_reads_avoided,
9842 &0,
9843 "non-search estimate events do not count as avoided file reads",
9844 )?;
9845 require_eq(
9846 &all_sessions.read_avoidance_confidence,
9847 &READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED.to_string(),
9848 "non-search estimate read avoidance confidence",
9849 )?;
9850
9851 store.record_usage(&usage_from_text(
9852 "bucketed",
9853 "summary",
9854 Some("src/main.rs".to_string()),
9855 None,
9856 "abcdefghijkl",
9857 "abcd",
9858 ))?;
9859 store.record_usage(&usage_from_estimates(
9860 "bucketed", "folders", None, None, 100, 20,
9861 ))?;
9862 let bucketed = store.token_overview(Some("bucketed"))?;
9863 require_eq(&bucketed.buckets.len(), &2, "bucketed overview count")?;
9864 require_eq(
9865 &bucketed.buckets[0].token_savings_bucket,
9866 &TOKEN_BUCKET_FULL_FILE_COMPRESSION.to_string(),
9867 "source compression bucket",
9868 )?;
9869 require_eq(
9870 &bucketed.buckets[1].token_savings_bucket,
9871 &TOKEN_BUCKET_NAVIGATION_AVOIDANCE.to_string(),
9872 "navigation bucket",
9873 )?;
9874 require_eq(
9875 &bucketed.observed_file_read_replacements,
9876 &1,
9877 "observed read replacement count",
9878 )?;
9879 require_eq(
9880 &bucketed.modeled_file_reads_avoided,
9881 &0,
9882 "folder navigation does not count as modeled file-read avoidance",
9883 )?;
9884 require_eq(
9885 &bucketed.likely_file_reads_avoided,
9886 &1,
9887 "observed-only likely file reads avoided",
9888 )?;
9889 require_eq(
9890 &bucketed.read_avoidance_confidence,
9891 &READ_AVOIDANCE_CONFIDENCE_OBSERVED.to_string(),
9892 "observed-only read avoidance confidence",
9893 )?;
9894
9895 store.record_usage(&usage_from_text(
9896 "deduped",
9897 "summary",
9898 Some("src/lib.rs".to_string()),
9899 None,
9900 "abcdabcd",
9901 "ab",
9902 ))?;
9903 store.record_usage(&usage_from_estimates(
9904 "deduped",
9905 TOKEN_COMMAND_SEARCH,
9906 None,
9907 Some("token".to_string()),
9908 400,
9909 40,
9910 ))?;
9911 store.record_usage(&usage_from_estimates(
9912 "deduped",
9913 TOKEN_COMMAND_SEARCH,
9914 None,
9915 Some("token".to_string()),
9916 400,
9917 30,
9918 ))?;
9919 let deduped = store.token_overview(Some("deduped"))?;
9920 require_eq(
9921 &deduped.legacy_gross_estimated_saved,
9922 &731,
9923 "legacy gross saved tokens remains available",
9924 )?;
9925 require_eq(
9926 &deduped.measured_tokens_saved,
9927 &1,
9928 "measured saved tokens remain separate",
9929 )?;
9930 require_eq(
9931 &deduped.gross_modeled_tokens_avoided,
9932 &730,
9933 "gross modeled avoided tokens remains available",
9934 )?;
9935 require_eq(
9936 &deduped.deduped_modeled_tokens_avoided,
9937 &330,
9938 "modeled avoided tokens are deduped by baseline",
9939 )?;
9940 require_eq(
9941 &deduped.tokens_avoided,
9942 &331,
9943 "headline avoided tokens use measured plus deduped modeled",
9944 )?;
9945 require_eq(
9946 &deduped.observed_file_read_replacements,
9947 &1,
9948 "deduped observed read replacements",
9949 )?;
9950 require_eq(
9951 &deduped.modeled_file_reads_avoided,
9952 &2,
9953 "deduped raw search events remain likely file reads avoided",
9954 )?;
9955 require_eq(
9956 &deduped.likely_file_reads_avoided,
9957 &3,
9958 "deduped likely file reads avoided",
9959 )?;
9960 require_eq(
9961 &deduped.read_avoidance_confidence,
9962 &READ_AVOIDANCE_CONFIDENCE_MODELED.to_string(),
9963 "deduped read avoidance confidence",
9964 )?;
9965
9966 store.record_usage(&usage_from_estimates_with_accounting(
9967 "event-scoped",
9968 "folders",
9969 None,
9970 Some("token".to_string()),
9971 400,
9972 40,
9973 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
9974 TOKEN_BASELINE_SELECTED_CANDIDATES,
9975 TOKEN_CONFIDENCE_INFERRED,
9976 TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
9977 TOKEN_BASELINE_SELECTED_CANDIDATES,
9978 TOKEN_DEDUPE_SCOPE_EVENT,
9979 ))?;
9980 store.record_usage(&usage_from_estimates_with_accounting(
9981 "event-scoped",
9982 "folders",
9983 None,
9984 Some("token".to_string()),
9985 400,
9986 30,
9987 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
9988 TOKEN_BASELINE_SELECTED_CANDIDATES,
9989 TOKEN_CONFIDENCE_INFERRED,
9990 TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
9991 TOKEN_BASELINE_SELECTED_CANDIDATES,
9992 TOKEN_DEDUPE_SCOPE_EVENT,
9993 ))?;
9994 let event_scoped = store.token_overview(Some("event-scoped"))?;
9995 require_eq(
9996 &event_scoped.gross_modeled_tokens_avoided,
9997 &730,
9998 "event-scoped gross modeled avoided tokens",
9999 )?;
10000 require_eq(
10001 &event_scoped.deduped_modeled_tokens_avoided,
10002 &730,
10003 "event-scoped modeled events are not collapsed",
10004 )?;
10005 require_eq(
10006 &event_scoped.repeated_baselines_deduped,
10007 &0,
10008 "event-scoped modeled events do not count as deduped repeats",
10009 )?;
10010 require_eq(
10011 &event_scoped.likely_file_reads_avoided,
10012 &0,
10013 "folder navigation events do not count as likely file reads avoided",
10014 )?;
10015 require_eq(
10016 &event_scoped.read_avoidance_confidence,
10017 &READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED.to_string(),
10018 "folder navigation read avoidance confidence",
10019 )?;
10020
10021 let mut negative_event = usage_from_estimates("negative", "outline", None, None, 20, 50);
10022 negative_event.estimated_tokens_saved = Some(999);
10023 store.record_usage(&negative_event)?;
10024 let negative = store.token_overview(Some("negative"))?;
10025 require_eq(&negative.calls, &1, "negative session call count")?;
10026 require_eq(
10027 &negative.estimated_saved,
10028 &-30,
10029 "negative session recomputed delta",
10030 )?;
10031 require_eq(
10032 &negative.savings_rate,
10033 &Some(-1.5),
10034 "negative session savings rate",
10035 )?;
10036
10037 let mut zero_event = usage_from_estimates("zero-baseline", "outline", None, None, 0, 12);
10038 zero_event.estimated_tokens_saved = Some(999);
10039 store.record_usage(&zero_event)?;
10040 let zero_baseline = store.token_overview(Some("zero-baseline"))?;
10041 require_eq(&zero_baseline.calls, &1, "zero baseline call count")?;
10042 require_eq(
10043 &zero_baseline.estimated_saved,
10044 &-12,
10045 "zero baseline recomputed delta",
10046 )?;
10047 require_eq(
10048 &zero_baseline.savings_rate,
10049 &None,
10050 "zero baseline savings rate",
10051 )?;
10052
10053 let large_project = tempfile::tempdir()?;
10054 let mut large_store = AtlasStore::in_memory()?;
10055 large_store.set_project_root(large_project.path())?;
10056 let maximum_estimate = usize::try_from(i64::MAX)?;
10057 large_store.record_usage(&usage_from_estimates(
10058 "large-primary",
10059 "large",
10060 None,
10061 None,
10062 maximum_estimate,
10063 0,
10064 ))?;
10065 let Err(overflow) = large_store.record_usage(&usage_from_estimates(
10066 "large-rejected",
10067 "large",
10068 None,
10069 None,
10070 maximum_estimate,
10071 0,
10072 )) else {
10073 return Err(io::Error::other("overflowing telemetry aggregate was committed").into());
10074 };
10075 require_eq(
10076 &matches!(overflow, DbError::TelemetryIntegerOverflow { .. }),
10077 &true,
10078 "overflowing telemetry aggregate error",
10079 )?;
10080 let large = large_store.token_overview(Some("large-primary"))?;
10081 require_eq(
10082 &large.estimated_saved,
10083 &isize::MAX,
10084 "largest accepted aggregate narrows at the public boundary",
10085 )?;
10086 require_eq(
10087 &large_store.token_overview(Some("large-rejected"))?.calls,
10088 &0,
10089 "rejected aggregate left no partial report",
10090 )?;
10091 Ok(())
10092 }
10093
10094 #[test]
10095 fn direct_library_usage_rotates_at_baseline_capacity_without_reopening_the_old_scope()
10096 -> Result<(), Box<dyn Error>> {
10097 let project = tempfile::tempdir()?;
10098 let mut store = AtlasStore::in_memory()?;
10099 store.set_project_root(project.path())?;
10100 let policy = TelemetryRetentionPolicy::default();
10101 let label = "bounded-library-label";
10102
10103 for index in 0..policy.max_baselines_per_instance {
10104 store.record_usage(&usage_from_estimates(
10105 label,
10106 "search",
10107 None,
10108 Some(format!("query-{index}")),
10109 100,
10110 20,
10111 ))?;
10112 }
10113 let old_instance = store
10114 .library_usage_instances
10115 .borrow()
10116 .get(label)
10117 .copied()
10118 .flatten()
10119 .ok_or_else(|| io::Error::other("library instance was not retained"))?;
10120
10121 let boundary_event = usage_from_estimates(
10122 label,
10123 "search",
10124 None,
10125 Some("capacity-boundary".to_string()),
10126 100,
10127 20,
10128 );
10129 store.record_usage(&boundary_event)?;
10130 let replacement = store
10131 .library_usage_instances
10132 .borrow()
10133 .get(label)
10134 .copied()
10135 .flatten()
10136 .ok_or_else(|| io::Error::other("replacement library instance was not retained"))?;
10137 require_eq(
10138 &(replacement != old_instance),
10139 &true,
10140 "capacity rotation created a new internal instance",
10141 )?;
10142 require_eq(
10143 &store.connection.query_row(
10144 "SELECT COUNT(*) FROM usage_instances WHERE state = 'sealed'",
10145 [],
10146 |row| row.get::<_, i64>(0),
10147 )?,
10148 &1,
10149 "sealed predecessor instances",
10150 )?;
10151 require_eq(
10152 &store.connection.query_row(
10153 "SELECT COUNT(*) FROM usage_instances WHERE state = 'active'",
10154 [],
10155 |row| row.get::<_, i64>(0),
10156 )?,
10157 &1,
10158 "active replacement instances",
10159 )?;
10160 require_eq(
10161 &store.connection.query_row(
10162 "SELECT COUNT(*) FROM usage_instance_baselines",
10163 [],
10164 |row| row.get::<_, i64>(0),
10165 )?,
10166 &1,
10167 "only replacement baseline witnesses remain",
10168 )?;
10169 require_eq(
10170 &store.token_overview(Some(label))?.calls,
10171 &(policy.max_baselines_per_instance + 1),
10172 "caller-label report spans both bounded instances",
10173 )?;
10174 require_eq(
10175 &store.token_overview(None)?.estimated_saved,
10176 &(isize::try_from(policy.max_baselines_per_instance + 1)? * 80),
10177 "global totals remain exact across rotation",
10178 )?;
10179 require_eq(
10180 &matches!(
10181 store.record_usage_for_instance(
10182 old_instance,
10183 UsageInstanceOwner::LibraryHandle,
10184 &boundary_event,
10185 false,
10186 ),
10187 Err(DbError::TelemetryInstanceInactive)
10188 ),
10189 &true,
10190 "sealed predecessor cannot be reopened",
10191 )?;
10192 Ok(())
10193 }
10194
10195 #[test]
10196 fn rejected_library_labels_do_not_consume_the_bounded_identity_map()
10197 -> Result<(), Box<dyn Error>> {
10198 let project = tempfile::tempdir()?;
10199 let mut store = AtlasStore::in_memory()?;
10200 store.set_project_root(project.path())?;
10201 let maximum = TelemetryRetentionPolicy::default().max_label_bytes;
10202
10203 for extra in 1..=4 {
10204 let event =
10205 usage_from_estimates(&"x".repeat(maximum + extra), "summary", None, None, 100, 20);
10206 require_eq(
10207 &matches!(
10208 store.record_usage(&event),
10209 Err(DbError::TelemetryFieldTooLarge {
10210 field: "session_id",
10211 ..
10212 })
10213 ),
10214 &true,
10215 "oversized caller label rejection",
10216 )?;
10217 }
10218 require_eq(
10219 &store.library_usage_instances.borrow().len(),
10220 &0,
10221 "rejected labels retained no identity-map entries",
10222 )?;
10223 Ok(())
10224 }
10225
10226 #[test]
10227 fn token_trends_group_usage_by_period_and_bucket() -> Result<(), Box<dyn Error>> {
10228 let project = tempfile::tempdir()?;
10229 let mut store = AtlasStore::in_memory()?;
10230 store.set_project_root(project.path())?;
10231 for (session, bucket, baseline_kind, confidence, without, with) in [
10232 (
10233 "session",
10234 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
10235 "selected_candidates",
10236 "inferred",
10237 100_usize,
10238 25_usize,
10239 ),
10240 (
10241 "session",
10242 TOKEN_BUCKET_FULL_FILE_COMPRESSION,
10243 "full_file",
10244 "observed",
10245 50_usize,
10246 10_usize,
10247 ),
10248 (
10249 "session",
10250 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
10251 "selected_candidates",
10252 "inferred",
10253 80_usize,
10254 20_usize,
10255 ),
10256 (
10257 "other",
10258 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
10259 "selected_candidates",
10260 "inferred",
10261 999_usize,
10262 1_usize,
10263 ),
10264 ] {
10265 store.record_usage(&usage_from_estimates_with_context(
10266 session,
10267 "trend",
10268 None,
10269 None,
10270 without,
10271 with,
10272 bucket,
10273 baseline_kind,
10274 confidence,
10275 ))?;
10276 }
10277
10278 let trends = store.token_trends(Some("session"), TokenTrendWindow::Day)?;
10279 require_eq(&trends.periods.len(), &1, "current daily period")?;
10280 require_eq(&trends.periods[0].calls, &3, "session trend call count")?;
10281 require_eq(
10282 &trends.periods[0].estimated_saved,
10283 &175,
10284 "session trend saved tokens",
10285 )?;
10286 require_eq(
10287 &trends.periods[0].buckets.len(),
10288 &2,
10289 "trend preserves evidence buckets",
10290 )?;
10291 require_eq(
10292 &trends.periods[0].buckets[0].token_savings_bucket,
10293 &TOKEN_BUCKET_FULL_FILE_COMPRESSION.to_string(),
10294 "full-file bucket remains visible",
10295 )?;
10296 require_eq(
10297 &trends.periods[0].buckets[0].confidence,
10298 &"observed".to_string(),
10299 "bucket confidence remains visible",
10300 )?;
10301 let all_labels = store.token_trends(None, TokenTrendWindow::Day)?;
10302 require_eq(&all_labels.periods.len(), &1, "all-label daily period")?;
10303 require_eq(&all_labels.periods[0].calls, &4, "all-label trend calls")?;
10304 Ok(())
10305 }
10306
10307 #[test]
10308 fn unsupported_legacy_schema_is_refused_without_mutation() -> Result<(), Box<dyn Error>> {
10309 let temp = tempfile::tempdir()?;
10310 let db_path = temp.path().join("legacy.db");
10311 {
10312 let connection = Connection::open(&db_path)?;
10313 connection.execute_batch(
10314 "
10315 CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL);
10316 INSERT INTO metadata(key, value) VALUES('schema_version', '7');
10317 CREATE TABLE usage_events (
10318 id INTEGER PRIMARY KEY,
10319 session_id TEXT NOT NULL,
10320 command TEXT NOT NULL,
10321 path TEXT,
10322 query TEXT,
10323 estimated_tokens_without_projectatlas INTEGER,
10324 estimated_tokens_with_projectatlas INTEGER,
10325 estimated_tokens_saved INTEGER,
10326 token_savings_bucket TEXT NOT NULL DEFAULT 'navigation_avoidance',
10327 provider TEXT NOT NULL DEFAULT 'heuristic',
10328 model TEXT NOT NULL DEFAULT 'unknown',
10329 tokenizer_backend TEXT NOT NULL DEFAULT 'chars_div_4',
10330 accuracy TEXT NOT NULL DEFAULT 'heuristic_estimate',
10331 baseline_kind TEXT NOT NULL DEFAULT 'selected_candidates',
10332 confidence TEXT NOT NULL DEFAULT 'inferred',
10333 calculation_trace TEXT NOT NULL DEFAULT 'heuristic=ceil(chars_or_bytes/4)'
10334 );
10335 INSERT INTO usage_events(
10336 session_id,
10337 command,
10338 estimated_tokens_without_projectatlas,
10339 estimated_tokens_with_projectatlas,
10340 estimated_tokens_saved
10341 )
10342 VALUES('legacy-session', 'legacy', 100, 20, 80);
10343 ",
10344 )?;
10345 }
10346
10347 let database_before = fs::read(&db_path)?;
10348 let Err(open_error) = AtlasStore::open(&db_path) else {
10349 return Err(io::Error::other("unsupported schema unexpectedly opened").into());
10350 };
10351 require_eq(
10352 &matches!(
10353 open_error,
10354 DbError::SchemaVersion {
10355 found: 7,
10356 expected: SCHEMA_VERSION,
10357 }
10358 ),
10359 &true,
10360 "unsupported schema rejection",
10361 )?;
10362 require_eq(
10363 &fs::read(&db_path)?,
10364 &database_before,
10365 "unsupported schema bytes remain unchanged",
10366 )?;
10367 Ok(())
10368 }
10369
10370 #[test]
10371 fn set_project_root_requires_existing_root_and_preserves_binding() -> Result<(), Box<dyn Error>>
10372 {
10373 let temp = tempfile::tempdir()?;
10374 let mut store = AtlasStore::in_memory()?;
10375 let missing = temp.path().join("missing-root");
10376 let Err(missing_error) = store.set_project_root(&missing) else {
10377 return Err(io::Error::other("missing project root was accepted").into());
10378 };
10379 require(
10380 matches!(missing_error, DbError::ProjectRootIdentity(_)),
10381 "missing project root returned the wrong error",
10382 )?;
10383 require_eq(
10384 &store.project_root()?,
10385 &None,
10386 "missing-root rejection changed metadata",
10387 )?;
10388 require_eq(
10389 &store.project_root_identity()?,
10390 &None,
10391 "missing-root rejection changed native identity",
10392 )?;
10393 require_eq(
10394 &store.project_instance_id()?,
10395 &None,
10396 "missing-root rejection changed project identity",
10397 )?;
10398
10399 let root = temp.path().join("workspace").join("example");
10400 fs::create_dir_all(&root)?;
10401 store.set_project_root(&root)?;
10402 require_eq(
10403 &store.project_root()?,
10404 &Some(normalize_native_path_display(&root)),
10405 "project root metadata",
10406 )?;
10407
10408 #[cfg(windows)]
10409 let extended = PathBuf::from(format!(r"\\?\{}", root.display()));
10410 #[cfg(windows)]
10411 store.set_project_root(&extended)?;
10412 #[cfg(windows)]
10413 require_eq(
10414 &store.project_root()?,
10415 &Some(normalize_native_path_display(&root)),
10416 "windows extended project root metadata",
10417 )?;
10418 let other = temp.path().join("other");
10419 fs::create_dir(&other)?;
10420 let Err(rebind_error) = store.set_project_root(&other) else {
10421 return Err(io::Error::other("project identity was rebound implicitly").into());
10422 };
10423 require_eq(
10424 &matches!(rebind_error, DbError::ProjectRootMismatch { .. }),
10425 &true,
10426 "project identity rebind rejection",
10427 )?;
10428 Ok(())
10429 }
10430
10431 #[test]
10432 fn ordinary_root_admission_rejects_missing_and_regular_paths_without_mutation()
10433 -> Result<(), Box<dyn Error>> {
10434 let temp = tempfile::tempdir()?;
10435 let root = temp.path().join("root");
10436 let missing = temp.path().join("missing-root");
10437 let regular = temp.path().join("regular-root");
10438 fs::create_dir(&root)?;
10439 fs::write(®ular, b"not a directory")?;
10440 let database = temp.path().join("projectatlas.db");
10441 drop(AtlasStore::open_for_project(&database, &root)?);
10442
10443 for (label, candidate) in [
10444 ("missing", missing.as_path()),
10445 ("regular", regular.as_path()),
10446 ] {
10447 let before = fs::read(&database)?;
10448 if AtlasStore::open_for_project(&database, candidate).is_ok() {
10449 return Err(
10450 io::Error::other(format!("ordinary {label} root admission succeeded")).into(),
10451 );
10452 }
10453 if fs::read(&database)? != before {
10454 return Err(io::Error::other(format!(
10455 "ordinary {label} root admission mutated the database"
10456 ))
10457 .into());
10458 }
10459 if verify_project_database(&database, candidate).is_ok() {
10460 return Err(
10461 io::Error::other(format!("verify admitted ordinary {label} root")).into(),
10462 );
10463 }
10464 if fs::read(&database)? != before {
10465 return Err(io::Error::other(format!(
10466 "verify of ordinary {label} root mutated the database"
10467 ))
10468 .into());
10469 }
10470 }
10471 Ok(())
10472 }
10473
10474 #[test]
10475 fn malformed_project_root_identity_is_refused_without_mutation() -> Result<(), Box<dyn Error>> {
10476 let temp = tempfile::tempdir()?;
10477 let root = temp.path().join("root");
10478 fs::create_dir(&root)?;
10479 let database = temp.path().join("projectatlas.db");
10480 drop(AtlasStore::open_for_project(&database, &root)?);
10481 let connection = Connection::open(&database)?;
10482 connection.execute(
10483 "UPDATE project_root_identity SET root = ?1 WHERE singleton = 1",
10484 [vec![1_u8, 1_u8, b'x']],
10485 )?;
10486 drop(connection);
10487 let before = fs::read(&database)?;
10488
10489 if AtlasStore::open(&database).is_ok()
10490 || AtlasStore::open_for_project(&database, &root).is_ok()
10491 || verify_project_database(&database, &root).is_ok()
10492 {
10493 return Err(io::Error::other("malformed project-root identity was admitted").into());
10494 }
10495 if fs::read(&database)? != before {
10496 return Err(io::Error::other(
10497 "malformed project-root identity refusal mutated the database",
10498 )
10499 .into());
10500 }
10501 Ok(())
10502 }
10503
10504 #[test]
10505 fn read_project_root_read_only_does_not_change_database_bytes() -> Result<(), Box<dyn Error>> {
10506 let temp = tempfile::tempdir()?;
10507 let root = temp.path().join("repo with spaces");
10508 let atlas_dir = root.join(".projectatlas");
10509 fs::create_dir_all(&atlas_dir)?;
10510 let db_path = atlas_dir.join("projectatlas.db");
10511 {
10512 let mut store = AtlasStore::open(&db_path)?;
10513 store.set_project_root(&root)?;
10514 store
10515 .connection
10516 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
10517 }
10518 let wal_path = db_path.with_extension("db-wal");
10519 let shm_path = db_path.with_extension("db-shm");
10520 for sidecar_path in [&wal_path, &shm_path] {
10521 match fs::remove_file(sidecar_path) {
10522 Ok(()) => {}
10523 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10524 Err(error) => return Err(error.into()),
10525 }
10526 }
10527 let database_before = fs::read(&db_path)?;
10528
10529 require_eq(
10530 &read_project_root_read_only(&db_path)?,
10531 &Some(normalize_native_path_display(&root)),
10532 "read-only project root",
10533 )?;
10534 require_eq(
10535 &read_legacy_project_root_candidate_read_only(&db_path)?,
10536 &None,
10537 "current schema has no legacy recovery candidate",
10538 )?;
10539 require_eq(
10540 &fs::read(&db_path)?,
10541 &database_before,
10542 "read-only project-root database bytes",
10543 )?;
10544 Ok(())
10545 }
10546
10547 #[test]
10548 fn read_project_root_read_only_observes_active_wal_state() -> Result<(), Box<dyn Error>> {
10549 let temp = tempfile::tempdir()?;
10550 let root = temp.path().join("repo-Δ");
10551 let atlas_dir = root.join(".projectatlas");
10552 fs::create_dir_all(&atlas_dir)?;
10553 let db_path = atlas_dir.join("projectatlas.db");
10554 let mut store = AtlasStore::open(&db_path)?;
10555 store
10556 .connection
10557 .execute_batch("PRAGMA wal_autocheckpoint = 0")?;
10558 store.set_project_root(&root)?;
10559
10560 require_eq(
10561 &sqlite_sidecar_path(&db_path, "-wal").exists(),
10562 &true,
10563 "active WAL exists",
10564 )?;
10565 require_eq(
10566 &read_project_root_read_only(&db_path)?,
10567 &Some(normalize_native_path_display(&root)),
10568 "WAL-aware read-only project root",
10569 )?;
10570 Ok(())
10571 }
10572
10573 #[test]
10574 fn validated_write_transaction_revalidates_before_operation_and_rolls_back()
10575 -> Result<(), Box<dyn Error>> {
10576 let temp = tempfile::tempdir()?;
10577 let root = temp.path().join("repository");
10578 fs::create_dir_all(&root)?;
10579 let db_path = temp.path().join("projectatlas.db");
10580 let store = AtlasStore::open_for_project(&db_path, &root)?;
10581 let expected_identity = store
10582 .validated_project_instance_id
10583 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
10584 let sentinel_key = "validated_write_transaction_test";
10585
10586 with_validated_write_transaction(
10587 &store.connection,
10588 store.validated_project_root.as_deref(),
10589 Some(expected_identity),
10590 |connection| set_metadata(connection, sentinel_key, "committed"),
10591 )?;
10592 require_eq(
10593 &store.connection.query_row(
10594 "SELECT value FROM metadata WHERE key = ?1",
10595 [sentinel_key],
10596 |row| row.get::<_, String>(0),
10597 )?,
10598 &"committed".to_string(),
10599 "validated write positive control",
10600 )?;
10601
10602 let Err(operation_error) = with_validated_write_transaction(
10603 &store.connection,
10604 store.validated_project_root.as_deref(),
10605 Some(expected_identity),
10606 |connection| -> DbResult<()> {
10607 set_metadata(connection, sentinel_key, "rolled-back")?;
10608 Err(DbError::TelemetryIdentityUnavailable)
10609 },
10610 ) else {
10611 return Err(io::Error::other("failing validated write unexpectedly committed").into());
10612 };
10613 require_eq(
10614 &matches!(operation_error, DbError::TelemetryIdentityUnavailable),
10615 &true,
10616 "validated write operation error",
10617 )?;
10618 require_eq(
10619 &store.connection.query_row(
10620 "SELECT value FROM metadata WHERE key = ?1",
10621 [sentinel_key],
10622 |row| row.get::<_, String>(0),
10623 )?,
10624 &"committed".to_string(),
10625 "validated write rollback",
10626 )?;
10627
10628 with_validated_native_write_transaction(
10629 &store.connection,
10630 store.validated_project_root_identity.as_ref(),
10631 Some(expected_identity),
10632 |connection| set_metadata(connection, sentinel_key, "native-committed"),
10633 )?;
10634 require_eq(
10635 &store.connection.query_row(
10636 "SELECT value FROM metadata WHERE key = ?1",
10637 [sentinel_key],
10638 |row| row.get::<_, String>(0),
10639 )?,
10640 &"native-committed".to_string(),
10641 "native validated write positive control",
10642 )?;
10643
10644 let future_schema = schema::SCHEMA_VERSION + 1;
10645 let newer_owner = Connection::open(&db_path)?;
10646 newer_owner.execute(
10647 "UPDATE metadata SET value = ?2 WHERE key = ?1",
10648 params![schema::SCHEMA_VERSION_KEY, future_schema.to_string()],
10649 )?;
10650 let operation_invoked = Cell::new(false);
10651 let Err(binding_error) = with_validated_native_write_transaction(
10652 &store.connection,
10653 store.validated_project_root_identity.as_ref(),
10654 Some(expected_identity),
10655 |connection| {
10656 operation_invoked.set(true);
10657 set_metadata(connection, sentinel_key, "wrong-schema")
10658 },
10659 ) else {
10660 return Err(io::Error::other("newer schema accepted a validated write").into());
10661 };
10662 require_eq(
10663 &matches!(
10664 binding_error,
10665 DbError::SchemaVersion { found, expected }
10666 if found == future_schema && expected == schema::SCHEMA_VERSION
10667 ),
10668 &true,
10669 "validated write schema recheck",
10670 )?;
10671 require_eq(
10672 &operation_invoked.get(),
10673 &false,
10674 "validated write closure invocation",
10675 )?;
10676 require_eq(
10677 &newer_owner.query_row(
10678 "SELECT value FROM metadata WHERE key = ?1",
10679 [sentinel_key],
10680 |row| row.get::<_, String>(0),
10681 )?,
10682 &"native-committed".to_string(),
10683 "newer-schema sentinel value",
10684 )?;
10685 Ok(())
10686 }
10687
10688 #[test]
10689 fn read_only_telemetry_revalidates_project_identity_before_write() -> Result<(), Box<dyn Error>>
10690 {
10691 let temp = tempfile::tempdir()?;
10692 let root = temp.path().join("repository");
10693 fs::create_dir_all(&root)?;
10694 let db_path = temp.path().join("projectatlas.db");
10695 drop(AtlasStore::open_for_project(&db_path, &root)?);
10696
10697 let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10698 reader.finish_index_read_snapshot()?;
10699 AtlasStore::transition_project_root(&db_path, &root, ProjectRootTransition::Detach)?;
10700
10701 let event = usage_from_estimates(
10702 "identity-race",
10703 "summary",
10704 Some("src/lib.rs".to_string()),
10705 None,
10706 100,
10707 20,
10708 );
10709 let Err(error) = reader.record_usage(&event) else {
10710 return Err(io::Error::other("telemetry wrote through replaced identity").into());
10711 };
10712 require_eq(
10713 &matches!(error, DbError::ProjectRootTransitionChanged { .. }),
10714 &true,
10715 "telemetry project identity recheck",
10716 )?;
10717 let current = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10718 require_eq(
10719 ¤t.token_overview(Some("identity-race"))?.calls,
10720 &0,
10721 "telemetry refused after identity replacement",
10722 )?;
10723 Ok(())
10724 }
10725
10726 #[test]
10727 fn read_only_recovery_requires_native_identity_before_telemetry_write()
10728 -> Result<(), Box<dyn Error>> {
10729 let temp = tempfile::tempdir()?;
10730 let root = temp.path().join("repository");
10731 fs::create_dir_all(&root)?;
10732 let database = temp.path().join("projectatlas.db");
10733 let event = usage_from_estimates(
10734 "rooted-telemetry",
10735 "summary",
10736 Some("src/lib.rs".to_string()),
10737 None,
10738 100,
10739 20,
10740 );
10741 let rooted_instance = UsageInstanceId::from_bytes([7; 16])?;
10742 let native_root_row;
10743 {
10744 let store = AtlasStore::open_for_project(&database, &root)?;
10745 native_root_row = store.connection.query_row(
10746 "SELECT codec_version, root
10747 FROM project_root_identity
10748 WHERE singleton = 1",
10749 [],
10750 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)),
10751 )?;
10752 store.record_usage(&event)?;
10753 store.record_usage_for_instance(
10754 rooted_instance,
10755 UsageInstanceOwner::LibraryHandle,
10756 &event,
10757 false,
10758 )?;
10759 store.seal_usage_instance(rooted_instance)?;
10760 require_eq(
10761 &store.token_overview(Some("rooted-telemetry"))?.calls,
10762 &2,
10763 "rooted telemetry positive write",
10764 )?;
10765 store
10766 .connection
10767 .execute("DELETE FROM project_root_identity", [])?;
10768 store
10769 .connection
10770 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
10771 }
10772
10773 let file_snapshot = || -> Result<BTreeMap<std::ffi::OsString, Vec<u8>>, io::Error> {
10774 let mut files = BTreeMap::new();
10775 for entry in fs::read_dir(temp.path())? {
10776 let entry = entry?;
10777 if entry.file_type()?.is_file() {
10778 files.insert(entry.file_name(), fs::read(entry.path())?);
10779 }
10780 }
10781 Ok(files)
10782 };
10783 let reader = AtlasStore::open_read_only(&database)?;
10784 require_eq(
10785 &reader.project_root_identity()?,
10786 &None,
10787 "rootless recovery has no native identity",
10788 )?;
10789 require_eq(
10790 &reader.validated_project_instance_id.is_some(),
10791 &true,
10792 "rootless recovery retains project identity",
10793 )?;
10794 require_eq(
10795 &reader.project_root()?,
10796 &Some(normalize_native_path_display(&root)),
10797 "rootless recovery retains legacy display root",
10798 )?;
10799 let overview_before = reader.token_overview(Some("rooted-telemetry"))?;
10800 let files_before = file_snapshot()?;
10801
10802 let missing_root_event = usage_from_estimates(
10803 "rootless-telemetry",
10804 "summary",
10805 Some("src/main.rs".to_string()),
10806 None,
10807 80,
10808 20,
10809 );
10810 let Err(error) = reader.record_usage(&missing_root_event) else {
10811 return Err(io::Error::other("rootless recovery recorded direct telemetry").into());
10812 };
10813 require_eq(
10814 &matches!(error, DbError::ProjectRootIdentityMissing),
10815 &true,
10816 "rootless direct telemetry refusal",
10817 )?;
10818
10819 let Err(error) = reader.record_usage_for_instance(
10820 UsageInstanceId::from_bytes([8; 16])?,
10821 UsageInstanceOwner::LibraryHandle,
10822 &missing_root_event,
10823 false,
10824 ) else {
10825 return Err(io::Error::other("rootless recovery recorded instance telemetry").into());
10826 };
10827 require_eq(
10828 &matches!(error, DbError::ProjectRootIdentityMissing),
10829 &true,
10830 "rootless instance telemetry refusal",
10831 )?;
10832
10833 let Err(error) = reader.record_usage_for_worktree_instance(
10834 UsageInstanceId::from_bytes([9; 16])?,
10835 UsageInstanceOwner::McpProcess,
10836 1,
10837 &missing_root_event,
10838 false,
10839 ) else {
10840 return Err(io::Error::other("rootless recovery recorded worktree telemetry").into());
10841 };
10842 require_eq(
10843 &matches!(error, DbError::ProjectRootIdentityMissing),
10844 &true,
10845 "rootless worktree telemetry refusal",
10846 )?;
10847
10848 let Err(error) = reader.seal_usage_instance(UsageInstanceId::from_bytes([10; 16])?) else {
10849 return Err(io::Error::other("rootless recovery sealed telemetry").into());
10850 };
10851 require_eq(
10852 &matches!(error, DbError::ProjectRootIdentityMissing),
10853 &true,
10854 "rootless seal telemetry refusal",
10855 )?;
10856
10857 require_eq(
10858 &reader.token_overview(Some("rooted-telemetry"))?,
10859 &overview_before,
10860 "rootless telemetry rows remain unchanged",
10861 )?;
10862 require_eq(
10863 &file_snapshot()?,
10864 &files_before,
10865 "rootless telemetry files remain unchanged",
10866 )?;
10867 drop(reader);
10868
10869 let repair = Connection::open(&database)?;
10870 schema::configure_writable(&repair)?;
10871 repair.execute(
10872 "INSERT INTO project_root_identity(singleton, codec_version, root)
10873 VALUES(1, ?1, ?2)",
10874 params![native_root_row.0, native_root_row.1],
10875 )?;
10876 drop(repair);
10877 let repaired = AtlasStore::open_for_project(&database, &root)?;
10878 repaired.record_usage(&usage_from_estimates(
10879 "repaired-telemetry",
10880 "summary",
10881 None,
10882 None,
10883 60,
10884 20,
10885 ))?;
10886 require_eq(
10887 &repaired.token_overview(Some("repaired-telemetry"))?.calls,
10888 &1,
10889 "repaired telemetry retry",
10890 )?;
10891 Ok(())
10892 }
10893
10894 #[test]
10895 fn telemetry_contention_uses_ancillary_busy_budget() -> Result<(), Box<dyn Error>> {
10896 let temp = tempfile::tempdir()?;
10897 let root = temp.path().join("repository");
10898 fs::create_dir_all(&root)?;
10899 let db_path = temp.path().join("projectatlas.db");
10900 drop(AtlasStore::open_for_project(&db_path, &root)?);
10901
10902 let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10903 let writer = Connection::open(&db_path)?;
10904 writer.execute_batch("BEGIN IMMEDIATE")?;
10905 let started = Instant::now();
10906 let result = reader.record_usage(&usage_from_estimates(
10907 "contended",
10908 "overview",
10909 None,
10910 None,
10911 100,
10912 20,
10913 ));
10914 let elapsed = started.elapsed();
10915 writer.execute_batch("ROLLBACK")?;
10916
10917 let Err(error) = result else {
10918 return Err(
10919 io::Error::other("contended telemetry write unexpectedly succeeded").into(),
10920 );
10921 };
10922 require_eq(
10923 &error.is_write_unavailable(),
10924 &true,
10925 "contended telemetry error kind",
10926 )?;
10927 require_eq(
10928 &(elapsed < Duration::from_millis(500)),
10929 &true,
10930 "ancillary telemetry busy latency",
10931 )?;
10932 require_eq(
10933 &reader.token_overview(Some("contended"))?.calls,
10934 &0,
10935 "contended telemetry rollback",
10936 )?;
10937 Ok(())
10938 }
10939
10940 #[cfg(unix)]
10941 #[test]
10942 fn read_only_telemetry_does_not_recreate_a_removed_database() -> Result<(), Box<dyn Error>> {
10943 let temp = tempfile::tempdir()?;
10944 let root = temp.path().join("repository");
10945 fs::create_dir_all(&root)?;
10946 let db_path = temp.path().join("projectatlas.db");
10947 drop(AtlasStore::open_for_project(&db_path, &root)?);
10948
10949 let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10950 fs::remove_file(&db_path)?;
10951 let event = usage_from_estimates(
10952 "removed-database",
10953 "summary",
10954 Some("src/lib.rs".to_string()),
10955 None,
10956 100,
10957 20,
10958 );
10959 if reader.record_usage(&event).is_ok() {
10960 return Err(io::Error::other("telemetry reopened a removed database").into());
10961 }
10962 require_eq(
10963 &db_path.exists(),
10964 &false,
10965 "telemetry must not recreate a removed database",
10966 )?;
10967 Ok(())
10968 }
10969
10970 #[test]
10971 fn read_only_store_opens_checkpointed_wal_without_existing_sidecars()
10972 -> Result<(), Box<dyn Error>> {
10973 let temp = tempfile::tempdir()?;
10974 let root = temp.path().join("repo with spaces");
10975 let atlas_dir = root.join(".projectatlas");
10976 fs::create_dir_all(&atlas_dir)?;
10977 let db_path = atlas_dir.join("projectatlas.db");
10978 {
10979 let mut store = AtlasStore::open(&db_path)?;
10980 store.set_project_root(&root)?;
10981 store
10982 .connection
10983 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
10984 }
10985 for sidecar_path in [
10986 sqlite_sidecar_path(&db_path, "-wal"),
10987 sqlite_sidecar_path(&db_path, "-shm"),
10988 ] {
10989 match fs::remove_file(sidecar_path) {
10990 Ok(()) => {}
10991 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10992 Err(error) => return Err(error.into()),
10993 }
10994 }
10995
10996 let store = AtlasStore::open_read_only(&db_path)?;
10997 require_eq(
10998 &store.project_root()?,
10999 &Some(normalize_native_path_display(&root)),
11000 "read-only checkpointed project root",
11001 )?;
11002 store.finish_index_read_snapshot()?;
11003 Ok(())
11004 }
11005
11006 #[test]
11007 fn partial_scan_updates_and_absents_paths() -> Result<(), Box<dyn Error>> {
11008 let mut store = AtlasStore::in_memory()?;
11009 store.replace_scan(&[
11010 test_file_node("src/a.rs", "hash-a"),
11011 test_file_node("src/b.rs", "hash-b"),
11012 ])?;
11013 store.upsert_scan_nodes(&[test_file_node("src/a.rs", "hash-a2")])?;
11014 let updated = store
11015 .load_node_by_path("src/a.rs")?
11016 .ok_or_else(|| io::Error::other("updated node missing"))?;
11017 require_eq(
11018 &updated.node.content_hash,
11019 &Some("hash-a2".to_string()),
11020 "partial content hash",
11021 )?;
11022 require_eq(
11023 &store.load_node_by_path("src/b.rs")?.is_some(),
11024 &true,
11025 "unrelated node remains indexed",
11026 )?;
11027 store.mark_paths_absent(&["src/b.rs".to_string()])?;
11028 require_eq(
11029 &store.load_node_by_path("src/b.rs")?.is_none(),
11030 &true,
11031 "absent path is no longer returned",
11032 )?;
11033 Ok(())
11034 }
11035
11036 #[test]
11037 fn approved_purpose_survives_incremental_file_hash_changes() -> Result<(), Box<dyn Error>> {
11038 let mut store = AtlasStore::in_memory()?;
11039 store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
11040 store.set_purpose(
11041 "src/main.rs",
11042 "Application entry point",
11043 PurposeSource::Agent,
11044 )?;
11045 store.upsert_scan_nodes(&[test_file_node("src/main.rs", "hash-b")])?;
11046
11047 let node = store
11048 .load_node_by_path("src/main.rs")?
11049 .ok_or_else(|| io::Error::other("changed node missing"))?;
11050 require_eq(
11051 &node.purpose.status,
11052 &PurposeStatus::Approved,
11053 "changed approved file purpose status",
11054 )?;
11055 require_eq(
11056 &node.purpose.agent_reviewed(),
11057 &true,
11058 "changed approved file remains agent reviewed",
11059 )?;
11060 Ok(())
11061 }
11062
11063 #[test]
11064 fn ranked_nodes_are_loaded_bounded_from_sql() -> Result<(), Box<dyn Error>> {
11065 let mut store = AtlasStore::in_memory()?;
11066 let mut gradle_task_node = test_file_node("build.gradle.kts", "hash-gradle");
11067 gradle_task_node.extension = Some(".kts".to_string());
11068 gradle_task_node.language = Some("kotlin".to_string());
11069 store.replace_scan(&[
11070 test_folder_node("src/auth"),
11071 test_folder_node("src/ui"),
11072 test_file_node("src/auth/login.rs", "hash-login"),
11073 test_file_node("src/ui/button.rs", "hash-button"),
11074 gradle_task_node,
11075 ])?;
11076 store.set_purpose(
11077 "src/auth",
11078 "Authentication workflow folder",
11079 PurposeSource::Agent,
11080 )?;
11081 store.set_purpose("src/ui", "User interface folder", PurposeSource::Agent)?;
11082 store.set_node_summary("src/auth/login.rs", "rust source defining login flow")?;
11083
11084 let folders = store.load_ranked_nodes("authentication", NodeKind::Folder, None, 1, 0)?;
11085 require_eq(&folders.len(), &1, "bounded folder ranking")?;
11086 require_eq(
11087 &folders[0].node.path,
11088 &"src/auth".to_string(),
11089 "semantic folder ranking",
11090 )?;
11091
11092 let files = store.load_ranked_nodes("login", NodeKind::File, Some("src/auth"), 10, 0)?;
11093 require_eq(&files.len(), &1, "folder-constrained file ranking")?;
11094 require_eq(
11095 &files[0].node.path,
11096 &"src/auth/login.rs".to_string(),
11097 "ranked file path",
11098 )?;
11099 store.replace_symbol_graph(&SymbolGraph {
11100 path: "build.gradle.kts".to_string(),
11101 language: Some("kotlin".to_string()),
11102 parser: ParserKind::TreeSitter,
11103 symbols: vec![CodeSymbol {
11104 path: "build.gradle.kts".to_string(),
11105 language: Some("kotlin".to_string()),
11106 name: "bootRunE2E".to_string(),
11107 kind: SymbolKind::Function,
11108 signature: "tasks.register<BootRun>(\"bootRunE2E\")".to_string(),
11109 exported: false,
11110 documentation: None,
11111 line_start: 1,
11112 line_end: 1,
11113 source_selector: None,
11114 parent: None,
11115 parser: ParserKind::TreeSitter,
11116 detail: Some("gradle-kotlin-dsl-task".to_string()),
11117 }],
11118 relations: Vec::new(),
11119 })?;
11120 let gradle_files = store.load_ranked_nodes("bootRunE2E", NodeKind::File, None, 10, 0)?;
11121 require_eq(&gradle_files.len(), &1, "symbol-ranked file count")?;
11122 require_eq(
11123 &gradle_files[0].node.path,
11124 &"build.gradle.kts".to_string(),
11125 "symbol-ranked file path",
11126 )?;
11127 store.clear_symbol_graph_for_path("build.gradle.kts")?;
11128 let cleared_gradle_files =
11129 store.load_ranked_nodes("bootRunE2E", NodeKind::File, None, 10, 0)?;
11130 require_eq(
11131 &cleared_gradle_files.len(),
11132 &0,
11133 "cleared symbol-ranked file count",
11134 )?;
11135 Ok(())
11136 }
11137
11138 #[test]
11139 fn ranked_node_admission_preserves_dominant_tiers_before_candidate_cap()
11140 -> Result<(), Box<dyn Error>> {
11141 let mut store = AtlasStore::in_memory()?;
11142 let mut nodes = vec![
11143 test_file_node("needle", "hash-exact"),
11144 test_file_node("deep/needle", "hash-name"),
11145 test_file_node("reviewed.rs", "hash-reviewed"),
11146 ];
11147 nodes.extend(
11148 (0..130).map(|index| test_file_node(&format!("weak/needle-{index:03}.rs"), "hash")),
11149 );
11150 store.replace_scan(&nodes)?;
11151 store.set_purpose(
11152 "reviewed.rs",
11153 "Own needle responsibility",
11154 PurposeSource::Agent,
11155 )?;
11156 for index in 0..130 {
11157 let path = format!("weak/needle-{index:03}.rs");
11158 store.set_suggested_purpose(&path, "Generated needle suggestion")?;
11159 store.set_node_summary(&path, "Observed needle summary")?;
11160 }
11161
11162 let selected = store.load_ranked_nodes("needle", NodeKind::File, None, 100, 0)?;
11163 require_eq(&selected.len(), &100, "bounded adversarial candidate count")?;
11164 require_eq(
11165 &selected[..3]
11166 .iter()
11167 .map(|node| node.node.path.as_str())
11168 .collect::<Vec<_>>(),
11169 &vec!["needle", "deep/needle", "reviewed.rs"],
11170 "pre-cap exact path basename and reviewed-purpose admission",
11171 )?;
11172 Ok(())
11173 }
11174
11175 #[test]
11176 fn folder_like_filters_treat_wildcards_as_literal_path_text() -> Result<(), Box<dyn Error>> {
11177 let mut store = AtlasStore::in_memory()?;
11178 store.replace_scan(&[
11179 test_folder_node("src/a%b"),
11180 test_folder_node("src/axb"),
11181 test_folder_node("src/a_b"),
11182 test_folder_node("src/acb"),
11183 test_file_node("src/a%b/target.rs", "hash-percent-target"),
11184 test_file_node("src/axb/false.rs", "hash-percent-false"),
11185 test_file_node("src/a_b/target.rs", "hash-underscore-target"),
11186 test_file_node("src/acb/false.rs", "hash-underscore-false"),
11187 ])?;
11188 for path in [
11189 "src/a%b/target.rs",
11190 "src/axb/false.rs",
11191 "src/a_b/target.rs",
11192 "src/acb/false.rs",
11193 ] {
11194 store.set_node_summary(path, "needle indexed summary")?;
11195 }
11196
11197 let percent_files =
11198 store.load_ranked_nodes("needle", NodeKind::File, Some("src/a%b"), 10, 0)?;
11199 require_eq(&percent_files.len(), &1, "percent folder ranked count")?;
11200 require_eq(
11201 &percent_files[0].node.path,
11202 &"src/a%b/target.rs".to_string(),
11203 "percent folder ranked path",
11204 )?;
11205 require_eq(
11206 &store.source_file_byte_count(Some("src/a%b"))?,
11207 &12,
11208 "percent folder byte count",
11209 )?;
11210
11211 let mut visited = Vec::new();
11212 store.visit_file_token_estimates(Some("src/a_b"), |path, _size| {
11213 visited.push(path);
11214 Ok(true)
11215 })?;
11216 require_eq(
11217 &visited,
11218 &vec!["src/a_b/target.rs".to_string()],
11219 "underscore folder token paths",
11220 )?;
11221
11222 store.mark_paths_absent(&["src/a%b".to_string(), "src/a_b".to_string()])?;
11223 require_eq(
11224 &store.load_node_by_path("src/axb/false.rs")?.is_some(),
11225 &true,
11226 "percent-like sibling remains indexed",
11227 )?;
11228 require_eq(
11229 &store.load_node_by_path("src/acb/false.rs")?.is_some(),
11230 &true,
11231 "underscore-like sibling remains indexed",
11232 )?;
11233 require_eq(
11234 &store.load_node_by_path("src/a%b/target.rs")?.is_none(),
11235 &true,
11236 "percent folder target removed",
11237 )?;
11238 require_eq(
11239 &store.load_node_by_path("src/a_b/target.rs")?.is_none(),
11240 &true,
11241 "underscore folder target removed",
11242 )?;
11243 Ok(())
11244 }
11245
11246 #[test]
11247 fn sql_health_findings_match_resolution_ids() -> Result<(), Box<dyn Error>> {
11248 let mut store = AtlasStore::in_memory()?;
11249 store.replace_scan(&[
11250 test_file_node("src/a.rs", "hash-a"),
11251 test_file_node("src/b.rs", "hash-b"),
11252 ])?;
11253 store.set_purpose("src/a.rs", "Shared purpose", PurposeSource::Agent)?;
11254 store.set_purpose("src/b.rs", "Shared purpose", PurposeSource::Agent)?;
11255
11256 let findings = store.unresolved_health_findings(&[])?;
11257 let duplicate = findings
11258 .iter()
11259 .find(|finding| finding.category == "duplicate-purpose")
11260 .ok_or_else(|| io::Error::other("duplicate-purpose finding missing"))?;
11261 store.resolve_health_finding(&HealthResolution {
11262 finding_id: duplicate.id.clone(),
11263 category: duplicate.category.clone(),
11264 path: duplicate.path.clone(),
11265 related_path: duplicate.related_path.clone(),
11266 rationale: "Intentional mirror for test.".to_string(),
11267 })?;
11268 let remaining = store.unresolved_health_findings(&store.resolved_health_ids()?)?;
11269 require_eq(&remaining.is_empty(), &true, "resolved SQL health finding")?;
11270 Ok(())
11271 }
11272
11273 #[test]
11274 fn unresolved_health_findings_page_filters_and_bounds_rows() -> Result<(), Box<dyn Error>> {
11275 let mut store = AtlasStore::in_memory()?;
11276 store.replace_scan(&[
11277 test_folder_node("."),
11278 test_folder_node("src"),
11279 test_file_node("src/a.rs", "hash-a"),
11280 test_file_node("docs/a.rs", "hash-doc"),
11281 ])?;
11282 let query = HealthQuery {
11283 start_index: 1,
11284 limit: 1,
11285 category: Some("missing-purpose".to_string()),
11286 severity: Some(Severity::Warning),
11287 path_prefix: Some("src".to_string()),
11288 summary_only: false,
11289 scope: HealthScope::all(),
11290 };
11291
11292 let page = store.unresolved_health_findings_page(&[], &query)?;
11293 require_eq(&page.unfiltered_total, &4, "unfiltered health total")?;
11294 require_eq(&page.total, &2, "filtered health total")?;
11295 require_eq(&page.returned, &1, "returned health rows")?;
11296 require_eq(
11297 &page.findings[0].path,
11298 &"src/a.rs".to_string(),
11299 "paged path",
11300 )?;
11301
11302 let summary_page = store.unresolved_health_findings_page(
11303 &[],
11304 &HealthQuery {
11305 summary_only: true,
11306 ..query
11307 },
11308 )?;
11309 require_eq(&summary_page.total, &2, "summary-only total")?;
11310 require_eq(
11311 &summary_page.findings.is_empty(),
11312 &true,
11313 "summary-only rows",
11314 )?;
11315 Ok(())
11316 }
11317
11318 #[test]
11319 fn unresolved_health_findings_page_skips_resolved_lifecycle_rows_before_paging()
11320 -> Result<(), Box<dyn Error>> {
11321 let mut store = AtlasStore::in_memory()?;
11322 store.replace_scan(&[
11323 test_file_node("src/a.rs", "hash-a"),
11324 test_file_node("src/b.rs", "hash-b"),
11325 test_file_node("src/c.rs", "hash-c"),
11326 test_file_node("src/d.rs", "hash-d"),
11327 ])?;
11328 store.resolve_health_finding(&HealthResolution {
11329 finding_id: finding_id("missing-purpose", "src/b.rs", None),
11330 category: "missing-purpose".to_string(),
11331 path: "src/b.rs".to_string(),
11332 related_path: None,
11333 rationale: "Resolved for pagination regression.".to_string(),
11334 })?;
11335
11336 let page = store.unresolved_health_findings_page_current(&HealthQuery {
11337 start_index: 0,
11338 limit: 2,
11339 category: Some("missing-purpose".to_string()),
11340 severity: Some(Severity::Warning),
11341 path_prefix: Some("src".to_string()),
11342 summary_only: false,
11343 scope: HealthScope::all(),
11344 })?;
11345
11346 require_eq(&page.total, &3, "filtered unresolved missing total")?;
11347 require_eq(&page.returned, &2, "returned unresolved missing rows")?;
11348 require_eq(
11349 &page
11350 .findings
11351 .iter()
11352 .map(|finding| finding.path.as_str())
11353 .collect::<Vec<_>>(),
11354 &vec!["src/a.rs", "src/c.rs"],
11355 "resolved row skipped before limit",
11356 )?;
11357 Ok(())
11358 }
11359
11360 #[test]
11361 fn stored_health_resolution_filter_stays_indexed_and_bind_bounded() -> Result<(), Box<dyn Error>>
11362 {
11363 const HISTORICAL_RESOLUTIONS: usize = 1_500;
11364
11365 let temp = tempfile::tempdir()?;
11366 let root = temp.path().join("repository");
11367 fs::create_dir_all(root.join("src"))?;
11368 let database = temp.path().join("projectatlas.db");
11369 let mut store = AtlasStore::open_for_project(&database, &root)?;
11370 store.replace_scan(&[test_file_node("src/current.rs", "hash-current")])?;
11371 {
11372 let transaction = store.connection.transaction()?;
11373 {
11374 let mut statement = transaction.prepare(
11375 "
11376 INSERT INTO health_resolutions(
11377 finding_id, category, path, related_path, rationale, resolved_by
11378 )
11379 VALUES(?1, 'missing-purpose', ?2, NULL, 'historical', 'agent')
11380 ",
11381 )?;
11382 for index in 0..HISTORICAL_RESOLUTIONS {
11383 let path = format!("removed/{index}.rs");
11384 statement.execute(params![finding_id("missing-purpose", &path, None), path])?;
11385 }
11386 }
11387 transaction.commit()?;
11388 }
11389
11390 drop(store);
11391 let store = AtlasStore::open_read_only_for_project(&database, &root)?;
11392 let page = store.unresolved_health_findings_page_current(&HealthQuery {
11393 start_index: 0,
11394 limit: 2,
11395 category: Some("missing-purpose".to_string()),
11396 severity: Some(Severity::Warning),
11397 path_prefix: Some("src".to_string()),
11398 summary_only: false,
11399 scope: HealthScope::all(),
11400 })?;
11401 require_eq(&page.total, &1, "current unresolved total")?;
11402 require_eq(
11403 &page.findings[0].path,
11404 &"src/current.rs".to_string(),
11405 "current unresolved path",
11406 )?;
11407
11408 let (where_clause, values) = purpose_status_where_clause(
11409 PURPOSE_HEALTH_SPECS[0],
11410 None,
11411 HealthResolutionFilter::Stored,
11412 HealthScope::all(),
11413 );
11414 require_eq(&values.len(), &1, "stored filter bind count")?;
11415 let plan_sql = format!(
11416 "EXPLAIN QUERY PLAN
11417 SELECT COUNT(*)
11418 FROM nodes n
11419 JOIN purposes p ON p.node_id = n.id
11420 WHERE {where_clause}"
11421 );
11422 let mut statement = store.connection.prepare(&plan_sql)?;
11423 let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(3))?;
11424 let mut plan = Vec::new();
11425 for row in rows {
11426 plan.push(row?);
11427 }
11428 if !plan.iter().any(|detail| {
11429 detail.contains("health_resolutions")
11430 && detail.contains("finding_id")
11431 && detail.contains("SEARCH")
11432 }) {
11433 return Err(io::Error::other(format!(
11434 "stored resolution anti-lookup did not use the finding-id index: {plan:?}"
11435 ))
11436 .into());
11437 }
11438 store.finish_index_read_snapshot()?;
11439 Ok(())
11440 }
11441
11442 #[test]
11443 fn unresolved_health_findings_page_streams_duplicate_and_temp_rows()
11444 -> Result<(), Box<dyn Error>> {
11445 let mut store = AtlasStore::in_memory()?;
11446 store.replace_scan(&[
11447 test_folder_node("."),
11448 test_folder_node("tmp"),
11449 test_folder_node("src"),
11450 test_folder_node("src/tmp"),
11451 test_file_node("src/a.rs", "hash-a"),
11452 test_file_node("src/b.rs", "hash-b"),
11453 ])?;
11454 store.set_purpose(".", "Repository root", PurposeSource::Agent)?;
11455 store.set_purpose("src", "Source folder", PurposeSource::Agent)?;
11456 store.set_purpose("tmp", "Temporary output", PurposeSource::Agent)?;
11457 store.set_purpose("src/tmp", "Source temporary output", PurposeSource::Agent)?;
11458 store.set_purpose("src/a.rs", "Shared implementation", PurposeSource::Agent)?;
11459 store.set_purpose("src/b.rs", "Shared implementation", PurposeSource::Agent)?;
11460
11461 let duplicate_page = store.unresolved_health_findings_page(
11462 &[],
11463 &HealthQuery {
11464 start_index: 0,
11465 limit: 1,
11466 category: Some("duplicate-purpose".to_string()),
11467 severity: Some(Severity::Warning),
11468 path_prefix: Some("src".to_string()),
11469 summary_only: false,
11470 scope: HealthScope::all(),
11471 },
11472 )?;
11473 require_eq(&duplicate_page.total, &1, "duplicate total")?;
11474 require_eq(&duplicate_page.returned, &1, "duplicate returned")?;
11475 require_eq(
11476 &duplicate_page.findings[0].category,
11477 &"duplicate-purpose".to_string(),
11478 "duplicate category",
11479 )?;
11480
11481 let temp_page = store.unresolved_health_findings_page(
11482 &[],
11483 &HealthQuery {
11484 start_index: 0,
11485 limit: 1,
11486 category: Some("repeated-temporary-folder".to_string()),
11487 severity: Some(Severity::Warning),
11488 path_prefix: Some(".".to_string()),
11489 summary_only: false,
11490 scope: HealthScope::all(),
11491 },
11492 )?;
11493 require_eq(&temp_page.total, &1, "temp total")?;
11494 require_eq(&temp_page.returned, &1, "temp returned")?;
11495 require_eq(
11496 &temp_page.findings[0].category,
11497 &"repeated-temporary-folder".to_string(),
11498 "temp category",
11499 )?;
11500 Ok(())
11501 }
11502
11503 #[test]
11504 fn unresolved_health_findings_page_source_only_filters_asset_noise()
11505 -> Result<(), Box<dyn Error>> {
11506 let mut store = AtlasStore::in_memory()?;
11507 let asset_file = Node {
11508 path: "assets/logo.png".to_string(),
11509 kind: NodeKind::File,
11510 parent_path: Some("assets".to_string()),
11511 extension: Some(".png".to_string()),
11512 language: None,
11513 size_bytes: Some(42),
11514 mtime_ns: Some(10),
11515 content_hash: Some("hash-logo".to_string()),
11516 };
11517 store.replace_scan(&[
11518 test_folder_node("."),
11519 test_folder_node("src"),
11520 test_file_node("src/main.rs", "hash-main"),
11521 test_folder_node("assets"),
11522 asset_file,
11523 ])?;
11524
11525 let page = store.unresolved_health_findings_page(
11526 &[],
11527 &HealthQuery {
11528 start_index: 0,
11529 limit: 10,
11530 category: Some("missing-purpose".to_string()),
11531 severity: Some(Severity::Warning),
11532 path_prefix: Some(".".to_string()),
11533 summary_only: false,
11534 scope: HealthScope::source_only(),
11535 },
11536 )?;
11537
11538 require_eq(&page.unfiltered_total, &5, "all unresolved rows")?;
11539 require_eq(&page.total, &3, "source-only missing total")?;
11540 require_eq(
11541 &page
11542 .findings
11543 .iter()
11544 .map(|finding| finding.path.as_str())
11545 .collect::<Vec<_>>(),
11546 &vec![".", "src", "src/main.rs"],
11547 "source-only paths",
11548 )?;
11549 Ok(())
11550 }
11551
11552 #[test]
11553 fn high_impact_purpose_queue_filters_low_priority_files() -> Result<(), Box<dyn Error>> {
11554 let mut store = AtlasStore::in_memory()?;
11555 store.replace_scan(&[
11556 test_folder_node("."),
11557 test_folder_node("src"),
11558 test_file_node("src/main.rs", "hash-main"),
11559 test_file_node("src/helper.rs", "hash-helper"),
11560 test_file_node("build.gradle.kts", "hash-gradle"),
11561 ])?;
11562
11563 let page = store.unresolved_health_findings_page(
11564 &[],
11565 &HealthQuery {
11566 start_index: 0,
11567 limit: 10,
11568 category: Some("missing-purpose".to_string()),
11569 severity: Some(Severity::Warning),
11570 path_prefix: Some(".".to_string()),
11571 summary_only: false,
11572 scope: HealthScope::purpose_default(),
11573 },
11574 )?;
11575
11576 require_eq(&page.unfiltered_total, &5, "all missing rows")?;
11577 require_eq(&page.total, &4, "default actionable rows")?;
11578 require_eq(
11579 &page
11580 .findings
11581 .iter()
11582 .map(|finding| finding.path.as_str())
11583 .collect::<Vec<_>>(),
11584 &vec![".", "src", "build.gradle.kts", "src/main.rs"],
11585 "folder-first high-impact queue paths",
11586 )?;
11587
11588 let broad_page = store.unresolved_health_findings_page(
11589 &[],
11590 &HealthQuery {
11591 start_index: 0,
11592 limit: 10,
11593 category: Some("missing-purpose".to_string()),
11594 severity: Some(Severity::Warning),
11595 path_prefix: Some(".".to_string()),
11596 summary_only: false,
11597 scope: HealthScope::all(),
11598 },
11599 )?;
11600 require_eq(&broad_page.total, &5, "explicit broad queue rows")?;
11601 Ok(())
11602 }
11603
11604 #[test]
11605 fn high_impact_purpose_queue_keeps_asset_only_folders_without_asset_files()
11606 -> Result<(), Box<dyn Error>> {
11607 let mut store = AtlasStore::in_memory()?;
11608 let asset_file = Node {
11609 path: "assets/logo.svg".to_string(),
11610 kind: NodeKind::File,
11611 parent_path: Some("assets".to_string()),
11612 extension: Some(".svg".to_string()),
11613 language: None,
11614 size_bytes: Some(42),
11615 mtime_ns: Some(10),
11616 content_hash: Some("hash-logo".to_string()),
11617 };
11618 store.replace_scan(&[
11619 test_folder_node("."),
11620 test_folder_node("assets"),
11621 test_folder_node("src"),
11622 asset_file,
11623 test_file_node("src/helper.rs", "hash-helper"),
11624 ])?;
11625
11626 let page = store.unresolved_health_findings_page(
11627 &[],
11628 &HealthQuery {
11629 start_index: 0,
11630 limit: 10,
11631 category: Some("missing-purpose".to_string()),
11632 severity: Some(Severity::Warning),
11633 path_prefix: Some(".".to_string()),
11634 summary_only: false,
11635 scope: HealthScope::purpose_default(),
11636 },
11637 )?;
11638
11639 require_eq(&page.unfiltered_total, &5, "all missing rows")?;
11640 require_eq(&page.total, &3, "all folders without asset files")?;
11641 require_eq(
11642 &page
11643 .findings
11644 .iter()
11645 .map(|finding| finding.path.as_str())
11646 .collect::<Vec<_>>(),
11647 &vec![".", "assets", "src"],
11648 "folder-first default queue keeps asset-only folders",
11649 )?;
11650 Ok(())
11651 }
11652
11653 #[test]
11654 fn high_impact_purpose_queue_pages_folders_before_files() -> Result<(), Box<dyn Error>> {
11655 let mut store = AtlasStore::in_memory()?;
11656 store.replace_scan(&[
11657 test_folder_node("."),
11658 test_folder_node("src"),
11659 test_file_node("Cargo.toml", "hash-cargo"),
11660 test_file_node("package.json", "hash-package"),
11661 test_file_node("pyproject.toml", "hash-python"),
11662 ])?;
11663
11664 let page = store.unresolved_health_findings_page(
11665 &[],
11666 &HealthQuery {
11667 start_index: 0,
11668 limit: 2,
11669 category: Some("missing-purpose".to_string()),
11670 severity: Some(Severity::Warning),
11671 path_prefix: Some(".".to_string()),
11672 summary_only: false,
11673 scope: HealthScope::purpose_default(),
11674 },
11675 )?;
11676
11677 require_eq(&page.total, &5, "default actionable total")?;
11678 require_eq(
11679 &page
11680 .findings
11681 .iter()
11682 .map(|finding| finding.path.as_str())
11683 .collect::<Vec<_>>(),
11684 &vec![".", "src"],
11685 "small page keeps folders first",
11686 )?;
11687 Ok(())
11688 }
11689
11690 #[test]
11691 fn high_impact_purpose_queue_omits_low_value_stale_files() -> Result<(), Box<dyn Error>> {
11692 let mut store = AtlasStore::in_memory()?;
11693 store.replace_scan(&[
11694 test_file_node("src/helper.rs", "hash-a"),
11695 test_file_node("Cargo.toml", "hash-cargo"),
11696 test_file_node("package.json", "hash-package"),
11697 ])?;
11698 store.set_purpose(
11699 "src/helper.rs",
11700 "Reviewed helper implementation.",
11701 PurposeSource::Agent,
11702 )?;
11703 store.set_purpose(
11704 "Cargo.toml",
11705 "Reviewed Cargo manifest.",
11706 PurposeSource::Agent,
11707 )?;
11708 store.connection.execute(
11709 "UPDATE purposes
11710 SET status = 'stale'
11711 WHERE node_id IN (
11712 SELECT id FROM nodes WHERE path IN ('src/helper.rs', 'Cargo.toml')
11713 )",
11714 [],
11715 )?;
11716 store.replace_scan(&[
11717 test_file_node("src/helper.rs", "hash-b"),
11718 test_file_node("Cargo.toml", "hash-cargo-new"),
11719 test_file_node("package.json", "hash-package"),
11720 ])?;
11721
11722 let page = store.unresolved_health_findings_page(
11723 &[],
11724 &HealthQuery {
11725 start_index: 0,
11726 limit: 1,
11727 category: None,
11728 severity: Some(Severity::Warning),
11729 path_prefix: Some(".".to_string()),
11730 summary_only: false,
11731 scope: HealthScope::purpose_default(),
11732 },
11733 )?;
11734
11735 require_eq(&page.total, &2, "default actionable total")?;
11736 require_eq(&page.returned, &1, "small page returned")?;
11737 require_eq(
11738 &page.findings[0].category,
11739 &"stale-purpose".to_string(),
11740 "stale high-impact file is still prioritized",
11741 )?;
11742 require_eq(
11743 &page.findings[0].path,
11744 &"Cargo.toml".to_string(),
11745 "stale high-impact file path",
11746 )?;
11747
11748 let broad_page = store.unresolved_health_findings_page(
11749 &[],
11750 &HealthQuery {
11751 start_index: 0,
11752 limit: 10,
11753 category: Some(CATEGORY_STALE_PURPOSE.to_string()),
11754 severity: Some(Severity::Warning),
11755 path_prefix: Some(".".to_string()),
11756 summary_only: false,
11757 scope: HealthScope::purpose_with_source_files(),
11758 },
11759 )?;
11760 require_eq(&broad_page.total, &2, "broad source stale rows")?;
11761 require_eq(
11762 &health_paths(&broad_page),
11763 &vec!["Cargo.toml", "src/helper.rs"],
11764 "broad scope includes low-value stale source files",
11765 )?;
11766 Ok(())
11767 }
11768
11769 #[test]
11770 fn include_assets_queue_includes_asset_files_not_low_priority_source()
11771 -> Result<(), Box<dyn Error>> {
11772 let mut store = AtlasStore::in_memory()?;
11773 let asset_file = Node {
11774 path: "assets/logo.svg".to_string(),
11775 kind: NodeKind::File,
11776 parent_path: Some("assets".to_string()),
11777 extension: Some(".svg".to_string()),
11778 language: None,
11779 size_bytes: Some(42),
11780 mtime_ns: Some(10),
11781 content_hash: Some("hash-logo".to_string()),
11782 };
11783 store.replace_scan(&[
11784 test_folder_node("."),
11785 test_folder_node("assets"),
11786 test_folder_node("src"),
11787 asset_file,
11788 test_file_node("src/helper.rs", "hash-helper"),
11789 ])?;
11790
11791 let page = store.unresolved_health_findings_page(
11792 &[],
11793 &HealthQuery {
11794 start_index: 0,
11795 limit: 10,
11796 category: Some("missing-purpose".to_string()),
11797 severity: Some(Severity::Warning),
11798 path_prefix: Some(".".to_string()),
11799 summary_only: false,
11800 scope: HealthScope::purpose_with_assets(),
11801 },
11802 )?;
11803
11804 require_eq(&page.unfiltered_total, &5, "all missing rows")?;
11805 require_eq(
11806 &page.total,
11807 &4,
11808 "assets included without broad source cleanup",
11809 )?;
11810 require_eq(
11811 &page
11812 .findings
11813 .iter()
11814 .map(|finding| finding.path.as_str())
11815 .collect::<Vec<_>>(),
11816 &vec![".", "assets", "src", "assets/logo.svg"],
11817 "asset files included and low-priority source omitted",
11818 )?;
11819 Ok(())
11820 }
11821
11822 #[test]
11823 fn legacy_human_stale_files_remain_in_default_queue() -> Result<(), Box<dyn Error>> {
11824 let mut store = AtlasStore::in_memory()?;
11825 store.replace_scan(&[test_file_node("src/helper.rs", "hash-a")])?;
11826 store.set_purpose(
11827 "src/helper.rs",
11828 "Legacy reviewed helper implementation.",
11829 PurposeSource::Agent,
11830 )?;
11831 store.connection.execute(
11832 "
11833 UPDATE purposes
11834 SET source = 'human', status = 'stale'
11835 WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/helper.rs')
11836 ",
11837 [],
11838 )?;
11839 store.replace_scan(&[test_file_node("src/helper.rs", "hash-b")])?;
11840
11841 let page = store.unresolved_health_findings_page(
11842 &[],
11843 &HealthQuery {
11844 start_index: 0,
11845 limit: 10,
11846 category: Some("stale-purpose".to_string()),
11847 severity: Some(Severity::Warning),
11848 path_prefix: Some(".".to_string()),
11849 summary_only: false,
11850 scope: HealthScope::purpose_default(),
11851 },
11852 )?;
11853
11854 require_eq(&page.total, &1, "legacy reviewed stale row total")?;
11855 require_eq(
11856 &page.findings[0].path,
11857 &"src/helper.rs".to_string(),
11858 "legacy reviewed stale file",
11859 )?;
11860 Ok(())
11861 }
11862
11863 #[test]
11864 fn stale_imported_files_remain_in_default_queue() -> Result<(), Box<dyn Error>> {
11865 let mut store = AtlasStore::in_memory()?;
11866 store.replace_scan(&[
11867 test_file_node("src/imported.rs", "hash-a"),
11868 test_file_node("Cargo.toml", "hash-cargo"),
11869 ])?;
11870 store.set_purpose(
11871 "src/imported.rs",
11872 "Imported helper implementation.",
11873 PurposeSource::Imported,
11874 )?;
11875 store.connection.execute(
11876 "UPDATE purposes
11877 SET status = 'stale'
11878 WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/imported.rs')",
11879 [],
11880 )?;
11881 store.replace_scan(&[
11882 test_file_node("src/imported.rs", "hash-b"),
11883 test_file_node("Cargo.toml", "hash-cargo"),
11884 ])?;
11885
11886 let page = store.unresolved_health_findings_page(
11887 &[],
11888 &HealthQuery {
11889 start_index: 0,
11890 limit: 10,
11891 category: None,
11892 severity: Some(Severity::Warning),
11893 path_prefix: Some(".".to_string()),
11894 summary_only: false,
11895 scope: HealthScope::purpose_default(),
11896 },
11897 )?;
11898
11899 require_eq(
11900 &page.total,
11901 &2,
11902 "default queue includes stale imported file",
11903 )?;
11904 require_eq(
11905 &health_paths(&page),
11906 &vec!["src/imported.rs", "Cargo.toml"],
11907 "stale imported file is queued before high-impact files",
11908 )?;
11909 require_eq(
11910 &page.findings[0].category,
11911 &"stale-purpose".to_string(),
11912 "stale imported finding category",
11913 )?;
11914 Ok(())
11915 }
11916
11917 #[test]
11918 fn duplicate_purpose_health_is_contextual_for_folders() -> Result<(), Box<dyn Error>> {
11919 let mut store = AtlasStore::in_memory()?;
11920 store.replace_scan(&[
11921 test_folder_node("."),
11922 test_folder_node("customers"),
11923 test_folder_node("customers/service"),
11924 test_folder_node("settings"),
11925 test_folder_node("settings/service"),
11926 ])?;
11927 store.set_purpose("customers/service", "Service layer", PurposeSource::Agent)?;
11928 store.set_purpose("settings/service", "Service layer", PurposeSource::Agent)?;
11929
11930 let page = store.unresolved_health_findings_page(
11931 &[],
11932 &HealthQuery {
11933 start_index: 0,
11934 limit: 10,
11935 category: Some("duplicate-purpose".to_string()),
11936 severity: Some(Severity::Warning),
11937 path_prefix: Some(".".to_string()),
11938 summary_only: false,
11939 scope: HealthScope::all(),
11940 },
11941 )?;
11942
11943 require_eq(&page.total, &0, "folder duplicates scoped by parent")?;
11944 Ok(())
11945 }
11946
11947 #[test]
11948 fn contextual_folder_duplicate_identity_matches_unpaged_health_and_resolution()
11949 -> Result<(), Box<dyn Error>> {
11950 let mut store = AtlasStore::in_memory()?;
11951 store.replace_scan(&[
11952 test_folder_node("."),
11953 test_folder_node("customers"),
11954 test_folder_node("customers/service"),
11955 test_folder_node("settings"),
11956 test_folder_node("settings/service"),
11957 test_folder_node("settings/worker"),
11958 ])?;
11959 store.set_purpose("customers/service", "Service layer", PurposeSource::Agent)?;
11960 store.set_purpose("settings/service", "Service layer", PurposeSource::Agent)?;
11961 store.set_purpose("settings/worker", "Service layer", PurposeSource::Agent)?;
11962
11963 let page = store.unresolved_health_findings_page(
11964 &[],
11965 &HealthQuery {
11966 start_index: 0,
11967 limit: 10,
11968 category: Some("duplicate-purpose".to_string()),
11969 severity: Some(Severity::Warning),
11970 path_prefix: Some(".".to_string()),
11971 summary_only: false,
11972 scope: HealthScope::all(),
11973 },
11974 )?;
11975 require_eq(&page.total, &1, "contextual duplicate total")?;
11976 let paged_finding = page
11977 .findings
11978 .first()
11979 .ok_or_else(|| io::Error::other("paged duplicate missing"))?;
11980 require_eq(
11981 &paged_finding.path,
11982 &"settings/worker".to_string(),
11983 "paged duplicate path",
11984 )?;
11985 require_eq(
11986 &paged_finding.related_path,
11987 &Some("settings/service".to_string()),
11988 "paged related path",
11989 )?;
11990
11991 let unpaged_duplicates = store
11992 .unresolved_health_findings(&[])?
11993 .into_iter()
11994 .filter(|finding| finding.category == "duplicate-purpose")
11995 .collect::<Vec<_>>();
11996 require_eq(
11997 &unpaged_duplicates,
11998 &page.findings,
11999 "unpaged duplicate identity",
12000 )?;
12001
12002 store.resolve_health_finding(&HealthResolution {
12003 finding_id: paged_finding.id.clone(),
12004 category: paged_finding.category.clone(),
12005 path: paged_finding.path.clone(),
12006 related_path: paged_finding.related_path.clone(),
12007 rationale: "Settings service and worker intentionally share a layer purpose."
12008 .to_string(),
12009 })?;
12010 let has_remaining_duplicate = store
12011 .unresolved_health_findings(&store.resolved_health_ids()?)?
12012 .into_iter()
12013 .any(|finding| finding.category == "duplicate-purpose");
12014 require_eq(
12015 &has_remaining_duplicate,
12016 &false,
12017 "resolved contextual duplicate",
12018 )?;
12019 Ok(())
12020 }
12021
12022 #[test]
12023 fn agent_review_required_scope_expands_from_low_to_strict() -> Result<(), Box<dyn Error>> {
12024 let mut store = AtlasStore::in_memory()?;
12025 let asset_file = Node {
12026 path: "assets/logo.svg".to_string(),
12027 kind: NodeKind::File,
12028 parent_path: Some("assets".to_string()),
12029 extension: Some(".svg".to_string()),
12030 language: None,
12031 size_bytes: Some(42),
12032 mtime_ns: Some(10),
12033 content_hash: Some("hash-logo".to_string()),
12034 };
12035 store.replace_scan(&[
12036 test_folder_node("."),
12037 test_folder_node("assets"),
12038 test_folder_node("src"),
12039 test_file_node("Cargo.toml", "hash-cargo"),
12040 test_file_node("src/detail.rs", "hash-detail"),
12041 asset_file,
12042 ])?;
12043 for (path, purpose) in [
12044 (".", "Imported repository root"),
12045 ("assets", "Imported asset folder"),
12046 ("src", "Imported Rust source folder"),
12047 ("Cargo.toml", "Imported Rust manifest"),
12048 ("src/detail.rs", "Imported implementation detail"),
12049 ("assets/logo.svg", "Imported SVG brand asset"),
12050 ] {
12051 store.set_purpose(path, purpose, PurposeSource::Imported)?;
12052 }
12053
12054 let low = store.unresolved_health_findings_page(
12055 &[],
12056 &HealthQuery {
12057 start_index: 0,
12058 limit: 20,
12059 category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
12060 severity: Some(Severity::Warning),
12061 path_prefix: Some(".".to_string()),
12062 summary_only: false,
12063 scope: HealthScope::purpose_default(),
12064 },
12065 )?;
12066 require_eq(
12067 &health_paths(&low),
12068 &vec![".", "assets", "src", "Cargo.toml"],
12069 "low purpose review scope",
12070 )?;
12071 require_eq(
12072 &low.unfiltered_total,
12073 &6,
12074 "agent-review findings are counted once in unfiltered total",
12075 )?;
12076
12077 let asset_scope = store.unresolved_health_findings_page(
12078 &[],
12079 &HealthQuery {
12080 scope: HealthScope::purpose_with_assets(),
12081 ..low_query()
12082 },
12083 )?;
12084 require_eq(
12085 &health_paths(&asset_scope),
12086 &vec![".", "assets", "src", "Cargo.toml", "assets/logo.svg"],
12087 "asset purpose review scope",
12088 )?;
12089
12090 let medium = store.unresolved_health_findings_page(
12091 &[],
12092 &HealthQuery {
12093 scope: HealthScope::purpose_with_source_files(),
12094 ..low_query()
12095 },
12096 )?;
12097 require_eq(
12098 &health_paths(&medium),
12099 &vec![".", "assets", "src", "Cargo.toml", "src/detail.rs"],
12100 "medium purpose review scope",
12101 )?;
12102
12103 let strict = store.unresolved_health_findings_page(
12104 &[],
12105 &HealthQuery {
12106 scope: HealthScope::purpose_strict(),
12107 ..low_query()
12108 },
12109 )?;
12110 require_eq(
12111 &health_paths(&strict),
12112 &vec![
12113 ".",
12114 "assets",
12115 "src",
12116 "Cargo.toml",
12117 "assets/logo.svg",
12118 "src/detail.rs",
12119 ],
12120 "strict purpose review scope",
12121 )?;
12122
12123 let all = store.unresolved_health_findings_page(
12124 &[],
12125 &HealthQuery {
12126 scope: HealthScope::all(),
12127 ..low_query()
12128 },
12129 )?;
12130 require_eq(
12131 &health_paths(&all),
12132 &health_paths(&strict),
12133 "all health scope should include every purpose review candidate",
12134 )?;
12135 Ok(())
12136 }
12137
12138 #[test]
12139 fn replace_scan_preserves_curated_purposes_and_reconciles_changed_paths()
12140 -> Result<(), Box<dyn Error>> {
12141 let mut store = AtlasStore::in_memory()?;
12142 store.replace_scan(&[
12143 test_folder_node("."),
12144 test_folder_node("src"),
12145 test_file_node("src/main.rs", "hash-a"),
12146 ])?;
12147 store.set_purpose(".", "Agent-reviewed repository root", PurposeSource::Agent)?;
12148 store.set_purpose(
12149 "src",
12150 "Agent-reviewed Rust source folder",
12151 PurposeSource::Agent,
12152 )?;
12153 store.set_purpose(
12154 "src/main.rs",
12155 "Agent-reviewed Rust entry point",
12156 PurposeSource::Agent,
12157 )?;
12158
12159 store.replace_scan(&[
12160 test_folder_node("."),
12161 test_folder_node("src"),
12162 test_file_node("src/main.rs", "hash-a"),
12163 test_file_node("src/new.rs", "hash-new"),
12164 ])?;
12165 let nodes = store.load_nodes_by_paths(&[
12166 ".".to_string(),
12167 "src".to_string(),
12168 "src/main.rs".to_string(),
12169 "src/new.rs".to_string(),
12170 ])?;
12171 let by_path = nodes
12172 .iter()
12173 .map(|node| (node.node.path.as_str(), node))
12174 .collect::<HashMap<_, _>>();
12175 require_eq(
12176 &by_path["src/main.rs"].purpose.purpose,
12177 &Some("Agent-reviewed Rust entry point".to_string()),
12178 "unchanged file purpose preserved",
12179 )?;
12180 require_eq(
12181 &by_path["src/main.rs"].purpose.status,
12182 &PurposeStatus::Approved,
12183 "unchanged file purpose stays approved",
12184 )?;
12185 require_eq(
12186 &by_path["src/new.rs"].purpose.status,
12187 &PurposeStatus::Missing,
12188 "new file starts missing",
12189 )?;
12190
12191 store.replace_scan(&[
12192 test_folder_node("."),
12193 test_folder_node("src"),
12194 test_file_node("src/main.rs", "hash-b"),
12195 test_file_node("src/new.rs", "hash-new"),
12196 ])?;
12197 let changed = store
12198 .load_nodes_by_paths(&["src/main.rs".to_string()])?
12199 .pop()
12200 .ok_or_else(|| io::Error::other("changed node missing"))?;
12201 require_eq(
12202 &changed.purpose.purpose,
12203 &Some("Agent-reviewed Rust entry point".to_string()),
12204 "changed file purpose text preserved",
12205 )?;
12206 require_eq(
12207 &changed.purpose.status,
12208 &PurposeStatus::Approved,
12209 "changed file purpose stays approved",
12210 )?;
12211
12212 store.replace_scan(&[test_folder_node("."), test_folder_node("src")])?;
12213 let removed = store.load_nodes_by_paths(&["src/main.rs".to_string()])?;
12214 require_eq(&removed.is_empty(), &true, "removed file is inactive")?;
12215
12216 let dormant = store.connection.query_row(
12217 "SELECT n.exists_now, p.purpose, p.status
12218 FROM nodes AS n
12219 JOIN purposes AS p ON p.node_id = n.id
12220 WHERE n.path = 'src/main.rs'",
12221 [],
12222 |row| {
12223 Ok((
12224 row.get::<_, i64>(0)?,
12225 row.get::<_, Option<String>>(1)?,
12226 row.get::<_, String>(2)?,
12227 ))
12228 },
12229 )?;
12230 require_eq(
12231 &dormant,
12232 &(
12233 0,
12234 Some("Agent-reviewed Rust entry point".to_string()),
12235 PurposeStatus::Approved.as_str().to_string(),
12236 ),
12237 "removed file keeps a dormant approved purpose",
12238 )?;
12239
12240 store.replace_scan(&[
12241 test_folder_node("."),
12242 test_folder_node("src"),
12243 test_file_node("src/renamed.rs", "hash-b"),
12244 ])?;
12245 let renamed = store
12246 .load_node_by_path("src/renamed.rs")?
12247 .ok_or_else(|| io::Error::other("renamed path missing"))?;
12248 require_eq(
12249 &renamed.purpose.status,
12250 &PurposeStatus::Missing,
12251 "rename does not transfer approval",
12252 )?;
12253
12254 store.replace_scan(&[
12255 test_folder_node("."),
12256 test_folder_node("src"),
12257 test_file_node("src/main.rs", "hash-c"),
12258 ])?;
12259 let reactivated = store
12260 .load_node_by_path("src/main.rs")?
12261 .ok_or_else(|| io::Error::other("reactivated path missing"))?;
12262 require_eq(
12263 &reactivated.purpose.purpose,
12264 &Some("Agent-reviewed Rust entry point".to_string()),
12265 "exact-path reactivation restores the dormant purpose",
12266 )?;
12267 require_eq(
12268 &reactivated.purpose.status,
12269 &PurposeStatus::Approved,
12270 "exact-path reactivation restores approval",
12271 )?;
12272 Ok(())
12273 }
12274
12275 #[test]
12276 fn file_token_estimates_are_visited_without_loading_nodes() -> Result<(), Box<dyn Error>> {
12277 let mut store = AtlasStore::in_memory()?;
12278 store.replace_scan(&[
12279 test_file_node("src/a.rs", "hash-a"),
12280 test_file_node("tests/b.rs", "hash-b"),
12281 ])?;
12282
12283 let mut visited = Vec::new();
12284 store.visit_file_token_estimates(Some("src"), |path, size_bytes| {
12285 visited.push((path, size_bytes));
12286 Ok(true)
12287 })?;
12288 require_eq(
12289 &visited,
12290 &vec![("src/a.rs".to_string(), Some(12))],
12291 "folder-scoped token estimate rows",
12292 )?;
12293 Ok(())
12294 }
12295
12296 #[test]
12297 fn indexed_file_text_replaces_and_clears_stale_rows() -> Result<(), Box<dyn Error>> {
12298 let mut store = AtlasStore::in_memory()?;
12299 store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
12300 store.replace_file_texts_for_paths(
12301 &["src/main.rs".to_string()],
12302 &[IndexedFileText {
12303 path: "src/main.rs".to_string(),
12304 content_hash: Some("hash-a".to_string()),
12305 byte_count: "needle old\n".len(),
12306 line_count: 1,
12307 content: "needle old\n".to_string(),
12308 }],
12309 )?;
12310 let texts = store.load_file_texts_for_search(Some("needle"), true)?;
12311 require_eq(&texts.len(), &1, "indexed text row count")?;
12312 require_eq(
12313 &store.load_file_text("src/main.rs")?,
12314 &texts.first().cloned(),
12315 "initial persisted-text read",
12316 )?;
12317
12318 store.replace_file_texts_for_paths(&["src/main.rs".to_string()], &[])?;
12319 let missing = store.load_file_text("src/main.rs")?;
12320 require_eq(&missing.is_none(), &true, "cleared stale indexed text")?;
12321 Ok(())
12322 }
12323
12324 #[test]
12325 fn indexed_text_candidates_preserve_unicode_folding_and_early_stop()
12326 -> Result<(), Box<dyn Error>> {
12327 let mut store = AtlasStore::in_memory()?;
12328 let paths = ["src/a.rs".to_string(), "src/b.rs".to_string()];
12329 store.replace_scan(&[
12330 test_file_node(&paths[0], "hash-a"),
12331 test_file_node(&paths[1], "hash-b"),
12332 ])?;
12333 let content = "für \u{212a}elvin ſource\n";
12334 let texts = paths
12335 .iter()
12336 .map(|path| IndexedFileText {
12337 path: path.clone(),
12338 content_hash: None,
12339 byte_count: content.len(),
12340 line_count: 1,
12341 content: content.to_string(),
12342 })
12343 .collect::<Vec<_>>();
12344 store.replace_file_texts_for_paths(&paths, &texts)?;
12345 for pattern in ["FÜR", "kelvin", "source"] {
12346 let mut visited = Vec::new();
12347 store.visit_file_texts_for_search(Some(pattern), false, |text| {
12348 visited.push(text.path);
12349 Ok(false)
12350 })?;
12351 require_eq(
12352 &visited,
12353 &vec![paths[0].clone()],
12354 "Unicode candidate early stop",
12355 )?;
12356 require_eq(
12357 &store.load_file_texts_for_search(Some(pattern), true)?.len(),
12358 &0,
12359 "case-sensitive candidate filter",
12360 )?;
12361 }
12362 require_eq(
12363 &store.load_file_texts_for_search(Some("für"), true)?.len(),
12364 &2,
12365 "exact substring candidates",
12366 )?;
12367 Ok(())
12368 }
12369
12370 #[test]
12371 fn indexed_file_text_search_can_stop_without_collecting_all_rows() -> Result<(), Box<dyn Error>>
12372 {
12373 let mut store = AtlasStore::in_memory()?;
12374 store.replace_scan(&[
12375 test_file_node("src/a.rs", "hash-a"),
12376 test_file_node("src/b.rs", "hash-b"),
12377 ])?;
12378 store.replace_file_texts_for_paths(
12379 &["src/a.rs".to_string(), "src/b.rs".to_string()],
12380 &[
12381 IndexedFileText {
12382 path: "src/a.rs".to_string(),
12383 content_hash: Some("hash-a".to_string()),
12384 byte_count: "needle first\n".len(),
12385 line_count: 1,
12386 content: "needle first\n".to_string(),
12387 },
12388 IndexedFileText {
12389 path: "src/b.rs".to_string(),
12390 content_hash: Some("hash-b".to_string()),
12391 byte_count: "needle second\n".len(),
12392 line_count: 1,
12393 content: "needle second\n".to_string(),
12394 },
12395 ],
12396 )?;
12397
12398 let mut visited = Vec::new();
12399 store.visit_file_texts_for_search(Some("needle"), true, |text| {
12400 visited.push(text.path);
12401 Ok(false)
12402 })?;
12403 require_eq(&visited, &vec!["src/a.rs".to_string()], "early stop rows")?;
12404 Ok(())
12405 }
12406
12407 #[test]
12408 fn file_text_fts_candidates_are_bounded_scoped_safe_and_metadata_only()
12409 -> Result<(), Box<dyn Error>> {
12410 let mut store = AtlasStore::in_memory()?;
12411 store.replace_scan(&[
12412 test_file_node("src/a.rs", "hash-a"),
12413 test_file_node("src/b.rs", "hash-b"),
12414 test_file_node("tests/c.rs", "hash-c"),
12415 ])?;
12416 store.replace_file_texts_for_paths(
12417 &[
12418 "src/a.rs".to_string(),
12419 "src/b.rs".to_string(),
12420 "tests/c.rs".to_string(),
12421 ],
12422 &[
12423 IndexedFileText {
12424 path: "src/a.rs".to_string(),
12425 content_hash: Some("hash-a".to_string()),
12426 byte_count: 13,
12427 line_count: 1,
12428 content: "needle alpha\n".to_string(),
12429 },
12430 IndexedFileText {
12431 path: "src/b.rs".to_string(),
12432 content_hash: Some("hash-b".to_string()),
12433 byte_count: 19,
12434 line_count: 1,
12435 content: "prefixneedlesuffix\n".to_string(),
12436 },
12437 IndexedFileText {
12438 path: "tests/c.rs".to_string(),
12439 content_hash: Some("hash-c".to_string()),
12440 byte_count: 32,
12441 line_count: 1,
12442 content: "needle gamma AND NOT NEAR terms\n".to_string(),
12443 },
12444 ],
12445 )?;
12446
12447 let bounded = store.query_file_text_fts_candidates(
12448 &FileTextFtsQuery {
12449 literal_token: "needle",
12450 path_prefix: None,
12451 limit: 1,
12452 },
12453 None,
12454 )?;
12455 require_eq(&bounded.candidates.len(), &1, "bounded FTS row count")?;
12456 require_eq(&bounded.overflow, &true, "bounded FTS overflow")?;
12457 require(
12458 bounded.candidates[0].bm25.is_finite(),
12459 "FTS rank was non-finite",
12460 )?;
12461 let zero_limit = store.query_file_text_fts_candidates(
12462 &FileTextFtsQuery {
12463 literal_token: "needle",
12464 path_prefix: None,
12465 limit: 0,
12466 },
12467 None,
12468 )?;
12469 require_eq(
12470 &zero_limit,
12471 &FileTextFtsPage {
12472 candidates: Vec::new(),
12473 overflow: true,
12474 },
12475 "zero-limit FTS overflow",
12476 )?;
12477
12478 let scoped = store.query_file_text_fts_candidates(
12479 &FileTextFtsQuery {
12480 literal_token: "needle",
12481 path_prefix: Some("src/"),
12482 limit: 10,
12483 },
12484 None,
12485 )?;
12486 require_eq(
12487 &scoped
12488 .candidates
12489 .iter()
12490 .map(|candidate| candidate.path.as_str())
12491 .collect::<Vec<_>>(),
12492 &vec!["src/a.rs", "src/b.rs"],
12493 "path-scoped FTS rank order",
12494 )?;
12495 let exact_path = store.query_file_text_fts_candidates(
12496 &FileTextFtsQuery {
12497 literal_token: "needle",
12498 path_prefix: Some("src/a.rs"),
12499 limit: 10,
12500 },
12501 None,
12502 )?;
12503 require_eq(
12504 &exact_path.candidates[0].path,
12505 &"src/a.rs".to_string(),
12506 "exact-path FTS scope",
12507 )?;
12508 for reserved_token in ["AND", "NOT", "NEAR"] {
12509 let reserved = store.query_file_text_fts_candidates(
12510 &FileTextFtsQuery {
12511 literal_token: reserved_token,
12512 path_prefix: Some("tests/c.rs"),
12513 limit: 10,
12514 },
12515 None,
12516 )?;
12517 require_eq(
12518 &reserved.candidates.len(),
12519 &1,
12520 "quoted FTS reserved-token candidate",
12521 )?;
12522 }
12523 let mut plan_statement = store.connection.prepare(
12524 "EXPLAIN QUERY PLAN
12525 SELECT f.path, f.content_hash, f.byte_count, f.line_count, bm25(file_text_fts)
12526 FROM file_text_fts
12527 JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
12528 WHERE file_text_fts MATCH ?1
12529 ORDER BY bm25(file_text_fts), f.path
12530 LIMIT ?2",
12531 )?;
12532 let plan_details = plan_statement
12533 .query_map(params!["needle", 11], |row| row.get::<_, String>(3))?
12534 .collect::<Result<Vec<_>, _>>()?;
12535 require(
12536 plan_details
12537 .iter()
12538 .any(|detail| detail.contains("VIRTUAL TABLE INDEX")),
12539 "FTS candidate query did not use the virtual-table index",
12540 )?;
12541 require(
12542 plan_details
12543 .iter()
12544 .any(|detail| detail.contains("INTEGER PRIMARY KEY")),
12545 "FTS candidate query did not hydrate metadata by file_texts rowid",
12546 )?;
12547
12548 for unsafe_token in ["ab", "foo_bar", "needle\" OR content:*", "néedle"] {
12549 let result = store.query_file_text_fts_candidates(
12550 &FileTextFtsQuery {
12551 literal_token: unsafe_token,
12552 path_prefix: None,
12553 limit: 10,
12554 },
12555 None,
12556 );
12557 require(
12558 matches!(result, Err(DbError::FileTextFtsTokenUnsafe { .. })),
12559 "unsafe FTS token was accepted",
12560 )?;
12561 }
12562 let over_cap = store.query_file_text_fts_candidates(
12563 &FileTextFtsQuery {
12564 literal_token: "needle",
12565 path_prefix: None,
12566 limit: MAX_FILE_TEXT_FTS_CANDIDATES + 1,
12567 },
12568 None,
12569 );
12570 require(
12571 matches!(over_cap, Err(DbError::FileTextFtsCandidateLimit { .. })),
12572 "over-cap FTS request was accepted",
12573 )?;
12574
12575 store.connection.execute(
12576 "UPDATE file_texts SET content = x'80' WHERE path = 'src/a.rs'",
12577 [],
12578 )?;
12579 let metadata_only = store.query_file_text_fts_candidates(
12580 &FileTextFtsQuery {
12581 literal_token: "needle",
12582 path_prefix: Some("src/a.rs"),
12583 limit: 1,
12584 },
12585 None,
12586 )?;
12587 require_eq(
12588 &metadata_only.candidates[0].byte_count,
12589 &13,
12590 "metadata-only candidate byte count",
12591 )?;
12592 require(
12593 store.load_file_text("src/a.rs").is_err(),
12594 "content corruption fixture did not prove candidate pre-hydration",
12595 )?;
12596 Ok(())
12597 }
12598
12599 #[test]
12600 fn file_text_fts_mutations_are_atomic_and_state_reports_identity_drift()
12601 -> Result<(), Box<dyn Error>> {
12602 let mut store = AtlasStore::in_memory()?;
12603 store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
12604 let invalid_metadata = IndexedFileText {
12605 path: "src/main.rs".to_string(),
12606 content_hash: Some("hash-invalid".to_string()),
12607 byte_count: 0,
12608 line_count: 1,
12609 content: "not empty\n".to_string(),
12610 };
12611 require(
12612 matches!(
12613 store.replace_file_texts_for_paths(
12614 std::slice::from_ref(&invalid_metadata.path),
12615 std::slice::from_ref(&invalid_metadata),
12616 ),
12617 Err(DbError::FileTextMetadataMismatch {
12618 field: "byte_count",
12619 ..
12620 })
12621 ),
12622 "invalid file-text byte metadata was accepted",
12623 )?;
12624 require_eq(
12625 &store.load_file_text(&invalid_metadata.path)?,
12626 &None,
12627 "invalid metadata write rollback",
12628 )?;
12629 let invalid_line_count = IndexedFileText {
12630 byte_count: invalid_metadata.content.len(),
12631 line_count: 0,
12632 ..invalid_metadata.clone()
12633 };
12634 require(
12635 matches!(
12636 store.replace_file_texts_for_paths(
12637 std::slice::from_ref(&invalid_line_count.path),
12638 std::slice::from_ref(&invalid_line_count),
12639 ),
12640 Err(DbError::FileTextMetadataMismatch {
12641 field: "line_count",
12642 ..
12643 })
12644 ),
12645 "invalid file-text line metadata was accepted",
12646 )?;
12647 let original = IndexedFileText {
12648 path: "src/main.rs".to_string(),
12649 content_hash: Some("hash-a".to_string()),
12650 byte_count: "needle old\n".len(),
12651 line_count: 1,
12652 content: "needle old\n".to_string(),
12653 };
12654 store.replace_file_texts_for_paths(
12655 std::slice::from_ref(&original.path),
12656 std::slice::from_ref(&original),
12657 )?;
12658 require_eq(
12659 &store.file_text_fts_state()?,
12660 &FileTextFtsState {
12661 source_rows: 1,
12662 indexed_rows: 1,
12663 synchronized: true,
12664 },
12665 "initial FTS synchronization state",
12666 )?;
12667
12668 store.connection.execute_batch(
12669 "CREATE TRIGGER fail_file_text_insert
12670 BEFORE INSERT ON file_texts
12671 BEGIN
12672 SELECT RAISE(ABORT, 'injected file-text failure');
12673 END;",
12674 )?;
12675 let replacement = IndexedFileText {
12676 path: original.path.clone(),
12677 content_hash: Some("hash-b".to_string()),
12678 byte_count: 11,
12679 line_count: 1,
12680 content: "beacon new\n".to_string(),
12681 };
12682 require(
12683 store
12684 .replace_file_texts_for_paths(
12685 std::slice::from_ref(&replacement.path),
12686 std::slice::from_ref(&replacement),
12687 )
12688 .is_err(),
12689 "fault-injected replacement unexpectedly committed",
12690 )?;
12691 store
12692 .connection
12693 .execute_batch("DROP TRIGGER fail_file_text_insert")?;
12694 require_eq(
12695 &store.load_file_text(&original.path)?,
12696 &Some(original.clone()),
12697 "rolled-back authoritative text",
12698 )?;
12699 require_eq(
12700 &store
12701 .query_file_text_fts_candidates(
12702 &FileTextFtsQuery {
12703 literal_token: "needle",
12704 path_prefix: None,
12705 limit: 10,
12706 },
12707 None,
12708 )?
12709 .candidates
12710 .len(),
12711 &1,
12712 "rolled-back FTS document",
12713 )?;
12714 require_eq(
12715 &store.file_text_fts_state()?.synchronized,
12716 &true,
12717 "rollback FTS synchronization",
12718 )?;
12719
12720 store.replace_file_texts_for_paths(
12721 std::slice::from_ref(&replacement.path),
12722 std::slice::from_ref(&replacement),
12723 )?;
12724 require_eq(
12725 &store.load_file_text(&replacement.path)?,
12726 &Some(replacement.clone()),
12727 "replacement read after repeated hydration",
12728 )?;
12729 require_eq(
12730 &store
12731 .query_file_text_fts_candidates(
12732 &FileTextFtsQuery {
12733 literal_token: "needle",
12734 path_prefix: None,
12735 limit: 10,
12736 },
12737 None,
12738 )?
12739 .candidates
12740 .len(),
12741 &0,
12742 "replaced token removed from FTS",
12743 )?;
12744 require_eq(
12745 &store
12746 .query_file_text_fts_candidates(
12747 &FileTextFtsQuery {
12748 literal_token: "beacon",
12749 path_prefix: None,
12750 limit: 10,
12751 },
12752 None,
12753 )?
12754 .candidates
12755 .len(),
12756 &1,
12757 "replacement token added to FTS",
12758 )?;
12759
12760 store.connection.execute(
12761 "INSERT INTO file_text_fts(file_text_fts, rowid, content)
12762 SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
12763 [&replacement.path],
12764 )?;
12765 require_eq(
12766 &store.file_text_fts_state()?,
12767 &FileTextFtsState {
12768 source_rows: 1,
12769 indexed_rows: 0,
12770 synchronized: false,
12771 },
12772 "desynchronized FTS identity state",
12773 )?;
12774 store.connection.execute(
12775 "INSERT INTO file_text_fts(file_text_fts) VALUES('rebuild')",
12776 [],
12777 )?;
12778 let (source_revision, projection_revision) =
12779 load_file_text_fts_revisions(&store.connection)?.ok_or_else(|| {
12780 io::Error::other("FTS revisions disappeared after synchronized writes")
12781 })?;
12782 require_eq(
12783 &source_revision,
12784 &projection_revision,
12785 "synchronized FTS revisions",
12786 )?;
12787 set_metadata(
12788 &store.connection,
12789 FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
12790 &projection_revision.saturating_sub(1).to_string(),
12791 )?;
12792 require_eq(
12793 &store.file_text_fts_ready()?,
12794 &false,
12795 "incomplete FTS revision readiness",
12796 )?;
12797 set_metadata(
12798 &store.connection,
12799 FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
12800 &source_revision.to_string(),
12801 )?;
12802 require_eq(
12803 &store.file_text_fts_ready()?,
12804 &true,
12805 "restored FTS revision readiness",
12806 )?;
12807 store.mark_paths_absent(&["src".to_string()])?;
12808 require_eq(
12809 &store.file_text_fts_state()?,
12810 &FileTextFtsState {
12811 source_rows: 0,
12812 indexed_rows: 0,
12813 synchronized: true,
12814 },
12815 "absent-path FTS synchronization",
12816 )?;
12817
12818 store.replace_scan(&[test_file_node("src/main.rs", "hash-c")])?;
12819 let rescanned = IndexedFileText {
12820 path: "src/main.rs".to_string(),
12821 content_hash: Some("hash-c".to_string()),
12822 byte_count: 13,
12823 line_count: 1,
12824 content: "rescan token\n".to_string(),
12825 };
12826 store.replace_file_texts_for_paths(
12827 std::slice::from_ref(&rescanned.path),
12828 std::slice::from_ref(&rescanned),
12829 )?;
12830 store.replace_scan(&[])?;
12831 require_eq(
12832 &store.file_text_fts_state()?,
12833 &FileTextFtsState {
12834 source_rows: 0,
12835 indexed_rows: 0,
12836 synchronized: true,
12837 },
12838 "full-scan deletion FTS synchronization",
12839 )?;
12840 Ok(())
12841 }
12842
12843 #[cfg(windows)]
12844 #[test]
12845 fn file_text_fts_migration_backfills_existing_text_and_survives_reopen()
12846 -> Result<(), Box<dyn Error>> {
12847 let temp = tempfile::tempdir()?;
12848 let root = temp.path().join("project");
12849 fs::create_dir(&root)?;
12850 let database = temp.path().join("atlas.db");
12851 let mut store = AtlasStore::open_for_project(&database, &root)?;
12852 store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
12853 store.replace_file_texts_for_paths(
12854 &["src/main.rs".to_string()],
12855 &[IndexedFileText {
12856 path: "src/main.rs".to_string(),
12857 content_hash: Some("hash-a".to_string()),
12858 byte_count: 17,
12859 line_count: 1,
12860 content: "migration needle\n".to_string(),
12861 }],
12862 )?;
12863 store.connection.execute_batch(
12864 "DROP TABLE file_content_classifications;
12865 DROP INDEX idx_nodes_path_kind;
12866 DROP TABLE usage_instance_worktree_origins;
12867 DROP TABLE worktree_usage_aggregates;
12868 DROP TABLE worktree_registrations;
12869 DROP TABLE usage_aggregate_revisions;
12870 DROP TABLE project_root_identity;
12871 DROP TABLE IF EXISTS graph_identity_rejections;",
12872 )?;
12873 crate::schema::recreate_disposable_graph_projection(&store.connection, false)?;
12874 crate::schema::recreate_pre_selector_symbol_storage_for_test(&store.connection)?;
12875 store.connection.execute_batch(
12876 "DROP INDEX idx_symbol_import_alias_lookup;
12877 DROP TABLE file_text_fts;",
12878 )?;
12879 store.connection.execute(
12880 "DELETE FROM metadata WHERE key IN (?1, ?2)",
12881 params![
12882 FILE_TEXT_FTS_SOURCE_REVISION_KEY,
12883 FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
12884 ],
12885 )?;
12886 set_metadata(
12887 &store.connection,
12888 SCHEMA_VERSION_KEY,
12889 &crate::schema::COVERAGE_DISCOVERY_SCHEMA_VERSION.to_string(),
12890 )?;
12891 drop(store);
12892
12893 let migrated = AtlasStore::open_for_project(&database, &root)?;
12894 require_eq(
12895 &migrated.file_text_fts_state()?,
12896 &FileTextFtsState {
12897 source_rows: 1,
12898 indexed_rows: 1,
12899 synchronized: true,
12900 },
12901 "migrated FTS synchronization state",
12902 )?;
12903 require_eq(
12904 &migrated
12905 .query_file_text_fts_candidates(
12906 &FileTextFtsQuery {
12907 literal_token: "needle",
12908 path_prefix: None,
12909 limit: 10,
12910 },
12911 None,
12912 )?
12913 .candidates[0]
12914 .path,
12915 &"src/main.rs".to_string(),
12916 "migrated FTS backfill candidate",
12917 )?;
12918 drop(migrated);
12919
12920 let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
12921 require_eq(
12922 &reopened.file_text_fts_state()?.synchronized,
12923 &true,
12924 "reopened FTS synchronization",
12925 )?;
12926 require_eq(
12927 &reopened
12928 .query_file_text_fts_candidates(
12929 &FileTextFtsQuery {
12930 literal_token: "migration",
12931 path_prefix: Some("src"),
12932 limit: 1,
12933 },
12934 None,
12935 )?
12936 .candidates
12937 .len(),
12938 &1,
12939 "reopened FTS candidate count",
12940 )?;
12941 Ok(())
12942 }
12943
12944 #[test]
12945 fn sqlite_read_progress_interrupts_active_repository_traversal_and_clears_handler()
12946 -> Result<(), Box<dyn Error>> {
12947 let store = AtlasStore::in_memory()?;
12948 let control = IndexWorkControl::with_deadline(
12949 projectatlas_core::IndexCancellation::new(),
12950 Instant::now() + std::time::Duration::from_millis(50),
12951 );
12952 let interrupted = with_sqlite_read_progress(
12953 &store.connection,
12954 Some(&control),
12955 IndexWorkStage::RepositoryTraversal,
12956 || {
12957 store
12958 .connection
12959 .query_row(
12960 "WITH RECURSIVE numbers(value) AS (
12961 VALUES(1)
12962 UNION ALL
12963 SELECT value + 1 FROM numbers WHERE value < 100000000
12964 )
12965 SELECT SUM(value) FROM numbers",
12966 [],
12967 |row| row.get::<_, i64>(0),
12968 )
12969 .map_err(DbError::from)
12970 },
12971 );
12972 require(
12973 matches!(
12974 interrupted,
12975 Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
12976 stage: IndexWorkStage::RepositoryTraversal
12977 }))
12978 ),
12979 "active SQLite traversal was not interrupted with its typed deadline",
12980 )?;
12981 require_eq(
12982 &store
12983 .connection
12984 .query_row("SELECT 1", [], |row| row.get::<_, i64>(0))?,
12985 &1,
12986 "cleared repository traversal progress handler",
12987 )?;
12988 store
12989 .connection
12990 .execute("CREATE TEMP TABLE follow_up(value INTEGER NOT NULL)", [])?;
12991 store
12992 .connection
12993 .execute("INSERT INTO follow_up(value) VALUES(1)", [])?;
12994 Ok(())
12995 }
12996
12997 #[cfg(feature = "sqlite-progress-test-observer")]
12998 #[test]
12999 fn sqlite_read_progress_propagates_cancellation_during_an_active_batch()
13000 -> Result<(), Box<dyn Error>> {
13001 use crate::sqlite_progress_test_observer::{
13002 SqliteReadProgressEvent, observe_sqlite_read_progress,
13003 };
13004 use std::cell::Cell;
13005 use std::rc::Rc;
13006
13007 let store = AtlasStore::in_memory()?;
13008 let cancellation = projectatlas_core::IndexCancellation::new();
13009 let control = IndexWorkControl::new(cancellation.clone(), None);
13010 let callback_seen = Rc::new(Cell::new(false));
13011 let cancelled = observe_sqlite_read_progress(
13012 {
13013 let callback_seen = Rc::clone(&callback_seen);
13014 move |event| {
13015 if matches!(
13016 event,
13017 SqliteReadProgressEvent::CallbackEntered {
13018 stage: IndexWorkStage::RepositoryTraversal
13019 }
13020 ) {
13021 callback_seen.set(true);
13022 cancellation.cancel();
13023 }
13024 }
13025 },
13026 || {
13027 with_sqlite_read_progress(
13028 &store.connection,
13029 Some(&control),
13030 IndexWorkStage::RepositoryTraversal,
13031 || {
13032 store
13033 .connection
13034 .query_row(
13035 "WITH RECURSIVE numbers(value) AS (
13036 VALUES(1)
13037 UNION ALL
13038 SELECT value + 1 FROM numbers WHERE value < 100000000
13039 )
13040 SELECT SUM(value) FROM numbers",
13041 [],
13042 |row| row.get::<_, i64>(0),
13043 )
13044 .map_err(DbError::from)
13045 },
13046 )
13047 },
13048 );
13049 require(
13050 callback_seen.get()
13051 && matches!(
13052 cancelled,
13053 Err(DbError::IndexWork(IndexWorkFailure::Cancelled {
13054 stage: IndexWorkStage::RepositoryTraversal
13055 }))
13056 ),
13057 "active SQLite batch did not propagate typed cancellation",
13058 )?;
13059 store.connection.execute(
13060 "CREATE TEMP TABLE cancellation_follow_up(value INTEGER)",
13061 [],
13062 )?;
13063 Ok(())
13064 }
13065
13066 #[test]
13067 fn fallback_admission_precedes_content_decode_and_clears_progress_handlers()
13068 -> Result<(), Box<dyn Error>> {
13069 let mut store = AtlasStore::in_memory()?;
13070 store.replace_scan(&[test_file_node("src/good.rs", "hash-good")])?;
13071 store.replace_file_texts_for_paths(
13072 &["src/good.rs".to_string()],
13073 &[IndexedFileText {
13074 path: "src/good.rs".to_string(),
13075 content_hash: Some("hash-good".to_string()),
13076 byte_count: 12,
13077 line_count: 1,
13078 content: "needle good\n".to_string(),
13079 }],
13080 )?;
13081 store.connection.execute_batch(
13082 "INSERT INTO nodes(path, kind, exists_now) VALUES('src/bad.rs', 'file', 1);
13083 INSERT INTO file_content_classifications(path, classification)
13084 VALUES('src/bad.rs', 'source');
13085 INSERT INTO file_texts(path, content_hash, byte_count, line_count, content)
13086 VALUES('src/bad.rs', 'hash-bad', 1, 1, x'80');",
13087 )?;
13088
13089 let mut admitted = Vec::new();
13090 let mut visited = Vec::new();
13091 store.visit_file_texts_for_fallback(
13092 Some("src"),
13093 None,
13094 |metadata| {
13095 admitted.push((metadata.path.clone(), metadata.classification));
13096 Ok(if metadata.path == "src/bad.rs" {
13097 FileTextAdmission::Skip
13098 } else {
13099 FileTextAdmission::Read
13100 })
13101 },
13102 |text| {
13103 visited.push(text.path);
13104 Ok(true)
13105 },
13106 )?;
13107 require_eq(
13108 &admitted,
13109 &vec![
13110 ("src/bad.rs".to_string(), ContentClassification::Source),
13111 ("src/good.rs".to_string(), ContentClassification::Opaque),
13112 ],
13113 "fallback metadata admission rows",
13114 )?;
13115 require_eq(
13116 &visited,
13117 &vec!["src/good.rs".to_string()],
13118 "fallback decoded rows",
13119 )?;
13120 let mut scoped_plan = store.connection.prepare(&format!(
13121 "EXPLAIN QUERY PLAN {FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL}"
13122 ))?;
13123 let scoped_plan_details = scoped_plan
13124 .query_map(params!["src", "src/", "src0"], |row| {
13125 row.get::<_, String>(3)
13126 })?
13127 .collect::<Result<Vec<_>, _>>()?;
13128 require(
13129 scoped_plan_details
13130 .iter()
13131 .any(|detail| detail.contains("sqlite_autoindex_file_texts_1"))
13132 && scoped_plan_details.iter().any(|detail| {
13133 detail.contains("sqlite_autoindex_file_content_classifications_1")
13134 })
13135 && scoped_plan_details.iter().all(|detail| {
13136 !detail.contains("SCAN text") && !detail.contains("SCAN classification")
13137 }),
13138 "path-scoped fallback did not use bounded binary index ranges",
13139 )?;
13140 let mut scoped_opcodes = store
13141 .connection
13142 .prepare(&format!("EXPLAIN {FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL}"))?;
13143 let scoped_opcode_rows = scoped_opcodes
13144 .query_map(params!["src", "src/", "src0"], |row| {
13145 Ok((
13146 row.get::<_, String>(1)?,
13147 row.get::<_, i64>(2)?,
13148 row.get::<_, i64>(3)?,
13149 row.get::<_, Option<String>>(5)?,
13150 ))
13151 })?
13152 .collect::<Result<Vec<_>, _>>()?;
13153 let file_text_table_cursors = scoped_opcode_rows
13154 .iter()
13155 .filter(|(opcode, _, _, shape)| opcode == "DeferredSeek" && shape.is_some())
13156 .map(|(_, _, table_cursor, _)| *table_cursor)
13157 .collect::<Vec<_>>();
13158 require(
13159 !file_text_table_cursors.is_empty()
13160 && scoped_opcode_rows
13161 .iter()
13162 .all(|(opcode, cursor, column, _)| {
13163 opcode != "Column"
13164 || !file_text_table_cursors.contains(cursor)
13165 || *column != 4
13166 }),
13167 &format!(
13168 "fallback metadata cursor read source content before admission: {scoped_opcode_rows:?}"
13169 ),
13170 )?;
13171 require(
13172 store
13173 .visit_file_texts_for_fallback(
13174 Some("src/bad.rs"),
13175 None,
13176 |_| Ok(FileTextAdmission::Read),
13177 |_| Ok(true),
13178 )
13179 .is_err(),
13180 "invalid source content decoded without error",
13181 )?;
13182
13183 let success_cancellation = projectatlas_core::IndexCancellation::new();
13184 let success_control = IndexWorkControl::new(success_cancellation.clone(), None);
13185 store.query_file_text_fts_candidates(
13186 &FileTextFtsQuery {
13187 literal_token: "needle",
13188 path_prefix: None,
13189 limit: 10,
13190 },
13191 Some(&success_control),
13192 )?;
13193 success_cancellation.cancel();
13194 let recursive_sum = store.connection.query_row(
13195 "WITH RECURSIVE numbers(value) AS (
13196 VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 5000
13197 ) SELECT SUM(value) FROM numbers",
13198 [],
13199 |row| row.get::<_, i64>(0),
13200 )?;
13201 require_eq(
13202 &recursive_sum,
13203 &12_502_500,
13204 "cleared success progress handler",
13205 )?;
13206
13207 let cancellation = projectatlas_core::IndexCancellation::new();
13208 let control = IndexWorkControl::new(cancellation.clone(), None);
13209 let mut rows_seen = 0;
13210 let cancelled = store.visit_file_texts_for_fallback(
13211 Some("src"),
13212 Some(&control),
13213 |_| {
13214 rows_seen += 1;
13215 cancellation.cancel();
13216 Ok(FileTextAdmission::Skip)
13217 },
13218 |_| Ok(true),
13219 );
13220 require_eq(&rows_seen, &1, "rows before fallback cancellation")?;
13221 require(
13222 matches!(
13223 cancelled,
13224 Err(DbError::IndexWork(IndexWorkFailure::Cancelled {
13225 stage: IndexWorkStage::TextIndex
13226 }))
13227 ),
13228 "fallback cancellation was not typed",
13229 )?;
13230 let post_error_sum = store.connection.query_row(
13231 "WITH RECURSIVE numbers(value) AS (
13232 VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 5000
13233 ) SELECT SUM(value) FROM numbers",
13234 [],
13235 |row| row.get::<_, i64>(0),
13236 )?;
13237 require_eq(
13238 &post_error_sum,
13239 &12_502_500,
13240 "cleared error progress handler",
13241 )?;
13242
13243 let expired = IndexWorkControl::with_deadline(
13244 projectatlas_core::IndexCancellation::new(),
13245 Instant::now(),
13246 );
13247 let deadline = store.query_file_text_fts_candidates(
13248 &FileTextFtsQuery {
13249 literal_token: "needle",
13250 path_prefix: None,
13251 limit: 10,
13252 },
13253 Some(&expired),
13254 );
13255 require(
13256 matches!(
13257 deadline,
13258 Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
13259 stage: IndexWorkStage::TextIndex
13260 }))
13261 ),
13262 "FTS deadline was not typed",
13263 )?;
13264 store.connection.execute(
13265 "DELETE FROM file_content_classifications WHERE path = 'src/good.rs'",
13266 [],
13267 )?;
13268 let missing_classification = store.visit_file_texts_for_fallback(
13269 Some("src/good.rs"),
13270 None,
13271 |_| Ok(FileTextAdmission::Read),
13272 |_| Ok(true),
13273 );
13274 require(
13275 matches!(
13276 missing_classification,
13277 Err(DbError::FileContentClassificationMissing { .. })
13278 ),
13279 "fallback omitted a text row whose predecode classification was missing",
13280 )?;
13281 Ok(())
13282 }
13283
13284 #[test]
13285 fn suggested_purpose_is_not_approved() -> Result<(), Box<dyn Error>> {
13286 let mut store = AtlasStore::in_memory()?;
13287 let node = Node {
13288 path: "src/main.rs".to_string(),
13289 kind: NodeKind::File,
13290 parent_path: normalized_parent("src/main.rs"),
13291 extension: Some(".rs".to_string()),
13292 language: Some("rust".to_string()),
13293 size_bytes: Some(12),
13294 mtime_ns: Some(10),
13295 content_hash: Some("abc".to_string()),
13296 };
13297 store.replace_scan(&[node])?;
13298 store.set_suggested_purpose("src/main.rs", "Maybe application entry point")?;
13299 let nodes = store.load_nodes()?;
13300 require_eq(
13301 &nodes[0].purpose.source,
13302 &PurposeSource::Generated,
13303 "suggested source",
13304 )?;
13305 require_eq(
13306 &nodes[0].purpose.status,
13307 &PurposeStatus::Suggested,
13308 "suggested status",
13309 )?;
13310 store.set_purpose(
13311 "src/main.rs",
13312 "Application entry point",
13313 PurposeSource::Agent,
13314 )?;
13315 let nodes = store.load_nodes()?;
13316 require_eq(
13317 &nodes[0].purpose.status,
13318 &PurposeStatus::Approved,
13319 "agent-approved status",
13320 )?;
13321 store.set_suggested_purpose("src/main.rs", "Late generated suggestion")?;
13322 let nodes = store.load_nodes()?;
13323 require_eq(
13324 &nodes[0].purpose.purpose,
13325 &Some("Application entry point".to_string()),
13326 "approved purpose survives a late suggestion",
13327 )?;
13328 require_eq(
13329 &nodes[0].purpose.source,
13330 &PurposeSource::Agent,
13331 "approved purpose source survives a late suggestion",
13332 )?;
13333 require_eq(
13334 &nodes[0].purpose.status,
13335 &PurposeStatus::Approved,
13336 "approved purpose status survives a late suggestion",
13337 )?;
13338
13339 store.connection.execute(
13340 "
13341 UPDATE purposes
13342 SET status = ?1
13343 WHERE node_id = (SELECT id FROM nodes WHERE path = ?2)
13344 ",
13345 params![PurposeStatus::Stale.as_str(), "src/main.rs"],
13346 )?;
13347 store.set_suggested_purpose("src/main.rs", "Later generated suggestion")?;
13348 let nodes = store.load_nodes()?;
13349 require_eq(
13350 &nodes[0].purpose.purpose,
13351 &Some("Application entry point".to_string()),
13352 "stale reviewed purpose survives a late suggestion",
13353 )?;
13354 require_eq(
13355 &nodes[0].purpose.source,
13356 &PurposeSource::Agent,
13357 "stale reviewed source survives a late suggestion",
13358 )?;
13359 require_eq(
13360 &nodes[0].purpose.status,
13361 &PurposeStatus::Stale,
13362 "stale reviewed status survives a late suggestion",
13363 )?;
13364 Ok(())
13365 }
13366
13367 #[test]
13368 fn agent_reviewed_marker_depends_on_agent_approved_source() -> Result<(), Box<dyn Error>> {
13369 let mut store = AtlasStore::in_memory()?;
13370 store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
13371
13372 store.set_suggested_purpose("src/main.rs", "Maybe application entry point")?;
13373 let nodes = store.load_nodes()?;
13374 require_eq(
13375 &nodes[0].purpose.agent_reviewed(),
13376 &false,
13377 "generated suggestion is not agent reviewed",
13378 )?;
13379
13380 store.set_purpose(
13381 "src/main.rs",
13382 "Imported application entry point",
13383 PurposeSource::Imported,
13384 )?;
13385 let nodes = store.load_nodes()?;
13386 require_eq(
13387 &nodes[0].purpose.agent_reviewed(),
13388 &false,
13389 "imported purpose is not agent reviewed",
13390 )?;
13391
13392 store.set_purpose(
13393 "src/main.rs",
13394 "Agent-reviewed application entry point",
13395 PurposeSource::Agent,
13396 )?;
13397 let nodes = store.load_nodes()?;
13398 require_eq(
13399 &nodes[0].purpose.agent_reviewed(),
13400 &true,
13401 "agent-approved purpose is agent reviewed",
13402 )?;
13403
13404 store.connection.execute(
13405 "
13406 UPDATE purposes
13407 SET source = 'human'
13408 WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/main.rs')
13409 ",
13410 [],
13411 )?;
13412 let nodes = store.load_nodes()?;
13413 require_eq(
13414 &nodes[0].purpose.source,
13415 &PurposeSource::Agent,
13416 "legacy human source normalizes to agent",
13417 )?;
13418 require_eq(
13419 &nodes[0].purpose.agent_reviewed(),
13420 &true,
13421 "legacy approved human row remains reviewed",
13422 )?;
13423
13424 store.replace_scan(&[test_file_node("src/main.rs", "hash-b")])?;
13425 let nodes = store.load_nodes()?;
13426 require_eq(
13427 &nodes[0].purpose.status,
13428 &PurposeStatus::Approved,
13429 "changed reviewed purpose stays approved",
13430 )?;
13431 require_eq(
13432 &nodes[0].purpose.agent_reviewed(),
13433 &true,
13434 "changed approved purpose remains agent reviewed",
13435 )?;
13436 Ok(())
13437 }
13438
13439 #[test]
13440 fn purpose_curation_batch_coalesces_current_unapproved_rows() -> Result<(), Box<dyn Error>> {
13441 let temp = tempfile::tempdir()?;
13442 let root = temp.path().join("project");
13443 fs::create_dir(&root)?;
13444 let database = temp.path().join("projectatlas.db");
13445 let mut store = AtlasStore::open_for_project(&database, &root)?;
13446 store.replace_scan(&[
13447 test_file_node("src/a.rs", "hash-a"),
13448 test_file_node("src/b.rs", "hash-b"),
13449 test_file_node("src/accepted.rs", "hash-accepted"),
13450 ])?;
13451 store.set_suggested_purpose("src/b.rs", "Generated B suggestion")?;
13452 store.set_purpose("src/accepted.rs", "Accepted purpose", PurposeSource::Agent)?;
13453
13454 let selected = vec![
13455 "src/b.rs".to_string(),
13456 "src/a.rs".to_string(),
13457 "src/a.rs".to_string(),
13458 "src/accepted.rs".to_string(),
13459 ];
13460 let first = store.load_purpose_curation_batch(" issue 308 ", &selected)?;
13461 let second = store.load_purpose_curation_batch(
13462 "issue 308",
13463 &selected.iter().rev().cloned().collect::<Vec<_>>(),
13464 )?;
13465 require_eq(&first.task, &"issue 308".to_string(), "normalized task")?;
13466 require_eq(&first.work_key, &second.work_key, "deterministic batch key")?;
13467 require_eq(&first.items, &second.items, "deterministic candidate rows")?;
13468 require_eq(&first.items.len(), &2, "coalesced unapproved row count")?;
13469 require_eq(
13470 &first
13471 .items
13472 .iter()
13473 .map(|item| item.node.node.path.as_str())
13474 .collect::<Vec<_>>(),
13475 &vec!["src/a.rs", "src/b.rs"],
13476 "sorted actionable paths",
13477 )?;
13478 require_eq(
13479 &first
13480 .items
13481 .iter()
13482 .all(|item| item.work_key.len() == 64 && item.state_token.len() == 64),
13483 &true,
13484 "bounded opaque item identities",
13485 )?;
13486
13487 let page = store.purpose_curation_findings_page_current(&HealthQuery {
13488 start_index: 0,
13489 limit: 20,
13490 category: None,
13491 severity: Some(Severity::Warning),
13492 path_prefix: None,
13493 summary_only: false,
13494 scope: HealthScope::all(),
13495 })?;
13496 require_eq(&page.total, &2, "actionable queue count")?;
13497 require_eq(
13498 &page
13499 .findings
13500 .iter()
13501 .any(|finding| finding.path == "src/accepted.rs"),
13502 &false,
13503 "accepted purpose omitted from automatic curation",
13504 )?;
13505
13506 store.set_purpose(
13507 "src/accepted.rs",
13508 "Deliberately corrected purpose",
13509 PurposeSource::Agent,
13510 )?;
13511 let corrected = store
13512 .load_node_by_path("src/accepted.rs")?
13513 .ok_or_else(|| io::Error::other("corrected purpose path disappeared"))?;
13514 require_eq(
13515 &corrected.purpose.purpose,
13516 &Some("Deliberately corrected purpose".to_string()),
13517 "explicit accepted-purpose correction",
13518 )?;
13519 Ok(())
13520 }
13521
13522 #[test]
13523 fn explicit_purpose_rollback_reports_storage_failure() -> Result<(), Box<dyn Error>> {
13524 let temp = tempfile::tempdir()?;
13525 let root = temp.path().join("project");
13526 fs::create_dir(&root)?;
13527 let database = temp.path().join("projectatlas.db");
13528 let mut store = AtlasStore::open_for_project(&database, &root)?;
13529 store.replace_scan(&[test_file_node("source.rs", "hash")])?;
13530 let before_revision = store.authored_purpose_revision()?;
13531 let transaction = store.begin_purpose_mutation()?;
13532 store.set_purpose("source.rs", "Rejected purpose", PurposeSource::Agent)?;
13533 store.connection.execute_batch("ROLLBACK")?;
13534
13535 require(
13536 matches!(transaction.rollback(), Err(DbError::Sqlite(_))),
13537 "explicit purpose rollback suppressed its SQLite failure",
13538 )?;
13539 require_eq(
13540 &store.authored_purpose_revision()?,
13541 &before_revision,
13542 "manually rolled-back purpose revision",
13543 )?;
13544 require(
13545 store
13546 .load_node_by_path("source.rs")?
13547 .is_none_or(|node| node.purpose.purpose.as_deref() != Some("Rejected purpose")),
13548 "manual rollback retained the rejected purpose",
13549 )
13550 }
13551
13552 #[test]
13553 fn conditional_purpose_batch_is_atomic_and_rejects_changed_work() -> Result<(), Box<dyn Error>>
13554 {
13555 let temp = tempfile::tempdir()?;
13556 let root = temp.path().join("project");
13557 fs::create_dir(&root)?;
13558 let database = temp.path().join("projectatlas.db");
13559 let mut store = AtlasStore::open_for_project(&database, &root)?;
13560 let nodes = vec![
13561 test_file_node("src/a.rs", "hash-a"),
13562 test_file_node("src/b.rs", "hash-b"),
13563 test_file_node("src/changed.rs", "hash-changed"),
13564 test_file_node("src/accepted.rs", "hash-accepted"),
13565 test_file_node("src/generation.rs", "hash-generation"),
13566 test_file_node("src/rollback-a.rs", "hash-rollback-a"),
13567 test_file_node("src/rollback-b.rs", "hash-rollback-b"),
13568 ];
13569 store.replace_scan(&nodes)?;
13570 store.set_suggested_purpose("src/b.rs", "Generated B suggestion")?;
13571 store.set_suggested_purpose("src/changed.rs", "Original suggestion")?;
13572 require_eq(
13573 &store.authored_purpose_revision()?,
13574 &0,
13575 "suggestions do not advance authored-purpose revision",
13576 )?;
13577 let task = "issue-308-purpose-curation";
13578
13579 let apply_batch = store
13580 .load_purpose_curation_batch(task, &["src/a.rs".to_string(), "src/b.rs".to_string()])?;
13581 let requests = apply_batch
13582 .items
13583 .iter()
13584 .map(|candidate| {
13585 conditional_purpose_request(
13586 task,
13587 candidate,
13588 &format!("Reviewed {}", candidate.node.node.path),
13589 )
13590 })
13591 .collect::<Vec<_>>();
13592 let applied = store.conditionally_set_purposes(&requests)?;
13593 require_eq(
13594 &applied
13595 .iter()
13596 .map(|result| result.state)
13597 .collect::<Vec<_>>(),
13598 &vec![
13599 PurposeConditionalApplyState::Applied,
13600 PurposeConditionalApplyState::Applied,
13601 ],
13602 "one-transaction batch outcomes",
13603 )?;
13604 require_eq(
13605 &applied.iter().all(|result| {
13606 result.current_purpose.as_ref().is_some_and(|purpose| {
13607 purpose.status == PurposeStatus::Approved
13608 && purpose.source == PurposeSource::Agent
13609 })
13610 }),
13611 &true,
13612 "applied transaction-owned purpose metadata",
13613 )?;
13614 require_eq(
13615 &store.authored_purpose_revision()?,
13616 &1,
13617 "one accepted batch advances one authored-purpose revision",
13618 )?;
13619
13620 let changed = store
13621 .load_purpose_curation_batch(task, &["src/changed.rs".to_string()])?
13622 .items
13623 .into_iter()
13624 .next()
13625 .ok_or_else(|| io::Error::other("changed candidate missing"))?;
13626 store.set_suggested_purpose("src/changed.rs", "Concurrent suggestion")?;
13627 let changed_result = store
13628 .conditionally_set_purposes(&[conditional_purpose_request(
13629 task,
13630 &changed,
13631 "Stale curator answer",
13632 )])?
13633 .pop()
13634 .ok_or_else(|| io::Error::other("changed conditional result missing"))?;
13635 require_eq(
13636 &changed_result.state,
13637 &PurposeConditionalApplyState::Stale,
13638 "changed suggestion state",
13639 )?;
13640 require_eq(
13641 &changed_result
13642 .current_purpose
13643 .as_ref()
13644 .map(|purpose| (purpose.status, purpose.source, purpose.purpose.as_deref())),
13645 &Some((
13646 PurposeStatus::Suggested,
13647 PurposeSource::Generated,
13648 Some("Concurrent suggestion"),
13649 )),
13650 "stale transaction-owned purpose metadata",
13651 )?;
13652 require_eq(
13653 &store.authored_purpose_revision()?,
13654 &1,
13655 "stale conditional purpose leaves revision unchanged",
13656 )?;
13657
13658 let accepted = store
13659 .load_purpose_curation_batch(task, &["src/accepted.rs".to_string()])?
13660 .items
13661 .into_iter()
13662 .next()
13663 .ok_or_else(|| io::Error::other("accepted candidate missing"))?;
13664 store.set_purpose(
13665 "src/accepted.rs",
13666 "Concurrent accepted purpose",
13667 PurposeSource::Agent,
13668 )?;
13669 require_eq(
13670 &store.authored_purpose_revision()?,
13671 &2,
13672 "explicit accepted correction advances the authored-purpose revision",
13673 )?;
13674 store.set_purpose(
13675 "src/accepted.rs",
13676 "Concurrent accepted purpose",
13677 PurposeSource::Agent,
13678 )?;
13679 require_eq(
13680 &store.authored_purpose_revision()?,
13681 &2,
13682 "identical accepted purpose leaves the authored-purpose revision unchanged",
13683 )?;
13684 let accepted_result = store
13685 .conditionally_set_purposes(&[conditional_purpose_request(
13686 task,
13687 &accepted,
13688 "Stale curator overwrite",
13689 )])?
13690 .pop()
13691 .ok_or_else(|| io::Error::other("accepted conditional result missing"))?;
13692 require_eq(
13693 &accepted_result.state,
13694 &PurposeConditionalApplyState::Accepted,
13695 "accepted concurrent state",
13696 )?;
13697 require_eq(
13698 &accepted_result
13699 .current_purpose
13700 .as_ref()
13701 .map(|purpose| (purpose.status, purpose.source, purpose.purpose.as_deref())),
13702 &Some((
13703 PurposeStatus::Approved,
13704 PurposeSource::Agent,
13705 Some("Concurrent accepted purpose"),
13706 )),
13707 "accepted transaction-owned purpose metadata",
13708 )?;
13709 require_eq(
13710 &store.authored_purpose_revision()?,
13711 &2,
13712 "accepted conditional no-op leaves revision unchanged",
13713 )?;
13714
13715 let unavailable_result = store
13716 .conditionally_set_purposes(&[PurposeConditionalApplyRequest {
13717 task: task.to_string(),
13718 path: "src/unavailable.rs".to_string(),
13719 work_key: "unavailable-work".to_string(),
13720 state_token: "unavailable-state".to_string(),
13721 purpose: "Unavailable purpose".to_string(),
13722 }])?
13723 .pop()
13724 .ok_or_else(|| io::Error::other("unavailable conditional result missing"))?;
13725 require_eq(
13726 &unavailable_result.state,
13727 &PurposeConditionalApplyState::PathUnavailable,
13728 "unavailable path state",
13729 )?;
13730 require_eq(
13731 &unavailable_result.current_purpose,
13732 &None,
13733 "unavailable transaction-owned purpose metadata",
13734 )?;
13735
13736 let generation = store
13737 .load_purpose_curation_batch(task, &["src/generation.rs".to_string()])?
13738 .items
13739 .into_iter()
13740 .next()
13741 .ok_or_else(|| io::Error::other("generation candidate missing"))?;
13742 let project = store
13743 .project_instance_id()?
13744 .ok_or_else(|| io::Error::other("project identity missing"))?;
13745 {
13746 let mut publication = store.begin_index_publication("purpose-curation-test")?;
13747 publication.begin_scan_replacement()?;
13748 publication.upsert_scan_node_batch(&nodes)?;
13749 publication.finish_scan_replacement()?;
13750 publication.replace_repository_graph(project, &[], &[], &[], &[])?;
13751 publication.complete()?;
13752 }
13753 let generation_state = store.conditionally_set_purpose(
13754 task,
13755 "src/generation.rs",
13756 &generation.work_key,
13757 &generation.state_token,
13758 "Prior-generation answer",
13759 )?;
13760 require_eq(
13761 &generation_state,
13762 &PurposeConditionalApplyState::Stale,
13763 "generation-bound state",
13764 )?;
13765
13766 let current = store.load_nodes_by_paths(&[
13767 "src/changed.rs".to_string(),
13768 "src/accepted.rs".to_string(),
13769 "src/generation.rs".to_string(),
13770 ])?;
13771 let purposes = current
13772 .iter()
13773 .map(|node| {
13774 (
13775 node.node.path.as_str(),
13776 node.purpose.purpose.as_deref(),
13777 node.purpose.status,
13778 )
13779 })
13780 .collect::<Vec<_>>();
13781 require_eq(
13782 &purposes,
13783 &vec![
13784 (
13785 "src/accepted.rs",
13786 Some("Concurrent accepted purpose"),
13787 PurposeStatus::Approved,
13788 ),
13789 (
13790 "src/changed.rs",
13791 Some("Concurrent suggestion"),
13792 PurposeStatus::Suggested,
13793 ),
13794 ("src/generation.rs", None, PurposeStatus::Missing),
13795 ],
13796 "conflicting rows remain untouched",
13797 )?;
13798
13799 let rollback_batch = store.load_purpose_curation_batch(
13800 task,
13801 &[
13802 "src/rollback-a.rs".to_string(),
13803 "src/rollback-b.rs".to_string(),
13804 ],
13805 )?;
13806 let rollback_requests = rollback_batch
13807 .items
13808 .iter()
13809 .map(|candidate| {
13810 conditional_purpose_request(
13811 task,
13812 candidate,
13813 &format!("Reviewed {}", candidate.node.node.path),
13814 )
13815 })
13816 .collect::<Vec<_>>();
13817 store.connection.execute_batch(
13818 "
13819 CREATE TEMP TRIGGER abort_second_conditional_purpose_update
13820 BEFORE UPDATE ON purposes
13821 WHEN OLD.node_id = (
13822 SELECT id FROM nodes WHERE path = 'src/rollback-b.rs'
13823 )
13824 BEGIN
13825 SELECT RAISE(ABORT, 'forced conditional purpose rollback');
13826 END;
13827 ",
13828 )?;
13829 if store.conditionally_set_purposes(&rollback_requests).is_ok() {
13830 return Err(io::Error::other("injected conditional batch failure succeeded").into());
13831 }
13832 drop(store);
13833 let reopened = AtlasStore::open_for_project(&database, &root)?;
13834 let rolled_back = reopened.load_nodes_by_paths(&[
13835 "src/rollback-a.rs".to_string(),
13836 "src/rollback-b.rs".to_string(),
13837 ])?;
13838 require_eq(
13839 &rolled_back
13840 .iter()
13841 .map(|node| node.purpose.status)
13842 .collect::<Vec<_>>(),
13843 &vec![PurposeStatus::Missing, PurposeStatus::Missing],
13844 "failed conditional batch rolled back after reopen",
13845 )?;
13846 Ok(())
13847 }
13848
13849 #[test]
13850 fn concurrent_purpose_curators_cannot_overwrite_the_winner() -> Result<(), Box<dyn Error>> {
13851 let temp = tempfile::tempdir()?;
13852 let root = temp.path().join("project");
13853 fs::create_dir(&root)?;
13854 let database = temp.path().join("projectatlas.db");
13855 let mut store = AtlasStore::open_for_project(&database, &root)?;
13856 store.replace_scan(&[test_file_node("src/main.rs", "hash-main")])?;
13857 let candidate = store
13858 .load_purpose_curation_batch("concurrent-curators", &["src/main.rs".to_string()])?
13859 .items
13860 .into_iter()
13861 .next()
13862 .ok_or_else(|| io::Error::other("concurrent candidate missing"))?;
13863 drop(store);
13864
13865 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
13866 let mut handles = Vec::new();
13867 for purpose in ["First reviewed purpose", "Second reviewed purpose"] {
13868 let database = database.clone();
13869 let root = root.clone();
13870 let barrier = std::sync::Arc::clone(&barrier);
13871 let candidate = candidate.clone();
13872 handles.push(std::thread::spawn(move || {
13873 let store = AtlasStore::open_for_project(&database, &root)?;
13874 barrier.wait();
13875 store.conditionally_set_purpose(
13876 "concurrent-curators",
13877 "src/main.rs",
13878 &candidate.work_key,
13879 &candidate.state_token,
13880 purpose,
13881 )
13882 }));
13883 }
13884 let outcomes = handles
13885 .into_iter()
13886 .map(|handle| {
13887 handle
13888 .join()
13889 .map_err(|_panic| io::Error::other("curator thread panicked"))?
13890 .map_err(io::Error::other)
13891 })
13892 .collect::<Result<Vec<_>, io::Error>>()?;
13893 require_eq(
13894 &outcomes
13895 .iter()
13896 .filter(|state| **state == PurposeConditionalApplyState::Applied)
13897 .count(),
13898 &1,
13899 "single concurrent winner",
13900 )?;
13901 require_eq(
13902 &outcomes
13903 .iter()
13904 .filter(|state| **state == PurposeConditionalApplyState::Accepted)
13905 .count(),
13906 &1,
13907 "accepted concurrent loser",
13908 )?;
13909
13910 let reopened = AtlasStore::open_for_project(&database, &root)?;
13911 let node = reopened
13912 .load_node_by_path("src/main.rs")?
13913 .ok_or_else(|| io::Error::other("concurrent path disappeared"))?;
13914 require_eq(
13915 &node.purpose.status,
13916 &PurposeStatus::Approved,
13917 "persisted concurrent winner status",
13918 )?;
13919 require_eq(
13920 &matches!(
13921 node.purpose.purpose.as_deref(),
13922 Some("First reviewed purpose" | "Second reviewed purpose")
13923 ),
13924 &true,
13925 "persisted concurrent winner value",
13926 )?;
13927 Ok(())
13928 }
13929
13930 #[test]
13931 fn purpose_curation_hydration_uses_existing_unique_indexes() -> Result<(), Box<dyn Error>> {
13932 let mut store = AtlasStore::in_memory()?;
13933 store.replace_scan(&[
13934 test_file_node("src/a.rs", "hash-a"),
13935 test_file_node("src/b.rs", "hash-b"),
13936 ])?;
13937 let sql = format!("EXPLAIN QUERY PLAN {}", load_nodes_by_paths_sql(2));
13938 let mut statement = store.connection.prepare(&sql)?;
13939 let details = statement
13940 .query_map(params!["src/a.rs", "src/b.rs"], |row| {
13941 row.get::<_, String>(3)
13942 })?
13943 .collect::<Result<Vec<_>, _>>()?;
13944 let plan = details.join("\n");
13945 require_eq(
13946 &plan.contains("SEARCH n USING INDEX sqlite_autoindex_nodes_"),
13947 &true,
13948 "unique path index query plan",
13949 )?;
13950 require_eq(
13951 &plan.contains("SEARCH p USING INTEGER PRIMARY KEY"),
13952 &true,
13953 "purpose primary-key query plan",
13954 )?;
13955 require_eq(&plan.contains("SCAN n"), &false, "no node-table scan")?;
13956 Ok(())
13957 }
13958
13959 #[test]
13960 fn updates_content_summary_without_approving_purpose() -> Result<(), Box<dyn Error>> {
13961 let mut store = AtlasStore::in_memory()?;
13962 let node = Node {
13963 path: "src/lib.rs".to_string(),
13964 kind: NodeKind::File,
13965 parent_path: normalized_parent("src/lib.rs"),
13966 extension: Some(".rs".to_string()),
13967 language: Some("rust".to_string()),
13968 size_bytes: Some(24),
13969 mtime_ns: Some(10),
13970 content_hash: Some("def".to_string()),
13971 };
13972 store.replace_scan(&[node])?;
13973 store.set_node_summary(
13974 "src/lib.rs",
13975 "rust source defining library entry functions.",
13976 )?;
13977 let nodes = store.load_nodes()?;
13978 require_eq(
13979 &nodes[0].summary,
13980 &Some("rust source defining library entry functions.".to_string()),
13981 "updated content summary",
13982 )?;
13983 require_eq(
13984 &nodes[0].purpose.status,
13985 &PurposeStatus::Missing,
13986 "summary update does not approve purpose",
13987 )?;
13988 require_eq(
13989 &store.has_agent_approved_purpose()?,
13990 &false,
13991 "missing purpose does not activate graph hydration",
13992 )?;
13993 store.set_purpose(
13994 "src/lib.rs",
13995 "Owns the library entry points.",
13996 PurposeSource::Agent,
13997 )?;
13998 require_eq(
13999 &store.has_agent_approved_purpose()?,
14000 &true,
14001 "agent-approved purpose activates graph hydration",
14002 )?;
14003 Ok(())
14004 }
14005
14006 #[test]
14007 fn replaces_symbol_graph_idempotently() -> Result<(), Box<dyn Error>> {
14008 let mut store = AtlasStore::in_memory()?;
14009 let graph = SymbolGraph {
14010 path: "src/main.rs".to_string(),
14011 language: Some("rust".to_string()),
14012 parser: ParserKind::TreeSitter,
14013 symbols: vec![CodeSymbol {
14014 path: "src/main.rs".to_string(),
14015 language: Some("rust".to_string()),
14016 name: "main".to_string(),
14017 kind: SymbolKind::Function,
14018 signature: "fn main()".to_string(),
14019 exported: true,
14020 documentation: Some("Run the application.".to_string()),
14021 line_start: 1,
14022 line_end: 3,
14023 source_selector: None,
14024 parent: None,
14025 parser: ParserKind::TreeSitter,
14026 detail: Some("function_item".to_string()),
14027 }],
14028 relations: vec![SymbolRelation {
14029 path: "src/main.rs".to_string(),
14030 source_name: "main".to_string(),
14031 target_name: "println!".to_string(),
14032 kind: RelationKind::Calls,
14033 line: 2,
14034 context: "println!(\"hello\")".to_string(),
14035 parser: ParserKind::TreeSitter,
14036 }],
14037 };
14038
14039 store.replace_symbol_graph(&graph)?;
14040 store.replace_symbol_graph(&graph)?;
14041 let symbols = store.load_symbols(Some("src/main.rs"), Some("main"), 10)?;
14042 let relations = store.load_symbol_relations(Some("src/main.rs"), Some("println"), 10)?;
14043 let metadata = store
14044 .load_source_parse_metadata("src/main.rs")?
14045 .ok_or_else(|| io::Error::other("missing source parse metadata"))?;
14046 require_eq(&symbols.len(), &1, "symbol count after replace")?;
14047 require_eq(&relations.len(), &1, "relation count after replace")?;
14048 require_eq(&metadata.parser, &ParserKind::TreeSitter, "metadata parser")?;
14049 require_eq(&metadata.symbol_count, &1, "metadata symbol count")?;
14050 require_eq(&metadata.relation_count, &1, "metadata relation count")?;
14051 require_eq(&symbols[0].exported, &true, "exported metadata")?;
14052 require_eq(
14053 &symbols[0].documentation,
14054 &Some("Run the application.".to_string()),
14055 "documentation metadata",
14056 )?;
14057 Ok(())
14058 }
14059
14060 #[test]
14061 fn symbol_source_selectors_round_trip_all_read_paths_and_reopen() -> Result<(), Box<dyn Error>>
14062 {
14063 let temp = tempfile::tempdir()?;
14064 let root = temp.path().join("repository");
14065 fs::create_dir(&root)?;
14066 let database = temp.path().join("projectatlas.db");
14067 let path = "docs/guide.md";
14068 let expected = SymbolSourceSelector {
14069 byte_start: 17,
14070 byte_end: 31,
14071 column_start: 4,
14072 column_end: 2,
14073 };
14074 let graph = SymbolGraph {
14075 path: path.to_string(),
14076 language: Some("markdown".to_string()),
14077 parser: ParserKind::Structural,
14078 symbols: vec![
14079 CodeSymbol {
14080 path: path.to_string(),
14081 language: Some("markdown".to_string()),
14082 name: "Overview".to_string(),
14083 kind: SymbolKind::Heading,
14084 signature: "overview#1".to_string(),
14085 exported: false,
14086 documentation: None,
14087 line_start: 2,
14088 line_end: 3,
14089 source_selector: Some(expected),
14090 parent: None,
14091 parser: ParserKind::Structural,
14092 detail: Some("level=2".to_string()),
14093 },
14094 CodeSymbol {
14095 path: path.to_string(),
14096 language: Some("markdown".to_string()),
14097 name: "Compatibility".to_string(),
14098 kind: SymbolKind::Heading,
14099 signature: "compatibility#1".to_string(),
14100 exported: false,
14101 documentation: None,
14102 line_start: 6,
14103 line_end: 6,
14104 source_selector: None,
14105 parent: None,
14106 parser: ParserKind::Structural,
14107 detail: Some("level=2".to_string()),
14108 },
14109 ],
14110 relations: Vec::new(),
14111 };
14112
14113 let mut store = AtlasStore::open_for_project(&database, &root)?;
14114 store.replace_scan(&[test_file_node(path, "guide-hash")])?;
14115 store.replace_symbol_graph(&graph)?;
14116 let mut publication = store.begin_index_publication("symbol-source-selectors")?;
14117 publication.upsert_file_content_classification_batch(&[FileContentClassification {
14118 path: path.to_string(),
14119 classification: ContentClassification::Documentation,
14120 }])?;
14121 publication.complete()?;
14122 verify_symbol_source_selector_read_paths(&store, path, expected)?;
14123 drop(store);
14124
14125 let reopened = AtlasStore::open_for_project(&database, &root)?;
14126 verify_symbol_source_selector_read_paths(&reopened, path, expected)?;
14127 drop(reopened);
14128
14129 let read_only = AtlasStore::open_read_only_for_project(&database, &root)?;
14130 verify_symbol_source_selector_read_paths(&read_only, path, expected)?;
14131 read_only.finish_index_read_snapshot()?;
14132 Ok(())
14133 }
14134
14135 #[test]
14136 fn symbol_source_selector_constraints_and_corrupt_reads_fail_closed()
14137 -> Result<(), Box<dyn Error>> {
14138 let mut store = AtlasStore::in_memory()?;
14139 let path = "docs/guide.md";
14140 store.replace_symbol_graph(&SymbolGraph {
14141 path: path.to_string(),
14142 language: Some("markdown".to_string()),
14143 parser: ParserKind::Structural,
14144 symbols: vec![CodeSymbol {
14145 path: path.to_string(),
14146 language: Some("markdown".to_string()),
14147 name: "Overview".to_string(),
14148 kind: SymbolKind::Heading,
14149 signature: "overview#1".to_string(),
14150 exported: false,
14151 documentation: None,
14152 line_start: 2,
14153 line_end: 2,
14154 source_selector: None,
14155 parent: None,
14156 parser: ParserKind::Structural,
14157 detail: None,
14158 }],
14159 relations: Vec::new(),
14160 })?;
14161
14162 for invalid_update in [
14163 "UPDATE symbols SET source_byte_start = 0 WHERE path = 'docs/guide.md'",
14164 "UPDATE symbols SET source_byte_start = -1, source_byte_end = 4,
14165 source_column_start = 0, source_column_end = 4
14166 WHERE path = 'docs/guide.md'",
14167 "UPDATE symbols SET source_byte_start = 5, source_byte_end = 4,
14168 source_column_start = 0, source_column_end = 4
14169 WHERE path = 'docs/guide.md'",
14170 "UPDATE symbols SET source_byte_start = 0, source_byte_end = 4,
14171 source_column_start = 5, source_column_end = 4
14172 WHERE path = 'docs/guide.md'",
14173 ] {
14174 require(
14175 store.connection.execute(invalid_update, []).is_err(),
14176 "invalid source selector bypassed its table constraint",
14177 )?;
14178 }
14179 store.connection.execute(
14180 "UPDATE symbols
14181 SET source_byte_start = 0, source_byte_end = 4,
14182 source_column_start = 0, source_column_end = 4
14183 WHERE path = ?1",
14184 [path],
14185 )?;
14186 require_symbol_source_selectors(
14187 &store.load_symbols(Some(path), None, 10)?,
14188 Some(SymbolSourceSelector {
14189 byte_start: 0,
14190 byte_end: 4,
14191 column_start: 0,
14192 column_end: 4,
14193 }),
14194 "valid constrained selector",
14195 )?;
14196
14197 store.connection.execute_batch(
14198 "PRAGMA ignore_check_constraints = ON;
14199 UPDATE symbols
14200 SET source_byte_start = 0, source_byte_end = NULL,
14201 source_column_start = 0, source_column_end = 4
14202 WHERE path = 'docs/guide.md';
14203 PRAGMA ignore_check_constraints = OFF;",
14204 )?;
14205 let corrupt = store.load_symbols(Some(path), None, 10);
14206 require(
14207 matches!(corrupt, Err(DbError::Sqlite(_))),
14208 "partially populated source selector did not fail closed",
14209 )?;
14210 Ok(())
14211 }
14212
14213 #[test]
14214 fn symbol_source_selector_write_failure_rolls_back_graph() -> Result<(), Box<dyn Error>> {
14215 let mut store = AtlasStore::in_memory()?;
14216 let path = "docs/guide.md";
14217 let previous = SymbolGraph {
14218 path: path.to_string(),
14219 language: Some("markdown".to_string()),
14220 parser: ParserKind::Structural,
14221 symbols: vec![batch_test_symbol(path, "previous", 1, 0)],
14222 relations: Vec::new(),
14223 };
14224 store.replace_symbol_graph(&previous)?;
14225
14226 let mut valid = batch_test_symbol(path, "valid", 2, 0);
14227 valid.source_selector = Some(SymbolSourceSelector {
14228 byte_start: 4,
14229 byte_end: 9,
14230 column_start: 0,
14231 column_end: 5,
14232 });
14233 let mut invalid = batch_test_symbol(path, "invalid", 3, 0);
14234 invalid.source_selector = Some(SymbolSourceSelector {
14235 byte_start: 12,
14236 byte_end: 11,
14237 column_start: 0,
14238 column_end: 5,
14239 });
14240 let replacement = SymbolGraph {
14241 path: path.to_string(),
14242 language: Some("markdown".to_string()),
14243 parser: ParserKind::Structural,
14244 symbols: vec![valid, invalid],
14245 relations: Vec::new(),
14246 };
14247 let Err(error) = store.replace_symbol_graph(&replacement) else {
14248 return Err(io::Error::other("invalid selector replacement committed").into());
14249 };
14250 require(
14251 matches!(error, DbError::SymbolGraphRowShape { .. }),
14252 "invalid selector replacement returned the wrong error",
14253 )?;
14254 require_eq(
14255 &store.load_symbol_graphs_for_paths(&[path.to_string()])?,
14256 &vec![previous],
14257 "selector failure transaction rollback",
14258 )?;
14259 Ok(())
14260 }
14261
14262 #[test]
14263 fn classified_symbol_list_filters_before_limit_and_preserves_legacy_order()
14264 -> Result<(), Box<dyn Error>> {
14265 let mut store = AtlasStore::in_memory()?;
14266 let classified_paths = [
14267 (
14268 "config/first.toml",
14269 ContentClassification::ConfigurationData,
14270 ),
14271 ("docs/guide.md", ContentClassification::Documentation),
14272 ("other/notes.txt", ContentClassification::OtherText),
14273 ("src/lib.rs", ContentClassification::Source),
14274 ("src/second.rs", ContentClassification::Source),
14275 ];
14276 let nodes = classified_paths
14277 .iter()
14278 .enumerate()
14279 .map(|(index, (path, _))| test_file_node(path, &format!("hash-{index}")))
14280 .collect::<Vec<_>>();
14281 store.replace_scan(&nodes)?;
14282 for (index, (path, _)) in classified_paths.iter().enumerate() {
14283 store.replace_symbol_graph(&SymbolGraph {
14284 path: (*path).to_string(),
14285 language: Some("rust".to_string()),
14286 parser: ParserKind::TreeSitter,
14287 symbols: vec![batch_test_symbol(path, &format!("needle_{index}"), 1, 0)],
14288 relations: Vec::new(),
14289 })?;
14290 }
14291 let classifications = classified_paths
14292 .iter()
14293 .map(|(path, classification)| FileContentClassification {
14294 path: (*path).to_string(),
14295 classification: *classification,
14296 })
14297 .collect::<Vec<_>>();
14298 let mut publication = store.begin_index_publication("classified-symbol-list")?;
14299 publication.upsert_file_content_classification_batch(&classifications)?;
14300 publication.complete()?;
14301
14302 for (file, query, limit) in [
14303 (None, None, 3),
14304 (None, Some("needle"), 3),
14305 (Some("src/second.rs"), None, 10),
14306 (Some("src/second.rs"), Some("needle_4"), 10),
14307 (Some("src/second.rs"), Some("second"), 10),
14308 (None, None, 0),
14309 ] {
14310 let legacy = store.load_symbols(file, query, limit)?;
14311 let classified = store.load_classified_symbols(
14312 file,
14313 query,
14314 ContentSelection::UnspecifiedLegacy,
14315 limit,
14316 )?;
14317 require_eq(
14318 &classified
14319 .into_iter()
14320 .map(|row| row.symbol)
14321 .collect::<Vec<_>>(),
14322 &legacy,
14323 "omitted classified symbol candidates and order",
14324 )?;
14325 }
14326 let omitted = store.load_classified_symbols(
14327 None,
14328 Some("needle"),
14329 ContentSelection::UnspecifiedLegacy,
14330 3,
14331 )?;
14332 require_eq(
14333 &omitted
14334 .iter()
14335 .map(|row| row.classification)
14336 .collect::<Vec<_>>(),
14337 &vec![
14338 ContentClassification::ConfigurationData,
14339 ContentClassification::Documentation,
14340 ContentClassification::OtherText,
14341 ],
14342 "additive legacy classifications",
14343 )?;
14344
14345 let source =
14346 store.load_classified_symbols(None, Some("needle"), ContentSelection::Source, 1)?;
14347 require_eq(
14348 &source.first().map(|row| row.symbol.path.as_str()),
14349 &Some("src/lib.rs"),
14350 "source selection before limit",
14351 )?;
14352 let documentation = store.load_classified_symbols(
14353 None,
14354 Some("needle"),
14355 ContentSelection::Documentation,
14356 1,
14357 )?;
14358 require_eq(
14359 &documentation.first().map(|row| row.symbol.path.as_str()),
14360 &Some("docs/guide.md"),
14361 "documentation selection before limit",
14362 )?;
14363 let both =
14364 store.load_classified_symbols(None, Some("needle"), ContentSelection::Both, 2)?;
14365 require_eq(
14366 &both
14367 .iter()
14368 .map(|row| row.symbol.path.as_str())
14369 .collect::<Vec<_>>(),
14370 &vec!["docs/guide.md", "src/lib.rs"],
14371 "combined selection order",
14372 )?;
14373 let file_query = store.load_classified_symbols(
14374 Some("src/second.rs"),
14375 Some("needle_4"),
14376 ContentSelection::Source,
14377 10,
14378 )?;
14379 require_eq(&file_query.len(), &1, "file/query classified symbol filter")?;
14380 Ok(())
14381 }
14382
14383 #[test]
14384 fn classified_symbol_list_rejects_missing_owner_and_uses_existing_indexes()
14385 -> Result<(), Box<dyn Error>> {
14386 let mut store = AtlasStore::in_memory()?;
14387 store.replace_scan(&[test_file_node("src/lib.rs", "hash")])?;
14388 store.replace_symbol_graph(&SymbolGraph {
14389 path: "src/lib.rs".to_string(),
14390 language: Some("rust".to_string()),
14391 parser: ParserKind::TreeSitter,
14392 symbols: vec![batch_test_symbol("src/lib.rs", "needle", 1, 0)],
14393 relations: Vec::new(),
14394 })?;
14395
14396 for selection in [
14397 ContentSelection::UnspecifiedLegacy,
14398 ContentSelection::Source,
14399 ContentSelection::Both,
14400 ] {
14401 let (sql, bindings) = classified_symbols_sql(None, Some("needle"), selection, 1);
14402 let mut statement = store
14403 .connection
14404 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
14405 let plan = statement
14406 .query_map(params_from_iter(bindings.iter()), |row| {
14407 row.get::<_, String>(3)
14408 })?
14409 .collect::<Result<Vec<_>, _>>()?
14410 .join("\n");
14411 require(
14412 plan.contains("idx_symbols_path")
14413 && plan.contains("sqlite_autoindex_file_content_classifications_1")
14414 && !plan.contains("SCAN classification"),
14415 &format!("classified symbol query missed existing indexes: {plan}"),
14416 )?;
14417 }
14418
14419 store.connection.execute(
14420 "DELETE FROM file_content_classifications WHERE path = 'src/lib.rs'",
14421 [],
14422 )?;
14423 for selection in [
14424 ContentSelection::UnspecifiedLegacy,
14425 ContentSelection::Source,
14426 ContentSelection::Both,
14427 ] {
14428 let Err(error) = store.load_classified_symbols(Some("src/lib.rs"), None, selection, 10)
14429 else {
14430 return Err(
14431 io::Error::other("symbol without a file classification was accepted").into(),
14432 );
14433 };
14434 require(
14435 matches!(error, DbError::FileContentClassificationMissing { .. }),
14436 "missing symbol classification returned the wrong error",
14437 )?;
14438 }
14439 Ok(())
14440 }
14441
14442 #[test]
14443 fn import_alias_lookup_uses_kind_first_covering_index() -> Result<(), Box<dyn Error>> {
14444 let mut store = AtlasStore::in_memory()?;
14445 store.replace_symbol_graph(&SymbolGraph {
14446 path: "src/main.rs".to_string(),
14447 language: Some("rust".to_string()),
14448 parser: ParserKind::TreeSitter,
14449 symbols: Vec::new(),
14450 relations: vec![
14451 SymbolRelation {
14452 path: "src/main.rs".to_string(),
14453 source_name: "main".to_string(),
14454 target_name: "use crate::service::run".to_string(),
14455 kind: RelationKind::Imports,
14456 line: 1,
14457 context: "use crate::service::run;".to_string(),
14458 parser: ParserKind::TreeSitter,
14459 },
14460 SymbolRelation {
14461 path: "src/main.rs".to_string(),
14462 source_name: "main".to_string(),
14463 target_name: "use crate::other::Thing".to_string(),
14464 kind: RelationKind::Imports,
14465 line: 2,
14466 context: "use crate::other::Thing;".to_string(),
14467 parser: ParserKind::TreeSitter,
14468 },
14469 ],
14470 })?;
14471
14472 let relations =
14473 store.load_import_relations_matching_targets(&["service".to_string()], 10)?;
14474 require_eq(&relations.len(), &1, "matched import relation count")?;
14475 require_eq(
14476 &store
14477 .load_import_relations_for_path("src/main.rs", 10)?
14478 .len(),
14479 &2,
14480 "exact-path import relation count",
14481 )?;
14482 let mut statement = store.connection.prepare(
14483 "EXPLAIN QUERY PLAN
14484 SELECT path, source_name, target_name, line
14485 FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
14486 WHERE kind = 'imports' AND target_name LIKE '%service%' ESCAPE '\\'
14487 ORDER BY path, line, source_name, target_name
14488 LIMIT 10",
14489 )?;
14490 let plan = statement
14491 .query_map([], |row| row.get::<_, String>(3))?
14492 .collect::<Result<Vec<_>, _>>()?
14493 .join("\n");
14494 require(
14495 plan.contains(
14496 "SEARCH symbol_relations USING COVERING INDEX idx_symbol_import_alias_lookup (kind=?)",
14497 ),
14498 &format!("import alias lookup missed kind-first covering index: {plan}"),
14499 )?;
14500 Ok(())
14501 }
14502
14503 #[test]
14504 fn file_scoped_symbol_kind_lookup_uses_path_index() -> Result<(), Box<dyn Error>> {
14505 let store = AtlasStore::in_memory()?;
14506 for sql in [
14507 "EXPLAIN QUERY PLAN
14508 SELECT path, language, name, kind, signature, line_start, line_end,
14509 parent, parser, detail, exported, documentation,
14510 source_byte_start, source_byte_end,
14511 source_column_start, source_column_end
14512 FROM symbols INDEXED BY idx_symbols_path
14513 WHERE path = 'src/lib.rs' AND kind IN ('function')
14514 ORDER BY path, line_start, name
14515 LIMIT 25",
14516 "EXPLAIN QUERY PLAN
14517 SELECT COUNT(*) FROM symbols INDEXED BY idx_symbols_path
14518 WHERE path = 'src/lib.rs' AND kind IN ('function')",
14519 ] {
14520 let mut statement = store.connection.prepare(sql)?;
14521 let plan = statement
14522 .query_map([], |row| row.get::<_, String>(3))?
14523 .collect::<Result<Vec<_>, _>>()?
14524 .join("\n");
14525 require(
14526 plan.contains("SEARCH symbols USING INDEX idx_symbols_path (path=?)"),
14527 &format!("file-scoped symbol lookup missed exact-path index: {plan}"),
14528 )?;
14529 }
14530 Ok(())
14531 }
14532
14533 #[test]
14534 fn preserves_source_parse_and_fact_parser_provenance_independently()
14535 -> Result<(), Box<dyn Error>> {
14536 let temp = tempfile::tempdir()?;
14537 let database = temp.path().join("projectatlas.db");
14538 let mut store = AtlasStore::open(&database)?;
14539 let graph = SymbolGraph {
14540 path: "src/optional.lang".to_string(),
14541 language: Some("optional-language".to_string()),
14542 parser: ParserKind::Fallback,
14543 symbols: vec![CodeSymbol {
14544 path: "src/optional.lang".to_string(),
14545 language: Some("optional-language".to_string()),
14546 name: "entry".to_string(),
14547 kind: SymbolKind::Function,
14548 signature: "entry()".to_string(),
14549 exported: false,
14550 documentation: None,
14551 line_start: 1,
14552 line_end: 1,
14553 source_selector: None,
14554 parent: None,
14555 parser: ParserKind::Fallback,
14556 detail: None,
14557 }],
14558 relations: vec![SymbolRelation {
14559 path: "src/optional.lang".to_string(),
14560 source_name: "entry".to_string(),
14561 target_name: "helper".to_string(),
14562 kind: RelationKind::Calls,
14563 line: 1,
14564 context: "entry()".to_string(),
14565 parser: ParserKind::Fallback,
14566 }],
14567 };
14568 let metadata = SourceParseMetadata {
14569 path: graph.path.clone(),
14570 language: graph.language.clone(),
14571 parser: ParserKind::TreeSitter,
14572 symbol_count: graph.symbols.len(),
14573 relation_count: graph.relations.len(),
14574 };
14575
14576 store.replace_symbol_graph_with_metadata(&graph, &metadata)?;
14577
14578 let stored_metadata = store
14579 .load_source_parse_metadata(&graph.path)?
14580 .ok_or_else(|| io::Error::other("missing independent source parse metadata"))?;
14581 let symbols = store.load_symbols(Some(&graph.path), Some("entry"), 10)?;
14582 let relations = store.load_symbol_relations(Some(&graph.path), Some("helper"), 10)?;
14583 require_eq(
14584 &stored_metadata.parser,
14585 &ParserKind::TreeSitter,
14586 "grammar-backed source parser",
14587 )?;
14588 require_eq(
14589 &symbols[0].parser,
14590 &ParserKind::Fallback,
14591 "fallback symbol provenance",
14592 )?;
14593 require_eq(
14594 &relations[0].parser,
14595 &ParserKind::Fallback,
14596 "fallback relation provenance",
14597 )?;
14598
14599 let invalid_metadata = SourceParseMetadata {
14600 symbol_count: 0,
14601 ..metadata
14602 };
14603 let Err(error) = store.replace_symbol_graph_with_metadata(&graph, &invalid_metadata) else {
14604 return Err(io::Error::other("mismatched explicit metadata was accepted").into());
14605 };
14606 if !matches!(error, DbError::SymbolGraphRowShape { .. }) {
14607 return Err(io::Error::other(format!(
14608 "mismatched explicit metadata returned the wrong error: {error}"
14609 ))
14610 .into());
14611 }
14612
14613 let empty_graph = SymbolGraph {
14614 path: "src/empty.optional".to_string(),
14615 language: Some("optional-language".to_string()),
14616 parser: ParserKind::Fallback,
14617 symbols: Vec::new(),
14618 relations: Vec::new(),
14619 };
14620 let empty_metadata = SourceParseMetadata {
14621 path: empty_graph.path.clone(),
14622 language: empty_graph.language.clone(),
14623 parser: ParserKind::TreeSitter,
14624 symbol_count: 0,
14625 relation_count: 0,
14626 };
14627 store.replace_symbol_graph_with_metadata(&empty_graph, &empty_metadata)?;
14628 drop(store);
14629
14630 let reader = AtlasStore::open_read_only(&database)?;
14631 let reopened_metadata = reader
14632 .load_source_parse_metadata(&graph.path)?
14633 .ok_or_else(|| io::Error::other("missing reopened source parse metadata"))?;
14634 let metadata_paths = reader.source_parse_metadata_paths_for_paths(&[
14635 "src/missing.optional".to_string(),
14636 empty_graph.path.clone(),
14637 graph.path.clone(),
14638 graph.path.clone(),
14639 ])?;
14640 let reopened_graphs =
14641 reader.load_symbol_graphs_for_paths(&[graph.path.clone(), empty_graph.path.clone()])?;
14642 require_eq(
14643 &reopened_metadata.parser,
14644 &ParserKind::TreeSitter,
14645 "reopened source parser provenance",
14646 )?;
14647 require_eq(
14648 &metadata_paths,
14649 &HashSet::from([graph.path.clone(), empty_graph.path.clone()]),
14650 "batched source parse metadata paths",
14651 )?;
14652 require_eq(
14653 &reopened_graphs,
14654 &vec![empty_graph, graph],
14655 "reopened fact graph provenance",
14656 )?;
14657 reader.finish_index_read_snapshot()?;
14658 Ok(())
14659 }
14660
14661 #[test]
14662 fn reconstructs_exact_symbol_graph_batches_from_disk_and_fails_closed()
14663 -> Result<(), Box<dyn Error>> {
14664 let temp = tempfile::tempdir()?;
14665 let db_path = temp.path().join("projectatlas.db");
14666 let mut store = AtlasStore::open(&db_path)?;
14667 store.replace_scan(&[
14668 test_file_node("src/a.rs", "hash-a"),
14669 test_file_node("src/b.rs", "hash-b"),
14670 ])?;
14671 let graph = SymbolGraph {
14672 path: "src/a.rs".to_string(),
14673 language: Some("rust".to_string()),
14674 parser: ParserKind::TreeSitter,
14675 symbols: vec![CodeSymbol {
14676 path: "src/a.rs".to_string(),
14677 language: Some("rust".to_string()),
14678 name: "owner".to_string(),
14679 kind: SymbolKind::Function,
14680 signature: "fn owner()".to_string(),
14681 exported: true,
14682 documentation: None,
14683 line_start: 1,
14684 line_end: 2,
14685 source_selector: None,
14686 parent: None,
14687 parser: ParserKind::TreeSitter,
14688 detail: Some("function_item".to_string()),
14689 }],
14690 relations: vec![SymbolRelation {
14691 path: "src/a.rs".to_string(),
14692 source_name: "owner".to_string(),
14693 target_name: "dependency".to_string(),
14694 kind: RelationKind::Calls,
14695 line: 2,
14696 context: "dependency()".to_string(),
14697 parser: ParserKind::TreeSitter,
14698 }],
14699 };
14700 store.replace_symbol_graph(&graph)?;
14701 let empty_graph = SymbolGraph {
14702 path: "src/b.rs".to_string(),
14703 language: Some("rust".to_string()),
14704 parser: ParserKind::TreeSitter,
14705 symbols: Vec::new(),
14706 relations: Vec::new(),
14707 };
14708 store.replace_symbol_graph(&empty_graph)?;
14709 drop(store);
14710
14711 let reader = AtlasStore::open_read_only(&db_path)?;
14712 let loaded = reader.load_symbol_graphs_for_paths(&[
14713 "src/b.rs".to_string(),
14714 "src/a.rs".to_string(),
14715 "src/a.rs".to_string(),
14716 "src/missing.rs".to_string(),
14717 ])?;
14718 require_eq(
14719 &loaded,
14720 &vec![graph, empty_graph],
14721 "batched symbol graph round trip",
14722 )?;
14723 for (table, expected_index) in [
14724 (
14725 "source_parse_metadata",
14726 "sqlite_autoindex_source_parse_metadata_1",
14727 ),
14728 ("symbols", "idx_symbols_path"),
14729 ("symbol_relations", "idx_symbol_relations_path"),
14730 ] {
14731 let sql = format!(
14732 "EXPLAIN QUERY PLAN SELECT path FROM {table}
14733 WHERE path IN ('src/a.rs', 'src/b.rs')"
14734 );
14735 let mut statement = reader.connection.prepare(&sql)?;
14736 let plan = statement
14737 .query_map([], |row| row.get::<_, String>(3))?
14738 .collect::<Result<Vec<_>, _>>()?
14739 .join("\n");
14740 if !plan.contains(expected_index) {
14741 return Err(io::Error::other(format!(
14742 "{table} batch lookup missed {expected_index}: {plan}"
14743 ))
14744 .into());
14745 }
14746 }
14747 reader.finish_index_read_snapshot()?;
14748 drop(reader);
14749
14750 let writer = AtlasStore::open(&db_path)?;
14751 writer.connection.execute(
14752 "UPDATE source_parse_metadata SET symbol_count = 2 WHERE path = 'src/a.rs'",
14753 [],
14754 )?;
14755 let Err(error) = writer.load_symbol_graphs_for_paths(&["src/a.rs".to_string()]) else {
14756 return Err(io::Error::other("metadata count corruption was accepted").into());
14757 };
14758 if !matches!(error, DbError::SymbolGraphRowShape { .. }) {
14759 return Err(io::Error::other(format!(
14760 "metadata count corruption returned the wrong error: {error}"
14761 ))
14762 .into());
14763 }
14764 Ok(())
14765 }
14766
14767 #[test]
14768 fn bounded_symbol_batch_omits_document_coverage_before_limits() -> Result<(), Box<dyn Error>> {
14769 let mut store = AtlasStore::in_memory()?;
14770 store.replace_scan(&[test_file_node("docs/partial.docx", "hash-docx")])?;
14771 let mut symbol = batch_test_symbol("docs/partial.docx", "document-symbol-1", 2, 0);
14772 symbol.language = Some("docx".to_owned());
14773 symbol.kind = SymbolKind::Value;
14774 symbol.parser = ParserKind::Structural;
14775 let mut coverage = batch_test_symbol("docs/partial.docx", "document-text-coverage", 1, 0);
14776 coverage.language = Some("docx".to_owned());
14777 coverage.kind = SymbolKind::Unknown;
14778 coverage.parser = ParserKind::Structural;
14779 store.replace_symbol_graph(&SymbolGraph {
14780 path: "docs/partial.docx".to_owned(),
14781 language: Some("docx".to_owned()),
14782 parser: ParserKind::Structural,
14783 symbols: vec![coverage, symbol],
14784 relations: Vec::new(),
14785 })?;
14786 let result = store.load_symbols_for_paths_bounded(
14787 &["docs/partial.docx".to_owned()],
14788 SymbolBatchReadBudget::new(1, 1, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14789 None,
14790 )?;
14791 require_eq(&result.rows.len(), &1, "only the real document symbol")?;
14792 require_eq(
14793 &result.rows[0].name.as_str(),
14794 &"document-symbol-1",
14795 "visible row",
14796 )?;
14797 require_eq(
14798 &result.reached_limit,
14799 &None,
14800 "coverage does not consume row budget",
14801 )?;
14802 let exact_bytes = store.load_symbols_for_paths_bounded(
14803 &["docs/partial.docx".to_owned()],
14804 SymbolBatchReadBudget::new(1, 1, result.work.decoded_bytes)?,
14805 None,
14806 )?;
14807 require_eq(&exact_bytes.rows.len(), &1, "exact real-symbol byte budget")?;
14808 require_eq(
14809 &exact_bytes.reached_limit,
14810 &None,
14811 "coverage does not consume byte budget",
14812 )?;
14813 require_eq(
14814 &store.max_symbol_end_line_for_path("docs/partial.docx")?,
14815 &3,
14816 "coverage marker does not affect the maximum real line",
14817 )?;
14818 let mut coverage_only =
14819 batch_test_symbol("docs/partial.docx", "document-text-coverage", 1, 0);
14820 coverage_only.language = Some("docx".to_owned());
14821 coverage_only.kind = SymbolKind::Unknown;
14822 coverage_only.parser = ParserKind::Structural;
14823 store.replace_symbol_graph(&SymbolGraph {
14824 path: "docs/partial.docx".to_owned(),
14825 language: Some("docx".to_owned()),
14826 parser: ParserKind::Structural,
14827 symbols: vec![coverage_only],
14828 relations: Vec::new(),
14829 })?;
14830 require_eq(
14831 &store.max_symbol_end_line_for_path("docs/partial.docx")?,
14832 &0,
14833 "coverage-only documents have no inferred rendered lines",
14834 )?;
14835 let plan = store
14836 .connection
14837 .prepare("EXPLAIN QUERY PLAN SELECT path FROM symbols WHERE path IN ('docs/partial.docx') AND NOT (language IN ('docx', 'pdf') AND kind = 'unknown' AND name = 'document-text-coverage')")?
14838 .query_map([], |row| row.get::<_, String>(3))?
14839 .collect::<Result<Vec<_>, _>>()?
14840 .join("\n");
14841 if !plan.contains("idx_symbols_path") {
14842 return Err(io::Error::other(format!(
14843 "bounded document symbols missed the path index: {plan}"
14844 ))
14845 .into());
14846 }
14847 Ok(())
14848 }
14849
14850 #[test]
14851 fn bounded_symbol_batch_streams_exact_paths_and_uses_path_index() -> Result<(), Box<dyn Error>>
14852 {
14853 let mut store = AtlasStore::in_memory()?;
14854 store.replace_scan(&[
14855 test_file_node("src/a.rs", "hash-a"),
14856 test_file_node("src/b.rs", "hash-b"),
14857 ])?;
14858 store.replace_symbol_graph(&SymbolGraph {
14859 path: "src/a.rs".to_string(),
14860 language: Some("rust".to_string()),
14861 parser: ParserKind::TreeSitter,
14862 symbols: vec![
14863 batch_test_symbol("src/a.rs", "alpha", 1, 32),
14864 batch_test_symbol("src/a.rs", "beta", 2, 32),
14865 ],
14866 relations: Vec::new(),
14867 })?;
14868 store.replace_symbol_graph(&SymbolGraph {
14869 path: "src/b.rs".to_string(),
14870 language: Some("rust".to_string()),
14871 parser: ParserKind::TreeSitter,
14872 symbols: vec![batch_test_symbol("src/b.rs", "gamma", 1, 32)],
14873 relations: Vec::new(),
14874 })?;
14875
14876 let complete = store.load_symbols_for_paths_bounded(
14877 &[
14878 "src/b.rs".to_string(),
14879 "src/a.rs".to_string(),
14880 "src/a.rs".to_string(),
14881 ],
14882 SymbolBatchReadBudget::new(2, 3, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14883 None,
14884 )?;
14885 require_eq(
14886 &complete
14887 .rows
14888 .iter()
14889 .map(|symbol| symbol.name.as_str())
14890 .collect::<Vec<_>>(),
14891 &vec!["alpha", "beta", "gamma"],
14892 "deterministic exact-path symbol order",
14893 )?;
14894 require_eq(&complete.truncated, &false, "complete symbol batch")?;
14895 require_eq(&complete.reached_limit, &None, "complete batch limit")?;
14896 require_eq(&complete.work.requested_paths, &2, "unique admitted paths")?;
14897 require_eq(&complete.work.returned_rows, &3, "complete returned rows")?;
14898
14899 let row_limited = store.load_symbols_for_paths_bounded(
14900 &["src/b.rs".to_string(), "src/a.rs".to_string()],
14901 SymbolBatchReadBudget::new(2, 1, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14902 None,
14903 )?;
14904 require_eq(
14905 &row_limited.reached_limit,
14906 &Some(SymbolBatchReadLimit::Rows),
14907 "row-bound classification",
14908 )?;
14909 require_eq(&row_limited.rows.len(), &1, "row-bound retained rows")?;
14910
14911 let byte_limited = store.load_symbols_for_paths_bounded(
14912 &["src/a.rs".to_string()],
14913 SymbolBatchReadBudget::new(1, 3, 1)?,
14914 None,
14915 )?;
14916 require_eq(
14917 &byte_limited.reached_limit,
14918 &Some(SymbolBatchReadLimit::DecodedBytes),
14919 "decoded-byte-bound classification",
14920 )?;
14921 require_eq(
14922 &byte_limited.rows.len(),
14923 &0,
14924 "tiny byte budget retained no partial row",
14925 )?;
14926 require_eq(
14927 &byte_limited.work.decoded_bytes,
14928 &0,
14929 "tiny byte budget retained no row payload",
14930 )?;
14931
14932 let gamma_bytes = code_symbol_decoded_bytes(&complete.rows[2])?;
14933 let exact_byte_boundary = store.load_symbols_for_paths_bounded(
14934 &["src/b.rs".to_string()],
14935 SymbolBatchReadBudget::new(1, 1, gamma_bytes)?,
14936 None,
14937 )?;
14938 require_eq(
14939 &exact_byte_boundary.rows.len(),
14940 &1,
14941 "exact decoded-byte boundary retained the complete row",
14942 )?;
14943 require_eq(
14944 &exact_byte_boundary.work.decoded_bytes,
14945 &gamma_bytes,
14946 "exact decoded-byte boundary accounting",
14947 )?;
14948
14949 let oversized_signature =
14950 "x".repeat(usize::try_from(MAX_SYMBOL_BATCH_DECODED_BYTES)?.saturating_add(1));
14951 store.connection.execute(
14952 "UPDATE symbols SET signature = ?1, line_start = 'invalid' WHERE path = 'src/b.rs'",
14953 [&oversized_signature],
14954 )?;
14955 let oversized_row = store.load_symbols_for_paths_bounded(
14956 &["src/b.rs".to_string()],
14957 SymbolBatchReadBudget::new(1, 1, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14958 None,
14959 )?;
14960 require_eq(
14961 &oversized_row.reached_limit,
14962 &Some(SymbolBatchReadLimit::DecodedBytes),
14963 "oversized persisted row was rejected before hydration",
14964 )?;
14965 require_eq(
14966 &oversized_row.rows.len(),
14967 &0,
14968 "oversized persisted row retained no allocation",
14969 )?;
14970 store.connection.execute(
14971 "UPDATE symbols SET signature = 'fn gamma()', line_start = 1 WHERE path = 'src/b.rs'",
14972 [],
14973 )?;
14974
14975 let many_paths = (0..65)
14976 .rev()
14977 .map(|index| format!("generated/{index:04}.rs"))
14978 .collect::<Vec<_>>();
14979 for index in 0..65 {
14980 let path = format!("generated/{index:04}.rs");
14981 store.replace_symbol_graph(&SymbolGraph {
14982 path: path.clone(),
14983 language: Some("rust".to_string()),
14984 parser: ParserKind::TreeSitter,
14985 symbols: vec![batch_test_symbol(
14986 &path,
14987 &format!("generated_{index:04}"),
14988 1,
14989 0,
14990 )],
14991 relations: Vec::new(),
14992 })?;
14993 }
14994 let path_limited = store.load_symbols_for_paths_bounded(
14995 &many_paths,
14996 SymbolBatchReadBudget::new(
14997 MAX_SYMBOL_BATCH_PATHS,
14998 MAX_SYMBOL_BATCH_ROWS,
14999 MAX_SYMBOL_BATCH_DECODED_BYTES,
15000 )?,
15001 None,
15002 )?;
15003 require_eq(
15004 &path_limited.reached_limit,
15005 &Some(SymbolBatchReadLimit::Paths),
15006 "path-bound classification",
15007 )?;
15008 require_eq(
15009 &path_limited.work.requested_paths,
15010 &MAX_SYMBOL_BATCH_PATHS,
15011 "bounded path admission",
15012 )?;
15013 require_eq(
15014 &path_limited.rows.len(),
15015 &usize::try_from(MAX_SYMBOL_BATCH_PATHS)?,
15016 "all admitted paths loaded across binding chunks",
15017 )?;
15018 require(
15019 path_limited
15020 .rows
15021 .iter()
15022 .any(|symbol| symbol.name == "generated_0063"),
15023 "last deterministically admitted path was not loaded",
15024 )?;
15025 require(
15026 path_limited
15027 .rows
15028 .iter()
15029 .all(|symbol| symbol.name != "generated_0064"),
15030 "path outside the deterministic admission set was loaded",
15031 )?;
15032
15033 let expired = IndexWorkControl::with_deadline(
15034 projectatlas_core::IndexCancellation::new(),
15035 Instant::now(),
15036 );
15037 let expired_result = store.load_symbols_for_paths_bounded(
15038 &["src/a.rs".to_string()],
15039 SymbolBatchReadBudget::new(1, 3, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
15040 Some(&expired),
15041 );
15042 require(
15043 matches!(
15044 expired_result,
15045 Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
15046 stage: IndexWorkStage::RepositoryTraversal
15047 }))
15048 ),
15049 "expired symbol control returned a partial batch instead of a typed error",
15050 )?;
15051
15052 let mut plan_statement = store.connection.prepare(
15053 "EXPLAIN QUERY PLAN
15054 SELECT
15055 path,
15056 language,
15057 name,
15058 kind,
15059 signature,
15060 line_start,
15061 line_end,
15062 parent,
15063 parser,
15064 detail,
15065 exported,
15066 documentation,
15067 source_byte_start,
15068 source_byte_end,
15069 source_column_start,
15070 source_column_end,
15071 length(CAST(path AS BLOB))
15072 + COALESCE(length(CAST(language AS BLOB)), 0)
15073 + length(CAST(name AS BLOB))
15074 + length(CAST(signature AS BLOB))
15075 + COALESCE(length(CAST(parent AS BLOB)), 0)
15076 + COALESCE(length(CAST(detail AS BLOB)), 0)
15077 + COALESCE(length(CAST(documentation AS BLOB)), 0)
15078 FROM symbols
15079 WHERE path IN (?1, ?2)
15080 ORDER BY path, line_start, name
15081 LIMIT ?3",
15082 )?;
15083 let plan = plan_statement
15084 .query_map(params!["src/a.rs", "src/b.rs", 4], |row| {
15085 row.get::<_, String>(3)
15086 })?
15087 .collect::<Result<Vec<_>, _>>()?
15088 .join("\n");
15089 require(
15090 plan.contains("idx_symbols_path"),
15091 &format!("bounded symbol batch missed idx_symbols_path: {plan}"),
15092 )?;
15093 Ok(())
15094 }
15095
15096 #[test]
15097 fn full_scan_removal_clears_source_parse_metadata() -> Result<(), Box<dyn Error>> {
15098 let mut store = AtlasStore::in_memory()?;
15099 store.replace_scan(&[test_file_node("src/a.rs", "hash-a")])?;
15100 store.replace_symbol_graph(&SymbolGraph {
15101 path: "src/a.rs".to_string(),
15102 language: Some("rust".to_string()),
15103 parser: ParserKind::TreeSitter,
15104 symbols: Vec::new(),
15105 relations: Vec::new(),
15106 })?;
15107 require_eq(
15108 &store.load_source_parse_metadata("src/a.rs")?.is_some(),
15109 &true,
15110 "metadata exists before removal",
15111 )?;
15112
15113 store.replace_scan(&[test_file_node("src/b.rs", "hash-b")])?;
15114 require_eq(
15115 &store.load_source_parse_metadata("src/a.rs")?,
15116 &None,
15117 "metadata cleared after full scan removal",
15118 )?;
15119 Ok(())
15120 }
15121
15122 #[test]
15123 fn call_relations_are_limited_per_target() -> Result<(), Box<dyn Error>> {
15124 let mut store = AtlasStore::in_memory()?;
15125 let mut relations = Vec::new();
15126 for index in 0..5 {
15127 relations.push(SymbolRelation {
15128 path: format!("src/a{index}.rs"),
15129 source_name: format!("alpha_caller_{index}"),
15130 target_name: "alpha".to_string(),
15131 kind: RelationKind::Calls,
15132 line: index + 1,
15133 context: "alpha();".to_string(),
15134 parser: ParserKind::TreeSitter,
15135 });
15136 }
15137 relations.push(SymbolRelation {
15138 path: "src/z.rs".to_string(),
15139 source_name: "beta_caller".to_string(),
15140 target_name: "beta".to_string(),
15141 kind: RelationKind::Calls,
15142 line: 99,
15143 context: "beta();".to_string(),
15144 parser: ParserKind::TreeSitter,
15145 });
15146 store.replace_symbol_graph(&SymbolGraph {
15147 path: "src/main.rs".to_string(),
15148 language: Some("rust".to_string()),
15149 parser: ParserKind::TreeSitter,
15150 symbols: Vec::new(),
15151 relations,
15152 })?;
15153
15154 let loaded =
15155 store.load_call_relations_to_targets(&["alpha".to_string(), "beta".to_string()], 2)?;
15156 let alpha_count = loaded
15157 .iter()
15158 .filter(|relation| relation.target_name == "alpha")
15159 .count();
15160 let beta_count = loaded
15161 .iter()
15162 .filter(|relation| relation.target_name == "beta")
15163 .count();
15164 require_eq(&alpha_count, &2, "alpha per-target limit")?;
15165 require_eq(&beta_count, &1, "beta preserved despite alpha skew")?;
15166 let mut statement = store.connection.prepare(
15167 "EXPLAIN QUERY PLAN
15168 SELECT path, source_name, target_name, kind, line, context, parser
15169 FROM (
15170 SELECT path, source_name, target_name, kind, line, context, parser,
15171 ROW_NUMBER() OVER (
15172 PARTITION BY target_name
15173 ORDER BY path, line, source_name, target_name
15174 ) AS target_row
15175 FROM symbol_relations INDEXED BY idx_symbol_relations_target
15176 WHERE kind = 'calls' AND target_name IN ('alpha', 'beta')
15177 )
15178 WHERE target_row <= 2
15179 ORDER BY path, line, source_name, target_name",
15180 )?;
15181 let plan = statement
15182 .query_map([], |row| row.get::<_, String>(3))?
15183 .collect::<Result<Vec<_>, _>>()?
15184 .join("\n");
15185 require(
15186 plan.contains(
15187 "SEARCH symbol_relations USING INDEX idx_symbol_relations_target (target_name=?)",
15188 ),
15189 &format!("call target lookup missed exact-target index: {plan}"),
15190 )?;
15191 Ok(())
15192 }
15193
15194 #[test]
15195 fn stores_health_resolution_ids() -> Result<(), Box<dyn Error>> {
15196 let mut store = AtlasStore::in_memory()?;
15197 store.replace_scan(&[
15198 test_file_node("src/a.rs", "hash-a"),
15199 test_file_node("src/b.rs", "hash-b"),
15200 ])?;
15201 store.set_purpose("src/a.rs", "Shared purpose", PurposeSource::Agent)?;
15202 store.set_purpose("src/b.rs", "Shared purpose", PurposeSource::Agent)?;
15203 let duplicate = store
15204 .unresolved_health_findings(&[])?
15205 .into_iter()
15206 .find(|finding| finding.category == "duplicate-purpose")
15207 .ok_or_else(|| io::Error::other("duplicate-purpose finding missing"))?;
15208 let duplicate_id = duplicate.id.clone();
15209 store.resolve_health_finding(&HealthResolution {
15210 finding_id: duplicate_id.clone(),
15211 category: duplicate.category,
15212 path: duplicate.path,
15213 related_path: duplicate.related_path,
15214 rationale: "Paths intentionally mirror agent skill variants.".to_string(),
15215 })?;
15216 let ids = store.resolved_health_ids()?;
15217 require_eq(&ids, &vec![duplicate_id], "resolved ids")?;
15218 Ok(())
15219 }
15220
15221 #[test]
15222 fn health_resolution_accepts_all_scope_agent_review_findings() -> Result<(), Box<dyn Error>> {
15223 let mut store = AtlasStore::in_memory()?;
15224 let mut asset_file = test_file_node("assets/logo.svg", "hash-logo");
15225 asset_file.extension = Some(".svg".to_string());
15226 asset_file.language = None;
15227 store.replace_scan(&[test_folder_node("assets"), asset_file])?;
15228 store.set_purpose(
15229 "assets/logo.svg",
15230 "Imported SVG brand asset purpose",
15231 PurposeSource::Imported,
15232 )?;
15233
15234 let page = store.unresolved_health_findings_page(
15235 &[],
15236 &HealthQuery {
15237 start_index: 0,
15238 limit: 20,
15239 category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
15240 severity: Some(Severity::Warning),
15241 path_prefix: Some(".".to_string()),
15242 summary_only: false,
15243 scope: HealthScope::all(),
15244 },
15245 )?;
15246 let finding = page
15247 .findings
15248 .iter()
15249 .find(|finding| finding.path == "assets/logo.svg")
15250 .ok_or_else(|| io::Error::other("asset review finding missing"))?;
15251 store.resolve_health_finding(&HealthResolution {
15252 finding_id: finding.id.clone(),
15253 category: finding.category.clone(),
15254 path: finding.path.clone(),
15255 related_path: finding.related_path.clone(),
15256 rationale: "Asset purpose imported from legacy metadata and intentionally accepted."
15257 .to_string(),
15258 })?;
15259
15260 let remaining = store.unresolved_health_findings_page(
15261 &store.resolved_health_ids()?,
15262 &HealthQuery {
15263 start_index: 0,
15264 limit: 20,
15265 category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
15266 severity: Some(Severity::Warning),
15267 path_prefix: Some(".".to_string()),
15268 summary_only: false,
15269 scope: HealthScope::all(),
15270 },
15271 )?;
15272 require_eq(
15273 &health_paths(&remaining).contains(&"assets/logo.svg"),
15274 &false,
15275 "resolved all-scope asset review finding",
15276 )?;
15277 Ok(())
15278 }
15279
15280 #[test]
15281 fn purpose_set_reports_unindexed_path_without_sqlite_leak() -> Result<(), Box<dyn Error>> {
15282 let mut store = AtlasStore::in_memory()?;
15283 store.replace_scan(&[test_file_node("src/main.rs", "hash")])?;
15284 let error = match store.set_purpose("no/such/file.rs", "Missing file", PurposeSource::Agent)
15285 {
15286 Ok(()) => return Err(io::Error::other("missing path should fail").into()),
15287 Err(error) => error,
15288 };
15289
15290 require_eq(
15291 &error.to_string().contains("no/such/file.rs"),
15292 &true,
15293 "path named in error",
15294 )?;
15295 require_eq(
15296 &error.to_string().contains("sqlite error"),
15297 &false,
15298 "raw sqlite error hidden",
15299 )?;
15300 store.replace_scan(&[])?;
15301 let error = match store.set_purpose("src/main.rs", "Removed file", PurposeSource::Agent) {
15302 Ok(()) => return Err(io::Error::other("stale indexed path should fail").into()),
15303 Err(error) => error,
15304 };
15305 require_eq(
15306 &error.to_string().contains("src/main.rs"),
15307 &true,
15308 "stale path named in error",
15309 )?;
15310 Ok(())
15311 }
15312
15313 #[test]
15314 fn health_resolution_requires_active_finding_tuple() -> Result<(), Box<dyn Error>> {
15315 let mut store = AtlasStore::in_memory()?;
15316 store.replace_scan(&[test_file_node("src/main.rs", "hash")])?;
15317 let error = match store.resolve_health_finding(&HealthResolution {
15318 finding_id: "missing-id".to_string(),
15319 category: "duplicate-purpose".to_string(),
15320 path: "no/such/file.rs".to_string(),
15321 related_path: None,
15322 rationale: "typo".to_string(),
15323 }) {
15324 Ok(()) => {
15325 return Err(io::Error::other("nonexistent health finding should fail").into());
15326 }
15327 Err(error) => error,
15328 };
15329
15330 require_eq(
15331 &error.to_string().contains("not active"),
15332 &true,
15333 "inactive finding rejected",
15334 )?;
15335 Ok(())
15336 }
15337
15338 fn write_released_schema_eight_compatibility_fixture(
15340 db_path: &Path,
15341 root: &Path,
15342 ) -> Result<(), Box<dyn Error>> {
15343 write_schema_eight_compatibility_fixture(
15344 db_path,
15345 root,
15346 schema::create_released_schema_eight,
15347 )
15348 }
15349
15350 #[cfg(windows)]
15352 fn write_evolved_released_schema_eight_compatibility_fixture(
15353 db_path: &Path,
15354 root: &Path,
15355 ) -> Result<(), Box<dyn Error>> {
15356 write_schema_eight_compatibility_fixture(
15357 db_path,
15358 root,
15359 schema::create_evolved_released_schema_eight,
15360 )
15361 }
15362
15363 fn write_schema_eight_compatibility_fixture(
15365 db_path: &Path,
15366 root: &Path,
15367 create_schema: fn(&Connection) -> DbResult<()>,
15368 ) -> Result<(), Box<dyn Error>> {
15369 let connection = Connection::open(db_path)?;
15370 create_schema(&connection)?;
15371 schema::configure_writable(&connection)?;
15372 connection.execute_batch("BEGIN IMMEDIATE")?;
15373 let write_result = (|| -> DbResult<()> {
15374 set_metadata(
15375 &connection,
15376 PROJECT_ROOT_KEY,
15377 &normalize_native_path_display(root),
15378 )?;
15379 set_metadata(&connection, "custom_setting", "preserved")?;
15380 set_metadata(&connection, INDEX_PUBLICATION_STATE_KEY, "complete")?;
15381 set_metadata(
15382 &connection,
15383 INDEX_PUBLICATION_FINGERPRINT_KEY,
15384 "untrusted-contract",
15385 )?;
15386 set_metadata(&connection, INDEX_PUBLICATION_GENERATION_KEY, "7")?;
15387 connection.execute_batch(
15388 "
15389 INSERT INTO nodes(
15390 id, path, kind, parent_path, extension, language,
15391 size_bytes, mtime_ns, content_hash
15392 )
15393 VALUES(1, 'src/lib.rs', 'file', 'src', '.rs', 'rust', 12, 10, 'hash-legacy');
15394
15395 INSERT INTO purposes(node_id, purpose, source, status, updated_by)
15396 VALUES(1, 'Schema compatibility source', 'agent', 'approved', 'agent');
15397
15398 INSERT INTO summaries(node_id, summary_level, subject, summary)
15399 VALUES(1, 'node', '', 'released schema source');
15400
15401 INSERT INTO file_texts(path, content_hash, byte_count, line_count, content)
15402 VALUES('src/lib.rs', 'hash-legacy', 6, 1, 'legacy');
15403 ",
15404 )?;
15405 connection.execute(
15406 "
15407 INSERT INTO health_resolutions(
15408 finding_id, category, path, rationale
15409 )
15410 VALUES('schema-review', ?1, 'src/lib.rs', 'Reviewed schema fixture')
15411 ",
15412 [CATEGORY_DUPLICATE_PURPOSE],
15413 )?;
15414 record_released_schema_eight_usage_event(
15415 &connection,
15416 &usage_from_estimates(
15417 "schema-session",
15418 "summary",
15419 Some("src/lib.rs".to_string()),
15420 None,
15421 100,
15422 20,
15423 ),
15424 )
15425 })();
15426 match write_result {
15427 Ok(()) => connection.execute_batch("COMMIT")?,
15428 Err(error) => {
15429 if let Err(rollback) = connection.execute_batch("ROLLBACK") {
15430 return Err(DbError::TransactionRollback {
15431 operation: Box::new(error),
15432 rollback,
15433 }
15434 .into());
15435 }
15436 return Err(error.into());
15437 }
15438 }
15439 Ok(())
15440 }
15441
15442 fn write_schema_compatibility_fixture(
15444 db_path: &Path,
15445 root: &Path,
15446 schema_version: i64,
15447 label: &str,
15448 ) -> Result<(), Box<dyn Error>> {
15449 let mut store = AtlasStore::open(db_path)?;
15450 store.set_project_root(root)?;
15451 populate_schema_compatibility_fixture(&mut store, label)?;
15452 set_metadata(
15453 &store.connection,
15454 SCHEMA_VERSION_KEY,
15455 &schema_version.to_string(),
15456 )?;
15457 store
15458 .connection
15459 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
15460 Ok(())
15461 }
15462
15463 pub(crate) fn populate_schema_compatibility_fixture(
15465 store: &mut AtlasStore,
15466 label: &str,
15467 ) -> Result<(), Box<dyn Error>> {
15468 {
15469 let mut publication = store.begin_index_publication("untrusted-contract")?;
15470 write_test_projection(&mut publication, label)?;
15471 publication.complete()?;
15472 }
15473 store.set_purpose(
15474 "src/lib.rs",
15475 "Schema compatibility source",
15476 PurposeSource::Agent,
15477 )?;
15478 store.connection.execute(
15479 "
15480 INSERT INTO health_resolutions(
15481 finding_id,
15482 category,
15483 path,
15484 rationale
15485 )
15486 VALUES('schema-review', ?1, 'src/lib.rs', 'Reviewed schema fixture')
15487 ",
15488 [CATEGORY_DUPLICATE_PURPOSE],
15489 )?;
15490 store.record_usage(&usage_from_estimates(
15491 "schema-session",
15492 "summary",
15493 Some("src/lib.rs".to_string()),
15494 None,
15495 100,
15496 20,
15497 ))?;
15498 set_metadata(&store.connection, "custom_setting", "preserved")?;
15499 Ok(())
15500 }
15501
15502 fn test_file_node(path: &str, hash: &str) -> Node {
15504 Node {
15505 path: path.to_string(),
15506 kind: NodeKind::File,
15507 parent_path: normalized_parent(path),
15508 extension: Some(".rs".to_string()),
15509 language: Some("rust".to_string()),
15510 size_bytes: Some(12),
15511 mtime_ns: Some(10),
15512 content_hash: Some(hash.to_string()),
15513 }
15514 }
15515
15516 fn batch_test_symbol(
15518 path: &str,
15519 name: &str,
15520 line_start: usize,
15521 documentation_bytes: usize,
15522 ) -> CodeSymbol {
15523 CodeSymbol {
15524 path: path.to_string(),
15525 language: Some("rust".to_string()),
15526 name: name.to_string(),
15527 kind: SymbolKind::Function,
15528 signature: format!("fn {name}()"),
15529 exported: false,
15530 documentation: Some("d".repeat(documentation_bytes)),
15531 line_start,
15532 line_end: line_start.saturating_add(1),
15533 source_selector: None,
15534 parent: None,
15535 parser: ParserKind::TreeSitter,
15536 detail: Some("function_item".to_string()),
15537 }
15538 }
15539
15540 fn require_symbol_source_selectors(
15542 symbols: &[CodeSymbol],
15543 expected: Option<SymbolSourceSelector>,
15544 context: &str,
15545 ) -> Result<(), Box<dyn Error>> {
15546 let heading = symbols
15547 .iter()
15548 .find(|symbol| symbol.name == "Overview")
15549 .ok_or_else(|| io::Error::other(format!("{context} omitted the heading symbol")))?;
15550 require_eq(&heading.source_selector, &expected, context)?;
15551 if let Some(compatibility) = symbols.iter().find(|symbol| symbol.name == "Compatibility") {
15552 require_eq(
15553 &compatibility.source_selector,
15554 &None,
15555 &format!("{context} changed the selector-free symbol"),
15556 )?;
15557 }
15558 Ok(())
15559 }
15560
15561 fn verify_symbol_source_selector_read_paths(
15563 store: &AtlasStore,
15564 path: &str,
15565 expected: SymbolSourceSelector,
15566 ) -> Result<(), Box<dyn Error>> {
15567 require_symbol_source_selectors(
15568 &store.load_symbols(Some(path), None, 10)?,
15569 Some(expected),
15570 "symbol list",
15571 )?;
15572 require_symbol_source_selectors(
15573 &store.load_symbols_by_kinds(path, &[SymbolKind::Heading], 10)?,
15574 Some(expected),
15575 "kind-filtered symbols",
15576 )?;
15577 require_symbol_source_selectors(
15578 &store.load_symbols_by_names(&["Overview".to_string()])?,
15579 Some(expected),
15580 "name-filtered symbols",
15581 )?;
15582 let named = store
15583 .load_symbol_by_name(path, "Overview")?
15584 .ok_or_else(|| io::Error::other("exact-name symbol lookup omitted the heading"))?;
15585 require_eq(
15586 &named.source_selector,
15587 &Some(expected),
15588 "single exact-name symbol",
15589 )?;
15590 require_symbol_source_selectors(
15591 &store.load_symbols_by_exact_file_and_name(path, "Overview")?,
15592 Some(expected),
15593 "exact-file-and-name symbols",
15594 )?;
15595 require_symbol_source_selectors(
15596 &store
15597 .load_symbols_for_paths_bounded(
15598 &[path.to_string()],
15599 SymbolBatchReadBudget::new(1, 10, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
15600 None,
15601 )?
15602 .rows,
15603 Some(expected),
15604 "bounded exact-path symbols",
15605 )?;
15606 let classified =
15607 store.load_classified_symbols(Some(path), None, ContentSelection::Documentation, 10)?;
15608 require(
15609 classified
15610 .iter()
15611 .all(|row| row.classification == ContentClassification::Documentation),
15612 "classified symbol read changed the owning file classification",
15613 )?;
15614 require_symbol_source_selectors(
15615 &classified
15616 .into_iter()
15617 .map(|row| row.symbol)
15618 .collect::<Vec<_>>(),
15619 Some(expected),
15620 "classified symbols",
15621 )?;
15622 let graphs = store.load_symbol_graphs_for_paths(&[path.to_string()])?;
15623 require_eq(&graphs.len(), &1, "reconstructed graph count")?;
15624 require_symbol_source_selectors(
15625 &graphs[0].symbols,
15626 Some(expected),
15627 "reconstructed symbol graph",
15628 )?;
15629 Ok(())
15630 }
15631
15632 fn conditional_purpose_request(
15634 task: &str,
15635 candidate: &PurposeCurationCandidate,
15636 purpose: &str,
15637 ) -> PurposeConditionalApplyRequest {
15638 PurposeConditionalApplyRequest {
15639 task: task.to_string(),
15640 path: candidate.node.node.path.clone(),
15641 work_key: candidate.work_key.clone(),
15642 state_token: candidate.state_token.clone(),
15643 purpose: purpose.to_string(),
15644 }
15645 }
15646
15647 fn write_test_projection(store: &mut AtlasStore, label: &str) -> DbResult<()> {
15649 let path = "src/lib.rs";
15650 let hash = format!("hash-{label}");
15651 store.replace_scan(&[test_file_node(path, &hash)])?;
15652 store.replace_file_texts_for_paths(
15653 &[path.to_string()],
15654 &[IndexedFileText {
15655 path: path.to_string(),
15656 content_hash: Some(hash),
15657 byte_count: label.len(),
15658 line_count: 1,
15659 content: label.to_string(),
15660 }],
15661 )?;
15662 store.replace_symbol_graph(&SymbolGraph {
15663 path: path.to_string(),
15664 language: Some("rust".to_string()),
15665 parser: ParserKind::TreeSitter,
15666 symbols: vec![CodeSymbol {
15667 path: path.to_string(),
15668 language: Some("rust".to_string()),
15669 name: format!("{label}_symbol"),
15670 kind: SymbolKind::Function,
15671 signature: format!("fn {label}_symbol()"),
15672 exported: true,
15673 documentation: None,
15674 line_start: 1,
15675 line_end: 1,
15676 source_selector: None,
15677 parent: None,
15678 parser: ParserKind::TreeSitter,
15679 detail: Some("function_item".to_string()),
15680 }],
15681 relations: vec![SymbolRelation {
15682 path: path.to_string(),
15683 source_name: format!("{label}_symbol"),
15684 target_name: format!("{label}_target"),
15685 kind: RelationKind::Calls,
15686 line: 1,
15687 context: format!("{label}_target();"),
15688 parser: ParserKind::TreeSitter,
15689 }],
15690 })?;
15691 store.set_node_summary(path, &format!("{label} summary"))?;
15692 Ok(())
15693 }
15694
15695 fn require_test_projection(
15697 store: &AtlasStore,
15698 generation: u64,
15699 label: &str,
15700 ) -> Result<(), Box<dyn Error>> {
15701 let path = "src/lib.rs";
15702 let publication = store
15703 .index_publication()?
15704 .ok_or_else(|| io::Error::other("publication missing"))?;
15705 require_eq(
15706 &publication.generation,
15707 &IndexGeneration::new(generation),
15708 "publication generation",
15709 )?;
15710 let node = store
15711 .load_node_by_path(path)?
15712 .ok_or_else(|| io::Error::other("projection node missing"))?;
15713 require_eq(
15714 &node.node.content_hash,
15715 &Some(format!("hash-{label}")),
15716 "projection node hash",
15717 )?;
15718 require_eq(
15719 &node.summary,
15720 &Some(format!("{label} summary")),
15721 "projection node summary",
15722 )?;
15723 let text = store
15724 .load_file_text(path)?
15725 .ok_or_else(|| io::Error::other("projection text missing"))?;
15726 require_eq(&text.content, &label.to_string(), "projection text")?;
15727 let symbols = store.load_symbols(Some(path), None, 10)?;
15728 require_eq(
15729 &symbols.first().map(|symbol| symbol.name.as_str()),
15730 &Some(format!("{label}_symbol")).as_deref(),
15731 "projection symbol",
15732 )?;
15733 let relations = store.load_symbol_relations(Some(path), None, 10)?;
15734 require_eq(
15735 &relations
15736 .first()
15737 .map(|relation| relation.target_name.as_str()),
15738 &Some(format!("{label}_target")).as_deref(),
15739 "projection relation",
15740 )?;
15741 let metadata = store
15742 .load_source_parse_metadata(path)?
15743 .ok_or_else(|| io::Error::other("projection parse metadata missing"))?;
15744 require_eq(&metadata.symbol_count, &1, "projection symbol metadata")?;
15745 require_eq(&metadata.relation_count, &1, "projection relation metadata")?;
15746 Ok(())
15747 }
15748
15749 fn require_writable_connection_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
15751 let foreign_keys =
15752 connection.pragma_query_value(None, "foreign_keys", |row| row.get::<_, i64>(0))?;
15753 require_eq(&foreign_keys, &1, "writable foreign-key enforcement")?;
15754 require_wal_profile(connection)?;
15755 let synchronous =
15756 connection.pragma_query_value(None, "synchronous", |row| row.get::<_, i64>(0))?;
15757 require_eq(&synchronous, &2, "writable FULL synchronous mode")?;
15758 require_busy_timeout(connection)
15759 }
15760
15761 fn require_read_connection_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
15763 let query_only =
15764 connection.pragma_query_value(None, "query_only", |row| row.get::<_, i64>(0))?;
15765 require_eq(&query_only, &1, "read query-only mode")?;
15766 require_wal_profile(connection)?;
15767 require_busy_timeout(connection)
15768 }
15769
15770 fn require_wal_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
15772 let journal_mode =
15773 connection.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))?;
15774 require_eq(
15775 &journal_mode.to_ascii_lowercase(),
15776 &"wal".to_string(),
15777 "WAL journal mode",
15778 )
15779 }
15780
15781 fn require_busy_timeout(connection: &Connection) -> Result<(), Box<dyn Error>> {
15783 let busy_timeout =
15784 connection.pragma_query_value(None, "busy_timeout", |row| row.get::<_, i64>(0))?;
15785 require_eq(
15786 &u128::try_from(busy_timeout)?,
15787 &SQLITE_BUSY_TIMEOUT.as_millis(),
15788 "bounded connection busy timeout",
15789 )
15790 }
15791
15792 fn test_folder_node(path: &str) -> Node {
15794 Node {
15795 path: path.to_string(),
15796 kind: NodeKind::Folder,
15797 parent_path: normalized_parent(path),
15798 extension: None,
15799 language: None,
15800 size_bytes: None,
15801 mtime_ns: Some(10),
15802 content_hash: None,
15803 }
15804 }
15805
15806 fn low_query() -> HealthQuery {
15808 HealthQuery {
15809 start_index: 0,
15810 limit: 20,
15811 category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
15812 severity: Some(Severity::Warning),
15813 path_prefix: Some(".".to_string()),
15814 summary_only: false,
15815 scope: HealthScope::purpose_default(),
15816 }
15817 }
15818
15819 fn health_paths(page: &HealthFindingsPage) -> Vec<&str> {
15821 page.findings
15822 .iter()
15823 .map(|finding| finding.path.as_str())
15824 .collect()
15825 }
15826
15827 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
15829 if condition {
15830 Ok(())
15831 } else {
15832 Err(io::Error::other(message).into())
15833 }
15834 }
15835
15836 fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
15838 where
15839 T: Debug + PartialEq,
15840 {
15841 if actual == expected {
15842 Ok(())
15843 } else {
15844 Err(io::Error::other(format!(
15845 "{label} mismatch: expected {expected:?}, got {actual:?}"
15846 ))
15847 .into())
15848 }
15849 }
15850}