Skip to main content

fathomdb_cli/
lib.rs

1//! Operator CLI parser + verb runtime for `fathomdb`.
2//!
3//! Surface owned by `dev/interfaces/cli.md`. Phase 10a wires the parser
4//! scaffold to real engine seam calls: `doctor check-integrity`,
5//! `doctor safe-export`, `doctor trace`, `recover --rebuild-projections`,
6//! `recover --rebuild-vec0`, and `recover --excise-source` invoke the
7//! corresponding [`fathomdb::Engine`] methods and serialize the typed
8//! report under the per-verb JSON discriminator.
9
10use std::path::PathBuf;
11
12use clap::{Args, Parser, Subcommand};
13use fathomdb::{
14    CheckIntegrityOpts, CorruptionLocator, DumpProfileReport, DumpRowCountsReport,
15    DumpSchemaReport, Engine, EngineError, EngineOpenError, ExciseReport, Finding, IntegrityReport,
16    MeanRecomputeReport, RebuildKind, RebuildReport, SafeExportArtifact, SchemaObject, Section,
17    TraceReport, TruncateWalReport, TruncateWalStatus, VerifyEmbedderReport, VerifyEmbedderStatus,
18};
19use serde_json::{json, Value};
20
21/// Stable exit-code classes for the operator CLI.
22///
23/// Sourced from `dev/interfaces/cli.md` § Exit-code classes; meanings remain
24/// load-bearing across `recover` + `doctor` outcomes.
25pub mod exit_code {
26    /// Successful completion with no findings that require a non-zero exit.
27    pub const OK: i32 = 0;
28
29    /// `recover` completed only because lossy action was explicitly accepted.
30    pub const RECOVERY_ACCEPTED_LOSS: i32 = 64;
31
32    /// Doctor / verification surface found actionable non-clean state.
33    pub const DOCTOR_FOUND_ISSUES: i32 = 65;
34
35    /// Export / materialization failure on an artifact-producing doctor verb.
36    pub const EXPORT_FAILURE: i32 = 66;
37
38    /// Unrecoverable command failure.
39    pub const UNRECOVERABLE: i32 = 70;
40
41    /// Lock-held or equivalent precondition-blocked outcome.
42    pub const LOCK_HELD: i32 = 71;
43}
44
45/// Top-level CLI invocation.
46#[derive(Debug, Parser)]
47#[command(name = "fathomdb", version, about = "FathomDB operator CLI", long_about = None)]
48pub struct Cli {
49    #[command(subcommand)]
50    pub command: Command,
51}
52
53/// Root command verbs.
54///
55/// 0.6.0 ships exactly two roots per `dev/interfaces/cli.md` § Roots:
56/// `recover` for lossy operator workflows and `doctor` for diagnostics.
57#[derive(Debug, Subcommand)]
58pub enum Command {
59    /// Run a lossy / non-bit-preserving recovery workflow.
60    Recover(RecoverArgs),
61    /// Run an operator diagnostic verb.
62    Doctor(DoctorArgs),
63}
64
65/// Wrapper carrying the doctor verb table beneath the `doctor` root.
66#[derive(Debug, Args)]
67pub struct DoctorArgs {
68    #[command(subcommand)]
69    pub command: DoctorCommand,
70}
71
72/// Argument set for the `recover` root command.
73///
74/// `--accept-data-loss` is declared on this parser only; doctor verbs reject
75/// it as unknown.
76#[derive(Debug, Args)]
77pub struct RecoverArgs {
78    /// Required acknowledgement that the workflow may discard data.
79    #[arg(long)]
80    pub accept_data_loss: bool,
81
82    /// Truncate the SQLite WAL after replay.
83    #[arg(long)]
84    pub truncate_wal: bool,
85
86    /// Rebuild the `vec0` shadow tables from canonical state.
87    #[arg(long)]
88    pub rebuild_vec0: bool,
89
90    /// Rebuild projection materializations.
91    #[arg(long)]
92    pub rebuild_projections: bool,
93
94    /// Excise the named source row from the canonical store.
95    #[arg(long)]
96    pub excise_source: Option<String>,
97
98    /// Emit machine-readable JSON output.
99    #[arg(long)]
100    pub json: bool,
101
102    /// Path to the database file to recover.
103    pub db_path: PathBuf,
104}
105
106/// Doctor verb table per `dev/interfaces/cli.md` § Doctor verbs.
107#[derive(Debug, Subcommand)]
108pub enum DoctorCommand {
109    /// Run a structural integrity check against the database.
110    CheckIntegrity(CheckIntegrityArgs),
111    /// Materialize a safe export of the database.
112    SafeExport(SafeExportArgs),
113    /// Verify the embedder identity recorded in the database.
114    VerifyEmbedder(VerifyEmbedderArgs),
115    /// Trace the resolution chain for a given source reference.
116    Trace(TraceArgs),
117    /// Dump the canonical schema definition.
118    DumpSchema(SimpleDoctorArgs),
119    /// Dump per-table row counts.
120    DumpRowCounts(SimpleDoctorArgs),
121    /// Dump the response-cycle profile recorded by the engine.
122    DumpProfile(SimpleDoctorArgs),
123    /// EU-5b — fetch and verify the pinned default embedder weights so the
124    /// next `Engine::open` with `EmbedderChoice::Default` runs against a
125    /// warm cache without touching the network.
126    WarmCache(WarmCacheArgs),
127    /// 0.7.2 PR-2b — re-derive and re-pin the corpus mean from the current
128    /// vectors, re-quantizing every row in one transaction. Always allowed
129    /// (exempt from the automatic-path 200k cap).
130    RecomputeMean(SimpleDoctorArgs),
131}
132
133/// EU-5b — `fathomdb doctor warm-cache` argument set.
134#[derive(Debug, Args)]
135pub struct WarmCacheArgs {
136    /// Emit machine-readable JSON output.
137    #[arg(long)]
138    pub json: bool,
139}
140
141/// Shared args for doctor verbs whose only options are `--json` and a
142/// required `<db_path>` positional. `cli.md` § Output posture: `--json` is
143/// the normative machine-readable contract on every verb.
144#[derive(Debug, Args)]
145pub struct SimpleDoctorArgs {
146    /// Emit machine-readable JSON output.
147    #[arg(long)]
148    pub json: bool,
149
150    /// Path to the database file to inspect.
151    pub db_path: PathBuf,
152}
153
154/// Per-verb argument set for `doctor check-integrity`.
155#[derive(Debug, Args)]
156pub struct CheckIntegrityArgs {
157    /// Run only the fast integrity probes.
158    #[arg(long)]
159    pub quick: bool,
160
161    /// Run the full per-page integrity sweep.
162    #[arg(long)]
163    pub full: bool,
164
165    /// Confirm round-trip equivalence between canonical + projection state.
166    #[arg(long = "round-trip")]
167    pub round_trip: bool,
168
169    /// Format human output.
170    #[arg(long)]
171    pub pretty: bool,
172
173    /// Emit machine-readable JSON output.
174    #[arg(long)]
175    pub json: bool,
176
177    /// Path to the database file to inspect.
178    pub db_path: PathBuf,
179}
180
181/// Per-verb argument set for `doctor safe-export`.
182#[derive(Debug, Args)]
183pub struct SafeExportArgs {
184    /// Destination path for the exported artifact.
185    pub out: PathBuf,
186
187    /// Optional manifest sidecar describing the exported artifact.
188    #[arg(long)]
189    pub manifest: Option<PathBuf>,
190
191    /// Emit machine-readable JSON output.
192    #[arg(long)]
193    pub json: bool,
194
195    /// Path to the database file to export.
196    pub db_path: PathBuf,
197}
198
199/// Per-verb argument set for `doctor verify-embedder`. `cli.md`
200/// (amended 2026-05-15) locks the invocation as
201/// `verify-embedder --identity <s> --dimension <n> <db_path>`.
202#[derive(Debug, Args)]
203pub struct VerifyEmbedderArgs {
204    /// Stored-embedder identity string the operator expects (typically
205    /// `<name>:<revision>`).
206    #[arg(long)]
207    pub identity: String,
208
209    /// Stored-embedder dimension the operator expects.
210    #[arg(long)]
211    pub dimension: u32,
212
213    /// Emit machine-readable JSON output.
214    #[arg(long)]
215    pub json: bool,
216
217    /// Path to the database file to inspect.
218    pub db_path: PathBuf,
219}
220
221/// Per-verb argument set for `doctor trace`.
222#[derive(Debug, Args)]
223pub struct TraceArgs {
224    /// Source reference to trace.
225    #[arg(long = "source-ref")]
226    pub source_ref: String,
227
228    /// Emit machine-readable JSON output.
229    #[arg(long)]
230    pub json: bool,
231
232    /// Path to the database file to inspect.
233    pub db_path: PathBuf,
234}
235
236/// Outcome classes that map to the stable exit-code matrix in
237/// `dev/interfaces/cli.md` § Exit-code classes.
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub enum CliOutcome {
240    /// Verb completed successfully with no findings.
241    Clean,
242    /// Doctor / verification surface found actionable non-clean state.
243    Findings,
244    /// Export / materialization failure on an artifact-producing doctor verb.
245    ExportFailure,
246    /// `recover` completed only because lossy action was explicitly accepted.
247    RecoveryAcceptedLoss,
248    /// Lock-held or equivalent precondition-blocked outcome.
249    LockHeld,
250    /// Unrecoverable command failure.
251    Unrecoverable,
252}
253
254/// Map an outcome to the stable exit code defined in `cli.md`.
255#[must_use]
256pub fn outcome_to_exit_code(outcome: CliOutcome) -> i32 {
257    match outcome {
258        CliOutcome::Clean => exit_code::OK,
259        CliOutcome::Findings => exit_code::DOCTOR_FOUND_ISSUES,
260        CliOutcome::ExportFailure => exit_code::EXPORT_FAILURE,
261        CliOutcome::RecoveryAcceptedLoss => exit_code::RECOVERY_ACCEPTED_LOSS,
262        CliOutcome::LockHeld => exit_code::LOCK_HELD,
263        CliOutcome::Unrecoverable => exit_code::UNRECOVERABLE,
264    }
265}
266
267/// Map an [`EngineError`] to the [`CliOutcome`] class per
268/// `dev/interfaces/cli.md` § Error to exit-code mapping.
269#[must_use]
270pub fn engine_error_to_outcome(err: &EngineError) -> CliOutcome {
271    match err {
272        EngineError::Closing => CliOutcome::LockHeld,
273        _ => CliOutcome::Unrecoverable,
274    }
275}
276
277/// Map an [`EngineOpenError`] to the [`CliOutcome`] class per
278/// `dev/interfaces/cli.md` § Error to exit-code mapping.
279#[must_use]
280pub fn engine_open_error_to_outcome(err: &EngineOpenError) -> CliOutcome {
281    match err {
282        EngineOpenError::DatabaseLocked { .. } => CliOutcome::LockHeld,
283        _ => CliOutcome::Unrecoverable,
284    }
285}
286
287/// Run a parsed CLI command.
288///
289/// Phase 10a wires the six landed engine seams. The CLI opens the engine
290/// at the verb's `<db_path>`, calls the seam, serializes the typed report
291/// under a `verb`-discriminated JSON envelope, and maps `EngineError` to
292/// the stable exit-code matrix.
293///
294/// `recover` invoked without `--accept-data-loss` is refused at the CLI
295/// layer (no engine call) per `dev/design/recovery.md`: recovery is the
296/// only lossy root and must not proceed without explicit acknowledgement.
297#[must_use]
298pub fn run(cli: Cli) -> i32 {
299    match cli.command {
300        Command::Recover(args) => run_recover(args),
301        Command::Doctor(d) => run_doctor(d.command),
302    }
303}
304
305fn run_recover(args: RecoverArgs) -> i32 {
306    if !args.accept_data_loss {
307        println!(
308            r#"{{"status":"refused","verb":"recover","code":"E_RECOVER_REQUIRES_ACCEPT_DATA_LOSS"}}"#
309        );
310        return exit_code::UNRECOVERABLE;
311    }
312
313    if args.rebuild_projections {
314        return wire_recover(&args.db_path, "rebuild-projections", |e| {
315            e.rebuild_projections().map(|r| rebuild_report_json("rebuild-projections", &r))
316        });
317    }
318    if args.rebuild_vec0 {
319        return wire_recover(&args.db_path, "rebuild-vec0", |e| {
320            e.rebuild_vec0().map(|r| rebuild_report_json("rebuild-vec0", &r))
321        });
322    }
323    if let Some(source_id) = args.excise_source.as_deref() {
324        return wire_recover(&args.db_path, "excise-source", |e| {
325            e.excise_source(source_id).map(|r| excise_report_json(&r))
326        });
327    }
328    if args.truncate_wal {
329        return wire_recover(&args.db_path, "truncate-wal", |e| {
330            e.truncate_wal().map(|r| truncate_wal_report_json(&r))
331        });
332    }
333
334    // No bound sub-action selected → stub.
335    println!(r#"{{"status":"not_implemented","verb":"recover"}}"#);
336    exit_code::UNRECOVERABLE
337}
338
339fn run_doctor(cmd: DoctorCommand) -> i32 {
340    match cmd {
341        DoctorCommand::CheckIntegrity(args) => {
342            let opts = CheckIntegrityOpts {
343                quick: args.quick,
344                full: args.full,
345                round_trip: args.round_trip,
346            };
347            run_doctor_verb(&args.db_path, "check-integrity", |e| {
348                e.check_integrity(opts).map(|r| integrity_report_outcome(&r))
349            })
350        }
351        DoctorCommand::SafeExport(args) => {
352            let manifest = args.manifest.clone().unwrap_or_else(|| {
353                let mut p = args.out.clone();
354                let name = p
355                    .file_name()
356                    .map(|s| s.to_string_lossy().into_owned())
357                    .unwrap_or_else(|| "export".to_string());
358                p.set_file_name(format!("{name}.manifest.json"));
359                p
360            });
361            run_doctor_verb_with_error_outcome(
362                &args.db_path,
363                "safe-export",
364                CliOutcome::ExportFailure,
365                |e| {
366                    e.safe_export(&args.out, &manifest)
367                        .map(|r| (safe_export_json(&r), CliOutcome::Clean))
368                },
369            )
370        }
371        DoctorCommand::Trace(args) => run_doctor_verb(&args.db_path, "trace", |e| {
372            e.trace_source_ref(&args.source_ref).map(|r| (trace_report_json(&r), CliOutcome::Clean))
373        }),
374        DoctorCommand::VerifyEmbedder(args) => {
375            let identity = args.identity.clone();
376            let dimension = args.dimension;
377            run_doctor_verb(&args.db_path, "verify-embedder", |e| {
378                e.verify_embedder(&identity, dimension)
379                    .map(|r| (verify_embedder_report_json(&r), CliOutcome::Clean))
380            })
381        }
382        DoctorCommand::DumpSchema(args) => run_doctor_verb(&args.db_path, "dump-schema", |e| {
383            e.dump_schema().map(|r| (dump_schema_report_json(&r), CliOutcome::Clean))
384        }),
385        DoctorCommand::DumpRowCounts(args) => {
386            run_doctor_verb(&args.db_path, "dump-row-counts", |e| {
387                e.dump_row_counts().map(|r| (dump_row_counts_report_json(&r), CliOutcome::Clean))
388            })
389        }
390        DoctorCommand::DumpProfile(args) => run_doctor_verb(&args.db_path, "dump-profile", |e| {
391            e.dump_profile().map(|r| (dump_profile_report_json(&r), CliOutcome::Clean))
392        }),
393        DoctorCommand::WarmCache(args) => run_doctor_warm_cache(args),
394        DoctorCommand::RecomputeMean(args) => {
395            run_doctor_verb(&args.db_path, "recompute-mean", |e| {
396                e.recompute_mean().map(|r| (recompute_mean_report_json(&r), CliOutcome::Clean))
397            })
398        }
399    }
400}
401
402/// EU-5b — invoke the default-embedder loader directly (no engine open)
403/// so users + CI can warm the on-disk cache before the first
404/// `Engine::open` triggers a download.
405fn run_doctor_warm_cache(args: WarmCacheArgs) -> i32 {
406    #[cfg(feature = "default-embedder")]
407    {
408        match fathomdb_embedder::loader::load_pinned_default_embedder() {
409            Ok(weights) => {
410                if args.json {
411                    let payload = json!({
412                        "verb": "warm-cache",
413                        "status": "ok",
414                        "config_json": weights.config_json_path.to_string_lossy(),
415                        "tokenizer_json": weights.tokenizer_json_path.to_string_lossy(),
416                        "model_safetensors": weights.model_safetensors_path.to_string_lossy(),
417                        "bytes_downloaded": weights.bytes_downloaded,
418                        "events": weights
419                            .events
420                            .iter()
421                            .map(warm_cache_event_json)
422                            .collect::<Vec<_>>(),
423                    });
424                    println!("{payload}");
425                } else {
426                    let kind = if weights.bytes_downloaded > 0 { "cold" } else { "warm" };
427                    println!("warm-cache: ok ({kind})");
428                    println!("  config.json:       {}", weights.config_json_path.display());
429                    println!("  tokenizer.json:    {}", weights.tokenizer_json_path.display());
430                    println!("  model.safetensors: {}", weights.model_safetensors_path.display());
431                    println!("  bytes downloaded:  {}", weights.bytes_downloaded);
432                    println!("  events:            {}", weights.events.len());
433                }
434                exit_code::OK
435            }
436            Err(err) => {
437                if args.json {
438                    let payload = json!({
439                        "verb": "warm-cache",
440                        "status": "error",
441                        "code": "EmbedderLoadError",
442                        "detail": err.to_string(),
443                    });
444                    println!("{payload}");
445                } else {
446                    eprintln!("warm-cache: error: {err}");
447                }
448                exit_code::UNRECOVERABLE
449            }
450        }
451    }
452    #[cfg(not(feature = "default-embedder"))]
453    {
454        let detail = "fathomdb CLI was built without the `default-embedder` feature; rebuild with --features default-embedder";
455        if args.json {
456            let payload = json!({
457                "verb": "warm-cache",
458                "status": "error",
459                "code": "DefaultEmbedderFeatureDisabled",
460                "detail": detail,
461            });
462            println!("{payload}");
463        } else {
464            eprintln!("warm-cache: error: {detail}");
465        }
466        exit_code::UNRECOVERABLE
467    }
468}
469
470#[cfg(feature = "default-embedder")]
471fn warm_cache_event_json(ev: &fathomdb_embedder::EmbedderEvent) -> Value {
472    use fathomdb_embedder::EmbedderEvent;
473    match ev {
474        EmbedderEvent::DefaultEmbedderDownload {
475            file,
476            url,
477            bytes,
478            sha256,
479            cache_path,
480            duration_ms,
481        } => json!({
482            "kind": "download",
483            "file": file,
484            "url": url,
485            "bytes": bytes,
486            "sha256": sha256,
487            "cache_path": cache_path.to_string_lossy(),
488            "duration_ms": duration_ms,
489        }),
490        EmbedderEvent::DefaultEmbedderCacheHit { file, sha256, cache_path } => json!({
491            "kind": "cache_hit",
492            "file": file,
493            "sha256": sha256,
494            "cache_path": cache_path.to_string_lossy(),
495        }),
496        EmbedderEvent::MeanVecPinned { dim, doc_count } => json!({
497            "kind": "mean_vec_pinned",
498            "dim": dim,
499            "doc_count": doc_count,
500        }),
501        EmbedderEvent::MeanVecRecomputed { dim, doc_count, trigger } => json!({
502            "kind": "mean_vec_recomputed",
503            "dim": dim,
504            "doc_count": doc_count,
505            "trigger": trigger.as_str(),
506        }),
507    }
508}
509
510/// Open the engine, invoke `f`, print the resulting JSON value, and map
511/// the outcome to an exit code. The closure returns `(json, outcome)`.
512fn run_doctor_verb<F>(db_path: &std::path::Path, verb: &str, f: F) -> i32
513where
514    F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
515{
516    run_doctor_verb_inner(db_path, verb, None, f)
517}
518
519/// Variant that overrides the `EngineError` → outcome mapping for verbs
520/// with a dedicated failure class (per `cli.md § Error → exit-code
521/// mapping`). Example: `doctor safe-export` maps engine errors to
522/// `ExportFailure` (66), not the default `Unrecoverable` (70).
523fn run_doctor_verb_with_error_outcome<F>(
524    db_path: &std::path::Path,
525    verb: &str,
526    error_outcome: CliOutcome,
527    f: F,
528) -> i32
529where
530    F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
531{
532    run_doctor_verb_inner(db_path, verb, Some(error_outcome), f)
533}
534
535fn run_doctor_verb_inner<F>(
536    db_path: &std::path::Path,
537    verb: &str,
538    error_outcome: Option<CliOutcome>,
539    f: F,
540) -> i32
541where
542    F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
543{
544    let opened = match Engine::open(db_path.to_path_buf()) {
545        Ok(o) => o,
546        Err(err) => return emit_engine_open_error(verb, &err),
547    };
548    match f(&opened.engine) {
549        Ok((value, outcome)) => {
550            println!("{value}");
551            outcome_to_exit_code(outcome)
552        }
553        Err(err) => match error_outcome {
554            Some(outcome) => emit_engine_error_with_outcome(verb, &err, outcome),
555            None => emit_engine_error(verb, &err),
556        },
557    }
558}
559
560/// Open the engine for `recover`, invoke `f`, print the JSON, and map the
561/// outcome to `RECOVERY_ACCEPTED_LOSS` (64) on success.
562fn wire_recover<F>(db_path: &std::path::Path, sub_verb: &str, f: F) -> i32
563where
564    F: FnOnce(&Engine) -> Result<Value, EngineError>,
565{
566    let opened = match Engine::open(db_path.to_path_buf()) {
567        Ok(o) => o,
568        Err(err) => return emit_engine_open_error(sub_verb, &err),
569    };
570    match f(&opened.engine) {
571        Ok(value) => {
572            println!("{value}");
573            outcome_to_exit_code(CliOutcome::RecoveryAcceptedLoss)
574        }
575        Err(err) => emit_engine_error(sub_verb, &err),
576    }
577}
578
579fn emit_engine_error(verb: &str, err: &EngineError) -> i32 {
580    emit_engine_error_with_outcome(verb, err, engine_error_to_outcome(err))
581}
582
583fn emit_engine_error_with_outcome(verb: &str, err: &EngineError, outcome: CliOutcome) -> i32 {
584    let payload = json!({
585        "status": "error",
586        "verb": verb,
587        "code": engine_error_code(err),
588        "detail": err.to_string(),
589    });
590    println!("{payload}");
591    outcome_to_exit_code(outcome)
592}
593
594fn emit_engine_open_error(verb: &str, err: &EngineOpenError) -> i32 {
595    let outcome = engine_open_error_to_outcome(err);
596    let payload = json!({
597        "status": "error",
598        "verb": verb,
599        "code": engine_open_error_code(err),
600        "detail": err.to_string(),
601    });
602    println!("{payload}");
603    outcome_to_exit_code(outcome)
604}
605
606fn engine_error_code(err: &EngineError) -> &'static str {
607    match err {
608        EngineError::Storage => "StorageError",
609        EngineError::Projection => "ProjectionError",
610        EngineError::Vector => "VectorError",
611        EngineError::Embedder => "EmbedderError",
612        EngineError::EmbedderNotConfigured => "EmbedderNotConfiguredError",
613        EngineError::KindNotVectorIndexed => "KindNotVectorIndexedError",
614        EngineError::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
615        EngineError::Scheduler => "SchedulerError",
616        EngineError::OpStore => "OpStoreError",
617        EngineError::WriteValidation => "WriteValidationError",
618        EngineError::SchemaValidation => "SchemaValidationError",
619        EngineError::Overloaded => "OverloadedError",
620        EngineError::Closing => "ClosingError",
621    }
622}
623
624fn engine_open_error_code(err: &EngineOpenError) -> &'static str {
625    match err {
626        EngineOpenError::DatabaseLocked { .. } => "DatabaseLockedError",
627        EngineOpenError::Corruption(_) => "CorruptionError",
628        EngineOpenError::IncompatibleSchemaVersion { .. } => "IncompatibleSchemaVersionError",
629        EngineOpenError::MigrationError { .. } => "MigrationError",
630        EngineOpenError::EmbedderIdentityMismatch { .. } => "EmbedderIdentityMismatchError",
631        EngineOpenError::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
632        EngineOpenError::Embedder(_) => "EmbedderError",
633        EngineOpenError::Io { .. } => "IoError",
634    }
635}
636
637// ---- JSON serializers for engine report types ----
638
639fn integrity_report_outcome(report: &IntegrityReport) -> (Value, CliOutcome) {
640    let any_findings = matches!(report.physical, Section::Findings(_))
641        || matches!(report.logical, Section::Findings(_))
642        || matches!(report.semantic, Section::Findings(_));
643    let body = json!({
644        "verb": "check-integrity",
645        "physical": section_json(&report.physical),
646        "logical": section_json(&report.logical),
647        "semantic": section_json(&report.semantic),
648    });
649    let outcome = if any_findings { CliOutcome::Findings } else { CliOutcome::Clean };
650    (body, outcome)
651}
652
653fn section_json(section: &Section) -> Value {
654    match section {
655        Section::Clean => json!({ "status": "clean", "findings": [] }),
656        Section::Findings(findings) => json!({
657            "status": "findings",
658            "findings": findings.iter().map(finding_json).collect::<Vec<_>>(),
659        }),
660    }
661}
662
663fn finding_json(f: &Finding) -> Value {
664    json!({
665        "code": f.code,
666        "stage": f.stage,
667        "locator": locator_json(&f.locator),
668        "doc_anchor": f.doc_anchor,
669        "detail": f.detail,
670    })
671}
672
673fn locator_json(loc: &CorruptionLocator) -> Value {
674    match loc {
675        CorruptionLocator::FileOffset { offset } => {
676            json!({ "kind": "file_offset", "offset": offset })
677        }
678        CorruptionLocator::PageId { page } => json!({ "kind": "page_id", "page": page }),
679        CorruptionLocator::TableRow { table, rowid } => {
680            json!({ "kind": "table_row", "table": table, "rowid": rowid })
681        }
682        CorruptionLocator::Vec0ShadowRow { partition, rowid } => {
683            json!({ "kind": "vec0_shadow_row", "partition": partition, "rowid": rowid })
684        }
685        CorruptionLocator::MigrationStep { from, to } => {
686            json!({ "kind": "migration_step", "from": from, "to": to })
687        }
688        CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
689            json!({
690                "kind": "opaque_sqlite_error",
691                "sqlite_extended_code": sqlite_extended_code,
692            })
693        }
694    }
695}
696
697fn safe_export_json(a: &SafeExportArtifact) -> Value {
698    json!({
699        "verb": "safe-export",
700        "export_path": a.export_path.to_string_lossy(),
701        "manifest_path": a.manifest_path.to_string_lossy(),
702        "manifest_sha256": a.manifest_sha256,
703    })
704}
705
706fn trace_report_json(t: &TraceReport) -> Value {
707    json!({
708        "verb": "trace",
709        "source_ref": t.source_ref,
710        "events": t.events.iter().map(|e| json!({
711            "write_cursor": e.write_cursor,
712            "kind": e.kind,
713            "table": e.table,
714        })).collect::<Vec<_>>(),
715    })
716}
717
718fn rebuild_report_json(verb: &'static str, r: &RebuildReport) -> Value {
719    let kind = match r.kind {
720        RebuildKind::Projections => "projections",
721        RebuildKind::Vec0 => "vec0",
722    };
723    json!({
724        "verb": verb,
725        "kind": kind,
726        "rows_invalidated": r.rows_invalidated,
727        "rows_rebuilt": r.rows_rebuilt,
728        "projection_cursor_after": r.projection_cursor_after,
729    })
730}
731
732fn excise_report_json(r: &ExciseReport) -> Value {
733    json!({
734        "verb": "excise-source",
735        "source_ref": r.source_ref,
736        "nodes_excised": r.nodes_excised,
737        "edges_excised": r.edges_excised,
738        "projections_invalidated": r.projections_invalidated,
739    })
740}
741
742fn verify_embedder_report_json(r: &VerifyEmbedderReport) -> Value {
743    let status = match r.status {
744        VerifyEmbedderStatus::Match => "match",
745        VerifyEmbedderStatus::IdentityMismatch => "identity_mismatch",
746        VerifyEmbedderStatus::DimensionMismatch => "dimension_mismatch",
747        VerifyEmbedderStatus::BothMismatch => "both_mismatch",
748    };
749    json!({
750        "verb": "verify-embedder",
751        "stored_identity": r.stored_identity,
752        "stored_dimension": r.stored_dimension,
753        "supplied_identity": r.supplied_identity,
754        "supplied_dimension": r.supplied_dimension,
755        "status": status,
756    })
757}
758
759fn schema_object_json(o: &SchemaObject) -> Value {
760    json!({ "name": o.name, "sql": o.sql })
761}
762
763fn dump_schema_report_json(r: &DumpSchemaReport) -> Value {
764    json!({
765        "verb": "dump-schema",
766        "user_version": r.user_version,
767        "tables": r.tables.iter().map(schema_object_json).collect::<Vec<_>>(),
768        "indexes": r.indexes.iter().map(schema_object_json).collect::<Vec<_>>(),
769    })
770}
771
772/// 0.7.2 PR-2b — `doctor recompute-mean` `--json` normative contract.
773fn recompute_mean_report_json(r: &MeanRecomputeReport) -> Value {
774    json!({
775        "verb": "recompute-mean",
776        "status": "ok",
777        "dim": r.dim,
778        "old_doc_count": r.old_doc_count,
779        "doc_count_requantized": r.doc_count_requantized,
780        "drift_cos_before": r.drift_cos_before,
781        "mean_was_pinned": r.mean_was_pinned,
782        "elapsed_ms": r.elapsed_ms,
783    })
784}
785
786fn dump_row_counts_report_json(r: &DumpRowCountsReport) -> Value {
787    json!({
788        "verb": "dump-row-counts",
789        "counts": r.counts.iter().map(|c| json!({
790            "name": c.name,
791            "rows": c.rows,
792        })).collect::<Vec<_>>(),
793    })
794}
795
796fn dump_profile_report_json(r: &DumpProfileReport) -> Value {
797    json!({
798        "verb": "dump-profile",
799        "embedder_identity": r.embedder_identity,
800        "embedder_dimension": r.embedder_dimension,
801        "vectorized_kinds": r.vectorized_kinds,
802    })
803}
804
805fn truncate_wal_report_json(r: &TruncateWalReport) -> Value {
806    let status = match r.status {
807        TruncateWalStatus::Done => "done",
808        TruncateWalStatus::Busy => "busy",
809    };
810    json!({
811        "verb": "truncate-wal",
812        "status": status,
813        "busy": r.busy,
814        "log_frames": r.log_frames,
815        "checkpointed_frames": r.checkpointed_frames,
816    })
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822
823    #[test]
824    fn outcome_mapping_covers_cli_md_exit_classes() {
825        assert_eq!(outcome_to_exit_code(CliOutcome::Clean), 0);
826        assert_eq!(outcome_to_exit_code(CliOutcome::RecoveryAcceptedLoss), 64);
827        assert_eq!(outcome_to_exit_code(CliOutcome::Findings), 65);
828        assert_eq!(outcome_to_exit_code(CliOutcome::ExportFailure), 66);
829        assert_eq!(outcome_to_exit_code(CliOutcome::Unrecoverable), 70);
830        assert_eq!(outcome_to_exit_code(CliOutcome::LockHeld), 71);
831    }
832
833    #[test]
834    fn engine_error_storage_maps_to_unrecoverable() {
835        assert_eq!(engine_error_to_outcome(&EngineError::Storage), CliOutcome::Unrecoverable);
836        assert_eq!(engine_error_to_outcome(&EngineError::Closing), CliOutcome::LockHeld);
837    }
838
839    #[test]
840    fn engine_open_database_locked_maps_to_lock_held() {
841        let err = EngineOpenError::DatabaseLocked { holder_pid: Some(1234) };
842        assert_eq!(engine_open_error_to_outcome(&err), CliOutcome::LockHeld);
843    }
844}