1use crate::schema::{self, SCHEMA_VERSION, SchemaState};
4use crate::sqlite_profile::{
5 REQUIRED_JOURNAL_MODE, REQUIRED_SYNCHRONOUS_NAME, SQLITE_BUSY_TIMEOUT,
6};
7use crate::telemetry::{
8 PlannerStatisticsPolicy, PlannerStatisticsState, TelemetryCheckpointState,
9 TelemetryRetentionPolicy,
10};
11use crate::{AtlasStore, DbError, DbResult, IndexPublication, IndexPublicationState};
12use projectatlas_core::IndexGeneration;
13use projectatlas_core::graph::{CoverageRecord, GraphIdentityText, RepositoryNodePath};
14use projectatlas_core::graph::{CoverageScope, CoverageState, GraphLimitKind, GraphRelationKind};
15use rusqlite::{Connection, ErrorCode};
16use serde::Serialize;
17use std::path::Path;
18
19const COVERAGE_SAMPLE_LIMIT: u32 = 8;
21const MAX_COMPILE_OPTIONS: u32 = 1_024;
23const MAX_COMPILE_OPTION_BYTES: usize = 1_024;
25const COMPILE_OPTIONS_DIGEST_DOMAIN: &[u8] = b"projectatlas:sqlite-compile-options:v1\0";
27const PUBLICATION_FINGERPRINT_BYTES: usize = 64;
29
30#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
32#[serde(rename_all = "snake_case")]
33pub enum DatabaseSchemaCompatibility {
34 Missing,
36 Current,
38 SupportedPredecessor,
40 Incompatible,
42 Corrupt,
44 NotInspected,
46}
47
48#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
50pub struct DatabaseSchemaReport {
51 pub runtime_version: i64,
53 pub stored_version: Option<i64>,
55 pub compatibility: DatabaseSchemaCompatibility,
57 pub migration_required: Option<bool>,
59 pub migration_supported: bool,
61 pub migration_steps_remaining: Option<u32>,
63}
64
65#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
67#[serde(rename_all = "snake_case")]
68pub enum DatabaseFilesystemSupport {
69 SupportedLocal,
71 Unsupported,
73 Uncertain,
75}
76
77#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
79pub struct SqliteCompileOptionsIdentity {
80 pub count: u32,
82 pub digest: String,
84}
85
86#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
88pub struct SqliteRuntimeReport {
89 pub version: String,
91 pub version_number: i32,
93 pub compile_options: SqliteCompileOptionsIdentity,
95}
96
97#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
99pub struct DatabaseOperatingProfileReport {
100 pub required_journal_mode: String,
102 pub observed_journal_mode: Option<String>,
104 pub required_synchronous_mode: String,
106 pub observed_synchronous_mode: Option<String>,
108 pub required_busy_timeout_ms: u64,
110 pub observed_busy_timeout_ms: Option<u64>,
112 pub checkpoint_write_interval: usize,
114 pub checkpoint_state: Option<TelemetryCheckpointState>,
116 pub wal_autocheckpoint_pages: Option<usize>,
118 pub statistics_policy: PlannerStatisticsPolicy,
120 pub statistics_state: Option<PlannerStatisticsState>,
122}
123
124#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
126#[serde(rename_all = "snake_case")]
127pub enum DatabasePublicationContractState {
128 Missing,
130 Valid,
132 Invalid,
134}
135
136#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
138pub struct DatabasePublicationReport {
139 pub state: IndexPublicationState,
141 pub contract_fingerprint: Option<String>,
143 pub contract_fingerprint_state: DatabasePublicationContractState,
145 pub generation: IndexGeneration,
147}
148
149#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
151#[serde(rename_all = "snake_case")]
152pub enum DatabaseCoverageTotalState {
153 Exact,
155 AtLeast,
157}
158
159#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
161pub struct DatabaseCoverageSample {
162 pub scope: CoverageScope,
164 pub relation: Option<GraphRelationKind>,
166 pub state: CoverageState,
168 pub total: u64,
170 pub covered: u64,
172 pub omitted: u64,
174 pub actionable_reason: Option<String>,
176 pub reached_limit: Option<GraphLimitKind>,
178}
179
180#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
182pub struct DatabaseCoverageSummary {
183 pub generation: IndexGeneration,
185 pub sample_limit: u32,
187 pub inspected: usize,
189 pub returned: usize,
191 pub sample: Vec<DatabaseCoverageSample>,
193 pub known_total: usize,
195 pub total_state: DatabaseCoverageTotalState,
197 pub truncated: bool,
199 pub next_call: &'static str,
201}
202
203#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
205pub struct DatabaseSettingsReport {
206 pub schema: DatabaseSchemaReport,
208 pub sqlite: SqliteRuntimeReport,
210 pub filesystem: DatabaseFilesystemSupport,
212 pub operating_profile: DatabaseOperatingProfileReport,
214 pub publication: Option<DatabasePublicationReport>,
216 pub coverage: Option<DatabaseCoverageSummary>,
218}
219
220pub fn database_settings_report(path: &Path) -> DbResult<DatabaseSettingsReport> {
226 let sqlite = sqlite_runtime_report()?;
227 let operating_profile = required_operating_profile()?;
228 let filesystem = match crate::sqlite_profile::inspect_database_location(path) {
229 Ok(_) => DatabaseFilesystemSupport::SupportedLocal,
230 Err(DbError::DatabaseFilesystemUnsupported { .. }) => {
231 return Ok(uninspected_report(
232 sqlite,
233 operating_profile,
234 DatabaseFilesystemSupport::Unsupported,
235 ));
236 }
237 Err(DbError::DatabaseFilesystemUncertain { .. }) => {
238 return Ok(uninspected_report(
239 sqlite,
240 operating_profile,
241 DatabaseFilesystemSupport::Uncertain,
242 ));
243 }
244 Err(error) => return Err(error),
245 };
246
247 let preflight = match schema::inspect_compatibility(path, None) {
248 Ok((preflight, _)) => preflight,
249 Err(error) => {
250 let stored_version = stored_version_from_error(&error);
251 let compatibility = classify_schema_error(error)?;
252 return Ok(DatabaseSettingsReport {
253 schema: schema_report(stored_version, compatibility),
254 sqlite,
255 filesystem,
256 operating_profile,
257 publication: None,
258 coverage: None,
259 });
260 }
261 };
262 let compatibility = match preflight.state {
263 SchemaState::Fresh => DatabaseSchemaCompatibility::Missing,
264 SchemaState::Current => DatabaseSchemaCompatibility::Current,
265 SchemaState::UpgradeRequired => DatabaseSchemaCompatibility::SupportedPredecessor,
266 };
267 if preflight.state != SchemaState::Current {
268 return Ok(DatabaseSettingsReport {
269 schema: schema_report(preflight.schema_version, compatibility),
270 sqlite,
271 filesystem,
272 operating_profile,
273 publication: None,
274 coverage: None,
275 });
276 }
277
278 let store = match AtlasStore::open_read_only(path) {
279 Ok(store) => store,
280 Err(error) => {
281 let compatibility = classify_schema_error(error)?;
282 return Ok(DatabaseSettingsReport {
283 schema: schema_report(preflight.schema_version, compatibility),
284 sqlite,
285 filesystem,
286 operating_profile,
287 publication: None,
288 coverage: None,
289 });
290 }
291 };
292 current_report(
293 &store,
294 preflight.schema_version,
295 sqlite,
296 filesystem,
297 operating_profile,
298 )
299}
300
301fn required_operating_profile() -> DbResult<DatabaseOperatingProfileReport> {
303 let required_busy_timeout_ms =
304 u64::try_from(SQLITE_BUSY_TIMEOUT.as_millis()).map_err(|_source| {
305 DbError::GraphCountOverflow {
306 field: "database_settings.required_busy_timeout_ms",
307 value: u64::MAX,
308 }
309 })?;
310 Ok(DatabaseOperatingProfileReport {
311 required_journal_mode: REQUIRED_JOURNAL_MODE.to_string(),
312 observed_journal_mode: None,
313 required_synchronous_mode: REQUIRED_SYNCHRONOUS_NAME.to_string(),
314 observed_synchronous_mode: None,
315 required_busy_timeout_ms,
316 observed_busy_timeout_ms: None,
317 checkpoint_write_interval: TelemetryRetentionPolicy::default().checkpoint_write_interval,
318 checkpoint_state: None,
319 wal_autocheckpoint_pages: None,
320 statistics_policy: PlannerStatisticsPolicy::NotConfigured,
321 statistics_state: None,
322 })
323}
324
325fn uninspected_report(
327 sqlite: SqliteRuntimeReport,
328 operating_profile: DatabaseOperatingProfileReport,
329 filesystem: DatabaseFilesystemSupport,
330) -> DatabaseSettingsReport {
331 DatabaseSettingsReport {
332 schema: schema_report(None, DatabaseSchemaCompatibility::NotInspected),
333 sqlite,
334 filesystem,
335 operating_profile,
336 publication: None,
337 coverage: None,
338 }
339}
340
341fn current_report(
343 store: &AtlasStore,
344 stored_version: Option<i64>,
345 sqlite: SqliteRuntimeReport,
346 filesystem: DatabaseFilesystemSupport,
347 mut operating_profile: DatabaseOperatingProfileReport,
348) -> DbResult<DatabaseSettingsReport> {
349 let publication = store.index_publication()?.map(database_publication_report);
350 let coverage = if store.validated_project_instance_id.is_some() {
351 let telemetry = store.telemetry_retention_state()?;
352 operating_profile.observed_journal_mode = Some(telemetry.journal_mode);
353 operating_profile.observed_synchronous_mode = Some(telemetry.synchronous_mode);
354 operating_profile.observed_busy_timeout_ms = Some(telemetry.connection_busy_timeout_ms);
355 operating_profile.checkpoint_write_interval = telemetry.checkpoint_write_interval;
356 operating_profile.checkpoint_state = Some(telemetry.checkpoint_state);
357 operating_profile.wal_autocheckpoint_pages = Some(telemetry.wal_autocheckpoint_pages);
358 operating_profile.statistics_policy = telemetry.statistics_policy;
359 operating_profile.statistics_state = Some(telemetry.statistics_state);
360 if publication
361 .as_ref()
362 .is_some_and(is_trusted_complete_publication)
363 {
364 coverage_summary(store, publication.as_ref())?
365 } else {
366 None
367 }
368 } else {
369 None
370 };
371 Ok(DatabaseSettingsReport {
372 schema: schema_report(stored_version, DatabaseSchemaCompatibility::Current),
373 sqlite,
374 filesystem,
375 operating_profile,
376 publication,
377 coverage,
378 })
379}
380
381fn is_trusted_complete_publication(publication: &DatabasePublicationReport) -> bool {
383 publication.state == IndexPublicationState::Complete
384 && publication.generation != IndexGeneration::ZERO
385 && publication.contract_fingerprint_state == DatabasePublicationContractState::Valid
386}
387
388fn database_publication_report(publication: IndexPublication) -> DatabasePublicationReport {
390 let (contract_fingerprint, contract_fingerprint_state) = match publication.contract_fingerprint
391 {
392 None => (None, DatabasePublicationContractState::Missing),
393 Some(fingerprint) if is_valid_publication_fingerprint(&fingerprint) => {
394 (Some(fingerprint), DatabasePublicationContractState::Valid)
395 }
396 Some(_) => (None, DatabasePublicationContractState::Invalid),
397 };
398 DatabasePublicationReport {
399 state: publication.state,
400 contract_fingerprint,
401 contract_fingerprint_state,
402 generation: publication.generation,
403 }
404}
405
406fn is_valid_publication_fingerprint(value: &str) -> bool {
408 value.len() == PUBLICATION_FINGERPRINT_BYTES
409 && value
410 .bytes()
411 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
412}
413
414fn coverage_summary(
416 store: &AtlasStore,
417 publication: Option<&DatabasePublicationReport>,
418) -> DbResult<Option<DatabaseCoverageSummary>> {
419 let Some(publication) = publication else {
420 return Ok(None);
421 };
422 if publication.state != crate::IndexPublicationState::Complete
423 || publication.generation == IndexGeneration::ZERO
424 {
425 return Ok(None);
426 }
427 let Some(graph_generation) = crate::project_identity::load_graph_generation(&store.connection)?
428 else {
429 return Ok(None);
430 };
431 if graph_generation == IndexGeneration::ZERO || graph_generation != publication.generation {
432 return Err(DbError::GraphRowShape {
433 table: "project_identity",
434 reason: "diagnostic coverage generation does not match complete publication",
435 });
436 }
437 let limit_plus_one = i64::from(COVERAGE_SAMPLE_LIMIT) + 1;
438 let mut statement = store.connection.prepare_cached(
439 "SELECT scope_kind, scope_path, state, total, covered, omitted, reason, reached_limit
440 FROM graph_coverage INDEXED BY idx_graph_coverage_relation_state
441 WHERE relation_scope IS NULL
442 AND relation_kind IS NULL
443 AND state IN ('partial', 'failed', 'ignored', 'oversized',
444 'quarantined', 'stale')
445 ORDER BY state, id
446 LIMIT ?1",
447 )?;
448 let raw = statement
449 .query_map([limit_plus_one], |row| {
450 Ok((
451 row.get::<_, String>(0)?,
452 row.get::<_, Option<String>>(1)?,
453 row.get::<_, String>(2)?,
454 row.get::<_, i64>(3)?,
455 row.get::<_, i64>(4)?,
456 row.get::<_, i64>(5)?,
457 row.get::<_, Option<String>>(6)?,
458 row.get::<_, Option<String>>(7)?,
459 ))
460 })?
461 .collect::<Result<Vec<_>, _>>()?;
462 let inspected = raw.len();
463 let truncated = inspected > COVERAGE_SAMPLE_LIMIT as usize;
464 let sample = raw
465 .into_iter()
466 .take(COVERAGE_SAMPLE_LIMIT as usize)
467 .map(
468 |(scope_kind, scope_path, state, total, covered, omitted, reason, reached_limit)| {
469 let scope = match (scope_kind.as_str(), scope_path) {
470 ("project", None) => CoverageScope::Project,
471 ("path", Some(path)) => CoverageScope::Path {
472 path: RepositoryNodePath::new(Path::new(&path))?,
473 },
474 ("project" | "path", _) => {
475 return Err(DbError::GraphRowShape {
476 table: "graph_coverage",
477 reason: "diagnostic coverage scope columns contradict scope kind",
478 });
479 }
480 (value, _) => {
481 return Err(DbError::InvalidEnum {
482 field: "graph_coverage.scope_kind",
483 value: value.to_string(),
484 });
485 }
486 };
487 let reason = reason.map(GraphIdentityText::new).transpose()?;
488 let record = CoverageRecord::new(
489 scope,
490 None,
491 crate::repository_graph::parse_coverage_state(&state)?,
492 nonnegative_coverage_count("graph_coverage.covered", covered)?,
493 nonnegative_coverage_count("graph_coverage.omitted", omitted)?,
494 graph_generation,
495 reason,
496 reached_limit
497 .as_deref()
498 .map(crate::repository_graph::parse_limit_kind)
499 .transpose()?,
500 )?;
501 if record.total() != nonnegative_coverage_count("graph_coverage.total", total)? {
502 return Err(DbError::GraphRowShape {
503 table: "graph_coverage",
504 reason: "diagnostic coverage total does not equal covered plus omitted",
505 });
506 }
507 Ok(DatabaseCoverageSample {
508 scope: record.scope().clone(),
509 relation: None,
510 state: record.state(),
511 total: record.total(),
512 covered: record.covered(),
513 omitted: record.omitted(),
514 actionable_reason: record.reason().map(|value| value.as_str().to_string()),
515 reached_limit: record.reached_limit(),
516 })
517 },
518 )
519 .collect::<DbResult<Vec<_>>>()?;
520 let known_total = sample.len() + usize::from(truncated);
521 Ok(Some(DatabaseCoverageSummary {
522 generation: graph_generation,
523 sample_limit: COVERAGE_SAMPLE_LIMIT,
524 inspected,
525 returned: sample.len(),
526 sample,
527 known_total,
528 total_state: if truncated {
529 DatabaseCoverageTotalState::AtLeast
530 } else {
531 DatabaseCoverageTotalState::Exact
532 },
533 truncated,
534 next_call: "atlas_file_summary",
535 }))
536}
537
538fn nonnegative_coverage_count(field: &'static str, value: i64) -> DbResult<u64> {
540 u64::try_from(value).map_err(|source| DbError::InvalidCount {
541 field,
542 value,
543 source,
544 })
545}
546
547fn schema_report(
549 stored_version: Option<i64>,
550 compatibility: DatabaseSchemaCompatibility,
551) -> DatabaseSchemaReport {
552 let migration_steps_remaining = stored_version.and_then(schema::migration_steps_remaining);
553 let (migration_required, migration_supported, migration_steps_remaining) = match compatibility {
554 DatabaseSchemaCompatibility::Missing | DatabaseSchemaCompatibility::Current => {
555 (Some(false), true, Some(0))
556 }
557 DatabaseSchemaCompatibility::SupportedPredecessor => {
558 (Some(true), true, migration_steps_remaining)
559 }
560 DatabaseSchemaCompatibility::Incompatible => (
561 stored_version.map(|version| version != SCHEMA_VERSION),
562 false,
563 None,
564 ),
565 DatabaseSchemaCompatibility::Corrupt | DatabaseSchemaCompatibility::NotInspected => {
566 (None, false, None)
567 }
568 };
569 DatabaseSchemaReport {
570 runtime_version: SCHEMA_VERSION,
571 stored_version,
572 compatibility,
573 migration_required,
574 migration_supported,
575 migration_steps_remaining,
576 }
577}
578
579fn sqlite_runtime_report() -> DbResult<SqliteRuntimeReport> {
581 let connection = Connection::open_in_memory()?;
582 let mut statement = connection.prepare(
583 "SELECT DISTINCT compile_options FROM pragma_compile_options ORDER BY compile_options",
584 )?;
585 let mut rows = statement.query([])?;
586 let mut count = 0_u32;
587 let mut hasher = blake3::Hasher::new();
588 hasher.update(COMPILE_OPTIONS_DIGEST_DOMAIN);
589 while let Some(row) = rows.next()? {
590 let option = row.get::<_, String>(0)?;
591 if option.len() > MAX_COMPILE_OPTION_BYTES {
592 return Err(DbError::DatabaseOperatingProfile {
593 setting: "sqlite_compile_options.option_bytes",
594 expected: format!("at most {MAX_COMPILE_OPTION_BYTES}"),
595 found: option.len().to_string(),
596 });
597 }
598 count = count.checked_add(1).ok_or(DbError::GraphCountOverflow {
599 field: "sqlite_compile_options.count",
600 value: u64::from(u32::MAX) + 1,
601 })?;
602 if count > MAX_COMPILE_OPTIONS {
603 return Err(DbError::DatabaseOperatingProfile {
604 setting: "sqlite_compile_options.count",
605 expected: format!("at most {MAX_COMPILE_OPTIONS}"),
606 found: count.to_string(),
607 });
608 }
609 let length =
610 u64::try_from(option.len()).map_err(|_source| DbError::GraphCountOverflow {
611 field: "sqlite_compile_options.option_bytes",
612 value: u64::MAX,
613 })?;
614 hasher.update(&length.to_le_bytes());
615 hasher.update(option.as_bytes());
616 }
617 Ok(SqliteRuntimeReport {
618 version: rusqlite::version().to_string(),
619 version_number: rusqlite::version_number(),
620 compile_options: SqliteCompileOptionsIdentity {
621 count,
622 digest: hasher.finalize().to_hex().to_string(),
623 },
624 })
625}
626
627fn classify_schema_error(error: DbError) -> DbResult<DatabaseSchemaCompatibility> {
629 match &error {
630 DbError::IntegrityCheck { .. } => Ok(DatabaseSchemaCompatibility::Corrupt),
631 DbError::Sqlite(error)
632 if matches!(
633 error.sqlite_error_code(),
634 Some(ErrorCode::DatabaseCorrupt | ErrorCode::NotADatabase)
635 ) =>
636 {
637 Ok(DatabaseSchemaCompatibility::Corrupt)
638 }
639 DbError::SchemaVersion { .. }
640 | DbError::SchemaVersionMissing
641 | DbError::SchemaShape { .. }
642 | DbError::ProjectRootMissing
643 | DbError::ProjectInstanceIdentityMissing
644 | DbError::ProjectRootTransitionChanged { .. }
645 | DbError::InvalidInteger { .. } => Ok(DatabaseSchemaCompatibility::Incompatible),
646 _ => Err(error),
647 }
648}
649
650const fn stored_version_from_error(error: &DbError) -> Option<i64> {
652 match error {
653 DbError::SchemaVersion { found, .. } => Some(*found),
654 _ => None,
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use crate::schema::{PREVIOUS_SCHEMA_VERSION, SCHEMA_VERSION_KEY};
662 use crate::{INDEX_PUBLICATION_FINGERPRINT_KEY, IndexPublicationState, set_metadata};
663 use projectatlas_core::IndexGeneration;
664 use projectatlas_core::graph::{
665 CoverageRecord, EntitySelector, GraphEntity, GraphIdentityText,
666 };
667 use std::error::Error;
668 use std::fmt::Debug;
669 use std::fs;
670 use std::io;
671 use tempfile::tempdir;
672
673 #[test]
674 fn reports_missing_current_predecessor_and_unhealthy_schema_without_mutation()
675 -> Result<(), Box<dyn Error>> {
676 let temp = tempdir()?;
677 let missing = temp.path().join("missing.db");
678 let first = database_settings_report(&missing)?;
679 let second = database_settings_report(&missing)?;
680 require_eq(
681 &first.schema.compatibility,
682 &DatabaseSchemaCompatibility::Missing,
683 "missing database compatibility",
684 )?;
685 require(
686 !missing.exists(),
687 "settings report created a missing database",
688 )?;
689 require_eq(
690 &first.sqlite,
691 &second.sqlite,
692 "deterministic SQLite identity",
693 )?;
694 require(
695 first.sqlite.compile_options.count > 0,
696 "SQLite compile option count is empty",
697 )?;
698 require_eq(
699 &first.sqlite.compile_options.digest.len(),
700 &64,
701 "SQLite compile option digest length",
702 )?;
703
704 let root = temp.path().join("project");
705 fs::create_dir(&root)?;
706 let current_path = temp.path().join("current.db");
707 drop(AtlasStore::open_for_project(¤t_path, &root)?);
708 let current_before = fs::read(¤t_path)?;
709 let current = database_settings_report(¤t_path)?;
710 require_eq(
711 ¤t.schema,
712 &DatabaseSchemaReport {
713 runtime_version: SCHEMA_VERSION,
714 stored_version: Some(SCHEMA_VERSION),
715 compatibility: DatabaseSchemaCompatibility::Current,
716 migration_required: Some(false),
717 migration_supported: true,
718 migration_steps_remaining: Some(0),
719 },
720 "current schema report",
721 )?;
722 require_eq(
723 &fs::read(¤t_path)?,
724 ¤t_before,
725 "current database bytes",
726 )?;
727
728 let predecessor_path = temp.path().join("predecessor.db");
729 let predecessor = Connection::open(&predecessor_path)?;
730 crate::schema::create_released_schema_eight(&predecessor)?;
731 set_metadata(
732 &predecessor,
733 SCHEMA_VERSION_KEY,
734 &PREVIOUS_SCHEMA_VERSION.to_string(),
735 )?;
736 drop(predecessor);
737 let predecessor_before = fs::read(&predecessor_path)?;
738 let predecessor = database_settings_report(&predecessor_path)?;
739 require_eq(
740 &predecessor.schema.compatibility,
741 &DatabaseSchemaCompatibility::SupportedPredecessor,
742 "predecessor schema compatibility",
743 )?;
744 require_eq(
745 &predecessor.schema.stored_version,
746 &Some(PREVIOUS_SCHEMA_VERSION),
747 "predecessor stored schema version",
748 )?;
749 require_eq(
750 &predecessor.schema.migration_required,
751 &Some(true),
752 "predecessor migration requirement",
753 )?;
754 require(
755 predecessor.schema.migration_supported,
756 "migration not supported",
757 )?;
758 require_eq(
759 &predecessor.schema.migration_steps_remaining,
760 &Some(8),
761 "predecessor migration steps",
762 )?;
763 require_eq(
764 &fs::read(&predecessor_path)?,
765 &predecessor_before,
766 "predecessor database bytes",
767 )?;
768
769 let resolution_path = temp.path().join("resolution-predecessor.db");
770 let resolution_store = AtlasStore::open_for_project(&resolution_path, &root)?;
771 resolution_store.connection.execute_batch(
772 "ALTER TABLE source_parse_metadata RENAME TO source_parse_metadata_current;
773 CREATE TABLE source_parse_metadata (
774 path TEXT PRIMARY KEY,
775 language TEXT,
776 parser TEXT NOT NULL,
777 symbol_count INTEGER NOT NULL,
778 relation_count INTEGER NOT NULL,
779 updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
780 );
781 INSERT INTO source_parse_metadata(
782 path, language, parser, symbol_count, relation_count, updated_at
783 )
784 SELECT path, language, fact_parser, symbol_count, relation_count, updated_at
785 FROM source_parse_metadata_current;
786 DROP TABLE source_parse_metadata_current;
787 CREATE INDEX idx_source_parse_metadata_parser
788 ON source_parse_metadata(parser);
789 DROP TABLE file_text_fts;",
790 )?;
791 set_metadata(&resolution_store.connection, SCHEMA_VERSION_KEY, "12")?;
792 drop(resolution_store);
793 let resolution_before = fs::read(&resolution_path)?;
794 let resolution = database_settings_report(&resolution_path)?;
795 require_eq(
796 &resolution.schema.compatibility,
797 &DatabaseSchemaCompatibility::SupportedPredecessor,
798 "schema-12 compatibility",
799 )?;
800 require_eq(
801 &resolution.schema.migration_steps_remaining,
802 &Some(4),
803 "schema-12 migration steps",
804 )?;
805 require_eq(
806 &fs::read(&resolution_path)?,
807 &resolution_before,
808 "schema-12 database bytes",
809 )?;
810
811 let incompatible_path = temp.path().join("incompatible.db");
812 let incompatible = Connection::open(&incompatible_path)?;
813 incompatible.execute_batch("CREATE TABLE unrelated(value TEXT NOT NULL)")?;
814 drop(incompatible);
815 let incompatible = database_settings_report(&incompatible_path)?;
816 require_eq(
817 &incompatible.schema.compatibility,
818 &DatabaseSchemaCompatibility::Incompatible,
819 "incompatible schema state",
820 )?;
821
822 let corrupt_path = temp.path().join("corrupt.db");
823 fs::write(&corrupt_path, b"not a sqlite database")?;
824 let corrupt = database_settings_report(&corrupt_path)?;
825 require_eq(
826 &corrupt.schema.compatibility,
827 &DatabaseSchemaCompatibility::Corrupt,
828 "corrupt schema state",
829 )?;
830 Ok(())
831 }
832
833 #[test]
834 fn reports_publication_and_bounded_content_free_coverage() -> Result<(), Box<dyn Error>> {
835 const VALID_FINGERPRINT: &str =
836 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
837 const PRIVATE_FINGERPRINT_SENTINEL: &str = "private-publication-fingerprint-sentinel";
838 let temp = tempdir()?;
839 let root = temp.path().join("sensitive-project-name");
840 fs::create_dir(&root)?;
841 let database = temp.path().join("atlas.db");
842 let mut store = AtlasStore::open_for_project(&database, &root)?;
843 let project = store.captured_project_binding()?.project_instance_id;
844 let generation = IndexGeneration::new(1);
845 let entity = GraphEntity::new(project, EntitySelector::Project, generation)?;
846 let coverage = (0..=COVERAGE_SAMPLE_LIMIT)
847 .enumerate()
848 .map(|(index, _)| {
849 CoverageRecord::new(
850 CoverageScope::Path {
851 path: RepositoryNodePath::new(Path::new(&format!(
852 "src/incomplete-{index}.rs"
853 )))?,
854 },
855 None,
856 CoverageState::Partial,
857 1,
858 1,
859 generation,
860 Some(GraphIdentityText::new(
861 "coverage incomplete after row limit",
862 )?),
863 Some(GraphLimitKind::Rows),
864 )
865 })
866 .collect::<Result<Vec<_>, _>>()?;
867 let mut publication = store.begin_index_publication(VALID_FINGERPRINT)?;
868 publication.replace_repository_graph(project, &[entity], &[], &[], &coverage)?;
869 publication.complete()?;
870 drop(store);
871
872 let before = fs::read(&database)?;
873 let report = database_settings_report(&database)?;
874 require_eq(&fs::read(&database)?, &before, "published database bytes")?;
875 let publication = report.publication.as_ref().ok_or("publication missing")?;
876 require_eq(
877 &publication.state,
878 &IndexPublicationState::Complete,
879 "publication state",
880 )?;
881 require_eq(
882 &publication.generation,
883 &generation,
884 "publication generation",
885 )?;
886 require_eq(
887 &publication.contract_fingerprint.as_deref(),
888 &Some(VALID_FINGERPRINT),
889 "publication contract fingerprint",
890 )?;
891 require_eq(
892 &publication.contract_fingerprint_state,
893 &DatabasePublicationContractState::Valid,
894 "publication contract fingerprint state",
895 )?;
896 let coverage = report.coverage.as_ref().ok_or("coverage missing")?;
897 require_eq(
898 &coverage.sample.len(),
899 &(COVERAGE_SAMPLE_LIMIT as usize),
900 "coverage sample length",
901 )?;
902 require(coverage.truncated, "coverage sample was not truncated")?;
903 require_eq(
904 &coverage.known_total,
905 &(COVERAGE_SAMPLE_LIMIT as usize + 1),
906 "coverage lower bound",
907 )?;
908 require_eq(
909 &coverage.total_state,
910 &DatabaseCoverageTotalState::AtLeast,
911 "coverage total state",
912 )?;
913
914 let encoded = serde_json::to_string(&report)?;
915 require(
916 !encoded.contains("sensitive-project-name"),
917 "serialized report leaked project path",
918 )?;
919 require(
920 encoded.contains("coverage incomplete after row limit"),
921 "serialized report omitted the bounded actionable coverage reason",
922 )?;
923 require(
924 !encoded.contains(database.to_string_lossy().as_ref()),
925 "serialized report leaked database path",
926 )?;
927 let value = serde_json::from_str::<serde_json::Value>(&encoded)?;
928 let compile_options = value["sqlite"]["compile_options"]
929 .as_object()
930 .ok_or("compile-options identity is not an object")?;
931 require_eq(&compile_options.len(), &2, "compile option identity fields")?;
932 require(
933 compile_options.contains_key("count"),
934 "compile option count is missing",
935 )?;
936 require(
937 compile_options.contains_key("digest"),
938 "compile option digest is missing",
939 )?;
940
941 let store = AtlasStore::open_for_project(&database, &root)?;
942 set_metadata(
943 &store.connection,
944 INDEX_PUBLICATION_FINGERPRINT_KEY,
945 PRIVATE_FINGERPRINT_SENTINEL,
946 )?;
947 drop(store);
948 let invalid = database_settings_report(&database)?;
949 let invalid_publication = invalid
950 .publication
951 .as_ref()
952 .ok_or("invalid publication state missing")?;
953 require_eq(
954 &invalid_publication.contract_fingerprint_state,
955 &DatabasePublicationContractState::Invalid,
956 "invalid publication contract fingerprint state",
957 )?;
958 require_eq(
959 &invalid_publication.contract_fingerprint,
960 &None,
961 "invalid publication contract fingerprint",
962 )?;
963 require_eq(
964 &invalid.coverage,
965 &None,
966 "coverage from invalid publication metadata",
967 )?;
968 require(
969 !serde_json::to_string(&invalid)?.contains(PRIVATE_FINGERPRINT_SENTINEL),
970 "serialized report leaked invalid publication metadata",
971 )?;
972 Ok(())
973 }
974
975 #[test]
976 fn coverage_query_uses_the_relation_state_index() -> Result<(), Box<dyn Error>> {
977 let store = AtlasStore::in_memory()?;
978 let mut statement = store.connection.prepare(
979 "EXPLAIN QUERY PLAN
980 SELECT scope_kind, scope_path, state, total, covered, omitted, reason, reached_limit
981 FROM graph_coverage INDEXED BY idx_graph_coverage_relation_state
982 WHERE relation_scope IS NULL
983 AND relation_kind IS NULL
984 AND state IN ('partial', 'failed', 'ignored', 'oversized',
985 'quarantined', 'stale')
986 ORDER BY state, id
987 LIMIT ?1",
988 )?;
989 let plan = statement
990 .query_map([i64::from(COVERAGE_SAMPLE_LIMIT) + 1], |row| {
991 row.get::<_, String>(3)
992 })?
993 .collect::<Result<Vec<_>, _>>()?
994 .join("; ");
995 require(
996 plan.contains("idx_graph_coverage_relation_state"),
997 &format!("coverage plan did not use relation-state index: {plan}"),
998 )?;
999 require(
1000 !plan.contains("TEMP B-TREE"),
1001 &format!("coverage plan required a temporary sort: {plan}"),
1002 )?;
1003 Ok(())
1004 }
1005
1006 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
1008 if condition {
1009 Ok(())
1010 } else {
1011 Err(io::Error::other(message).into())
1012 }
1013 }
1014
1015 fn require_eq<T: Debug + PartialEq>(
1017 actual: &T,
1018 expected: &T,
1019 label: &str,
1020 ) -> Result<(), Box<dyn Error>> {
1021 if actual == expected {
1022 Ok(())
1023 } else {
1024 Err(io::Error::other(format!(
1025 "{label} mismatch: expected {expected:?}, found {actual:?}"
1026 ))
1027 .into())
1028 }
1029 }
1030}