projectatlas_db/
diagnostics.rs

1//! Build bounded content-free database settings diagnostics.
2
3use 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
19/// Maximum project-scope coverage rows returned by one settings report.
20const COVERAGE_SAMPLE_LIMIT: u32 = 8;
21/// Maximum distinct compile options accepted from one linked `SQLite` runtime.
22const MAX_COMPILE_OPTIONS: u32 = 1_024;
23/// Maximum bytes accepted for one linked `SQLite` compile-option name.
24const MAX_COMPILE_OPTION_BYTES: usize = 1_024;
25/// Domain separator for deterministic `SQLite` compile-option identities.
26const COMPILE_OPTIONS_DIGEST_DOMAIN: &[u8] = b"projectatlas:sqlite-compile-options:v1\0";
27/// Exact lowercase hexadecimal bytes in a runtime-owned publication fingerprint.
28const PUBLICATION_FINGERPRINT_BYTES: usize = 64;
29
30/// Compatibility of the selected database with the current runtime.
31#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
32#[serde(rename_all = "snake_case")]
33pub enum DatabaseSchemaCompatibility {
34    /// The database file does not exist yet.
35    Missing,
36    /// The durable schema matches the current runtime.
37    Current,
38    /// The durable schema is an admitted predecessor with a transactional migration path.
39    SupportedPredecessor,
40    /// The durable schema or project binding is not admitted by this runtime.
41    Incompatible,
42    /// `SQLite` or its integrity check identified corrupt database state.
43    Corrupt,
44    /// Filesystem support could not admit a safe database inspection.
45    NotInspected,
46}
47
48/// Content-free schema and migration state.
49#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
50pub struct DatabaseSchemaReport {
51    /// Schema version supported by this runtime.
52    pub runtime_version: i64,
53    /// Durable schema version when a validated admitted schema exposed one.
54    pub stored_version: Option<i64>,
55    /// Closed compatibility and migration state.
56    pub compatibility: DatabaseSchemaCompatibility,
57    /// Whether the selected state is known to require migration.
58    pub migration_required: Option<bool>,
59    /// Whether this runtime owns a complete migration path for the selected state.
60    pub migration_supported: bool,
61    /// Remaining append-only migration steps when the state is admitted.
62    pub migration_steps_remaining: Option<u32>,
63}
64
65/// WAL suitability of the selected database filesystem.
66#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
67#[serde(rename_all = "snake_case")]
68pub enum DatabaseFilesystemSupport {
69    /// The location resolved to an admitted local filesystem.
70    SupportedLocal,
71    /// The location resolved to a known unsupported network or distributed filesystem.
72    Unsupported,
73    /// The runtime could not prove an admitted local filesystem.
74    Uncertain,
75}
76
77/// Deterministic fixed-size identity of the active `SQLite` compile options.
78#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
79pub struct SqliteCompileOptionsIdentity {
80    /// Number of distinct compile options represented by the digest.
81    pub count: u32,
82    /// Lowercase BLAKE3 digest of sorted length-delimited option names.
83    pub digest: String,
84}
85
86/// Content-free identity of the linked `SQLite` runtime.
87#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
88pub struct SqliteRuntimeReport {
89    /// Version reported by the linked `SQLite` library.
90    pub version: String,
91    /// Numeric version reported by the linked `SQLite` library.
92    pub version_number: i32,
93    /// Fixed-size identity of all active compile options.
94    pub compile_options: SqliteCompileOptionsIdentity,
95}
96
97/// Required and observed database connection and maintenance policy.
98#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
99pub struct DatabaseOperatingProfileReport {
100    /// Journal mode required for live project databases.
101    pub required_journal_mode: String,
102    /// Journal mode observed on the reporting connection, when available.
103    pub observed_journal_mode: Option<String>,
104    /// Synchronous mode required for authored and derived state.
105    pub required_synchronous_mode: String,
106    /// Synchronous mode observed on the reporting connection, when available.
107    pub observed_synchronous_mode: Option<String>,
108    /// Busy timeout required for ordinary connections in milliseconds.
109    pub required_busy_timeout_ms: u64,
110    /// Busy timeout observed on the reporting connection in milliseconds.
111    pub observed_busy_timeout_ms: Option<u64>,
112    /// Writes between `ProjectAtlas` passive-checkpoint attempts.
113    pub checkpoint_write_interval: usize,
114    /// Most recent passive-checkpoint state, when durable state is available.
115    pub checkpoint_state: Option<TelemetryCheckpointState>,
116    /// Connection-local automatic checkpoint threshold, when available.
117    pub wal_autocheckpoint_pages: Option<usize>,
118    /// `ProjectAtlas` planner-statistics maintenance policy.
119    pub statistics_policy: PlannerStatisticsPolicy,
120    /// Observed planner-statistics state, when available.
121    pub statistics_state: Option<PlannerStatisticsState>,
122}
123
124/// Validation state of persisted publication-contract identity metadata.
125#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
126#[serde(rename_all = "snake_case")]
127pub enum DatabasePublicationContractState {
128    /// No contract fingerprint is present for this publication state.
129    Missing,
130    /// The fingerprint is a validated lowercase 256-bit hexadecimal identity.
131    Valid,
132    /// Persisted metadata is not a safe runtime-owned fingerprint and was omitted.
133    Invalid,
134}
135
136/// Content-free active publication identity for settings diagnostics.
137#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
138pub struct DatabasePublicationReport {
139    /// Current publication lifecycle state.
140    pub state: IndexPublicationState,
141    /// Validated contract identity, never arbitrary persisted metadata.
142    pub contract_fingerprint: Option<String>,
143    /// Validation state for the persisted contract identity.
144    pub contract_fingerprint_state: DatabasePublicationContractState,
145    /// Monotonic generation of the last complete derived index.
146    pub generation: IndexGeneration,
147}
148
149/// Whether a bounded coverage total is exact or a lower bound.
150#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
151#[serde(rename_all = "snake_case")]
152pub enum DatabaseCoverageTotalState {
153    /// Every matching row was returned by the bounded query.
154    Exact,
155    /// At least one additional row exists beyond the returned sample.
156    AtLeast,
157}
158
159/// One content-free project-scope graph coverage row.
160#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
161pub struct DatabaseCoverageSample {
162    /// Typed project or repository-relative path scope.
163    pub scope: CoverageScope,
164    /// Optional relation family covered by this row.
165    pub relation: Option<GraphRelationKind>,
166    /// Coverage lifecycle state.
167    pub state: CoverageState,
168    /// Supported items in this coverage scope.
169    pub total: u64,
170    /// Successfully covered items.
171    pub covered: u64,
172    /// Omitted, failed, or untrusted items.
173    pub omitted: u64,
174    /// Bounded actionable non-complete explanation.
175    pub actionable_reason: Option<String>,
176    /// Product limit reached, when applicable.
177    pub reached_limit: Option<GraphLimitKind>,
178}
179
180/// One bounded project-scope graph coverage sample with honest total state.
181#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
182pub struct DatabaseCoverageSummary {
183    /// Complete graph generation represented by the coverage rows.
184    pub generation: IndexGeneration,
185    /// Fixed maximum number of returned rows.
186    pub sample_limit: u32,
187    /// Rows validated including the one truncation sentinel, when present.
188    pub inspected: usize,
189    /// Rows returned to the caller.
190    pub returned: usize,
191    /// Validated rows in deterministic indexed order.
192    pub sample: Vec<DatabaseCoverageSample>,
193    /// Exact total when complete, otherwise a proved lower bound.
194    pub known_total: usize,
195    /// Interpretation of `known_total`.
196    pub total_state: DatabaseCoverageTotalState,
197    /// Whether additional matching rows exist.
198    pub truncated: bool,
199    /// Existing agent route for inspecting one returned path in context.
200    pub next_call: &'static str,
201}
202
203/// Bounded content-free database state for CLI and MCP settings projection.
204#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
205pub struct DatabaseSettingsReport {
206    /// Schema compatibility and migration state.
207    pub schema: DatabaseSchemaReport,
208    /// Linked `SQLite` runtime identity.
209    pub sqlite: SqliteRuntimeReport,
210    /// WAL suitability of the selected database location.
211    pub filesystem: DatabaseFilesystemSupport,
212    /// Required and observed operating profile.
213    pub operating_profile: DatabaseOperatingProfileReport,
214    /// Current derived publication state, when the schema is current.
215    pub publication: Option<DatabasePublicationReport>,
216    /// Bounded project-scope graph coverage, when a complete graph is available.
217    pub coverage: Option<DatabaseCoverageSummary>,
218}
219
220/// Build bounded content-free database settings without creating, migrating, or repairing state.
221///
222/// # Errors
223///
224/// Returns an error when runtime introspection or an otherwise admitted database read fails.
225pub 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
301/// Build the constant required operating profile before observed state is available.
302fn 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
325/// Build a report when filesystem policy intentionally prevents database inspection.
326fn 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
341/// Complete current-schema diagnostics from one validated read-only snapshot.
342fn 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
381/// Return whether derived diagnostic rows belong to one validated complete publication.
382fn 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
388/// Omit arbitrary persisted publication text while retaining validated identity truth.
389fn 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
406/// Accept only the runtime's lowercase BLAKE3 publication identity shape.
407fn 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
414/// Project one existing bounded non-complete coverage page.
415fn 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
538/// Convert one nonnegative persisted coverage count without losing its source error.
539fn 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
547/// Derive explicit migration flags from the closed compatibility state.
548fn 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
579/// Return a deterministic runtime version and compile-option identity.
580fn 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
627/// Convert known schema validation failures into a closed unhealthy state.
628fn 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
650/// Retain a stored unsupported version without opening an incompatible schema further.
651const 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(&current_path, &root)?);
708        let current_before = fs::read(&current_path)?;
709        let current = database_settings_report(&current_path)?;
710        require_eq(
711            &current.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(&current_path)?,
724            &current_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    /// Return a test error instead of panicking in result-returning tests.
1007    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    /// Compare one test value without panicking in result-returning tests.
1016    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}