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    RebuildKind, RebuildReport, SafeExportArtifact, SchemaObject, Section, TraceReport,
17    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}
124
125/// Shared args for doctor verbs whose only options are `--json` and a
126/// required `<db_path>` positional. `cli.md` § Output posture: `--json` is
127/// the normative machine-readable contract on every verb.
128#[derive(Debug, Args)]
129pub struct SimpleDoctorArgs {
130    /// Emit machine-readable JSON output.
131    #[arg(long)]
132    pub json: bool,
133
134    /// Path to the database file to inspect.
135    pub db_path: PathBuf,
136}
137
138/// Per-verb argument set for `doctor check-integrity`.
139#[derive(Debug, Args)]
140pub struct CheckIntegrityArgs {
141    /// Run only the fast integrity probes.
142    #[arg(long)]
143    pub quick: bool,
144
145    /// Run the full per-page integrity sweep.
146    #[arg(long)]
147    pub full: bool,
148
149    /// Confirm round-trip equivalence between canonical + projection state.
150    #[arg(long = "round-trip")]
151    pub round_trip: bool,
152
153    /// Format human output.
154    #[arg(long)]
155    pub pretty: bool,
156
157    /// Emit machine-readable JSON output.
158    #[arg(long)]
159    pub json: bool,
160
161    /// Path to the database file to inspect.
162    pub db_path: PathBuf,
163}
164
165/// Per-verb argument set for `doctor safe-export`.
166#[derive(Debug, Args)]
167pub struct SafeExportArgs {
168    /// Destination path for the exported artifact.
169    pub out: PathBuf,
170
171    /// Optional manifest sidecar describing the exported artifact.
172    #[arg(long)]
173    pub manifest: Option<PathBuf>,
174
175    /// Emit machine-readable JSON output.
176    #[arg(long)]
177    pub json: bool,
178
179    /// Path to the database file to export.
180    pub db_path: PathBuf,
181}
182
183/// Per-verb argument set for `doctor verify-embedder`. `cli.md`
184/// (amended 2026-05-15) locks the invocation as
185/// `verify-embedder --identity <s> --dimension <n> <db_path>`.
186#[derive(Debug, Args)]
187pub struct VerifyEmbedderArgs {
188    /// Stored-embedder identity string the operator expects (typically
189    /// `<name>:<revision>`).
190    #[arg(long)]
191    pub identity: String,
192
193    /// Stored-embedder dimension the operator expects.
194    #[arg(long)]
195    pub dimension: u32,
196
197    /// Emit machine-readable JSON output.
198    #[arg(long)]
199    pub json: bool,
200
201    /// Path to the database file to inspect.
202    pub db_path: PathBuf,
203}
204
205/// Per-verb argument set for `doctor trace`.
206#[derive(Debug, Args)]
207pub struct TraceArgs {
208    /// Source reference to trace.
209    #[arg(long = "source-ref")]
210    pub source_ref: String,
211
212    /// Emit machine-readable JSON output.
213    #[arg(long)]
214    pub json: bool,
215
216    /// Path to the database file to inspect.
217    pub db_path: PathBuf,
218}
219
220/// Outcome classes that map to the stable exit-code matrix in
221/// `dev/interfaces/cli.md` § Exit-code classes.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum CliOutcome {
224    /// Verb completed successfully with no findings.
225    Clean,
226    /// Doctor / verification surface found actionable non-clean state.
227    Findings,
228    /// Export / materialization failure on an artifact-producing doctor verb.
229    ExportFailure,
230    /// `recover` completed only because lossy action was explicitly accepted.
231    RecoveryAcceptedLoss,
232    /// Lock-held or equivalent precondition-blocked outcome.
233    LockHeld,
234    /// Unrecoverable command failure.
235    Unrecoverable,
236}
237
238/// Map an outcome to the stable exit code defined in `cli.md`.
239#[must_use]
240pub fn outcome_to_exit_code(outcome: CliOutcome) -> i32 {
241    match outcome {
242        CliOutcome::Clean => exit_code::OK,
243        CliOutcome::Findings => exit_code::DOCTOR_FOUND_ISSUES,
244        CliOutcome::ExportFailure => exit_code::EXPORT_FAILURE,
245        CliOutcome::RecoveryAcceptedLoss => exit_code::RECOVERY_ACCEPTED_LOSS,
246        CliOutcome::LockHeld => exit_code::LOCK_HELD,
247        CliOutcome::Unrecoverable => exit_code::UNRECOVERABLE,
248    }
249}
250
251/// Map an [`EngineError`] to the [`CliOutcome`] class per
252/// `dev/interfaces/cli.md` § Error to exit-code mapping.
253#[must_use]
254pub fn engine_error_to_outcome(err: &EngineError) -> CliOutcome {
255    match err {
256        EngineError::Closing => CliOutcome::LockHeld,
257        _ => CliOutcome::Unrecoverable,
258    }
259}
260
261/// Map an [`EngineOpenError`] to the [`CliOutcome`] class per
262/// `dev/interfaces/cli.md` § Error to exit-code mapping.
263#[must_use]
264pub fn engine_open_error_to_outcome(err: &EngineOpenError) -> CliOutcome {
265    match err {
266        EngineOpenError::DatabaseLocked { .. } => CliOutcome::LockHeld,
267        _ => CliOutcome::Unrecoverable,
268    }
269}
270
271/// Run a parsed CLI command.
272///
273/// Phase 10a wires the six landed engine seams. The CLI opens the engine
274/// at the verb's `<db_path>`, calls the seam, serializes the typed report
275/// under a `verb`-discriminated JSON envelope, and maps `EngineError` to
276/// the stable exit-code matrix.
277///
278/// `recover` invoked without `--accept-data-loss` is refused at the CLI
279/// layer (no engine call) per `dev/design/recovery.md`: recovery is the
280/// only lossy root and must not proceed without explicit acknowledgement.
281#[must_use]
282pub fn run(cli: Cli) -> i32 {
283    match cli.command {
284        Command::Recover(args) => run_recover(args),
285        Command::Doctor(d) => run_doctor(d.command),
286    }
287}
288
289fn run_recover(args: RecoverArgs) -> i32 {
290    if !args.accept_data_loss {
291        println!(
292            r#"{{"status":"refused","verb":"recover","code":"E_RECOVER_REQUIRES_ACCEPT_DATA_LOSS"}}"#
293        );
294        return exit_code::UNRECOVERABLE;
295    }
296
297    if args.rebuild_projections {
298        return wire_recover(&args.db_path, "rebuild-projections", |e| {
299            e.rebuild_projections().map(|r| rebuild_report_json("rebuild-projections", &r))
300        });
301    }
302    if args.rebuild_vec0 {
303        return wire_recover(&args.db_path, "rebuild-vec0", |e| {
304            e.rebuild_vec0().map(|r| rebuild_report_json("rebuild-vec0", &r))
305        });
306    }
307    if let Some(source_id) = args.excise_source.as_deref() {
308        return wire_recover(&args.db_path, "excise-source", |e| {
309            e.excise_source(source_id).map(|r| excise_report_json(&r))
310        });
311    }
312    if args.truncate_wal {
313        return wire_recover(&args.db_path, "truncate-wal", |e| {
314            e.truncate_wal().map(|r| truncate_wal_report_json(&r))
315        });
316    }
317
318    // No bound sub-action selected → stub.
319    println!(r#"{{"status":"not_implemented","verb":"recover"}}"#);
320    exit_code::UNRECOVERABLE
321}
322
323fn run_doctor(cmd: DoctorCommand) -> i32 {
324    match cmd {
325        DoctorCommand::CheckIntegrity(args) => {
326            let opts = CheckIntegrityOpts {
327                quick: args.quick,
328                full: args.full,
329                round_trip: args.round_trip,
330            };
331            run_doctor_verb(&args.db_path, "check-integrity", |e| {
332                e.check_integrity(opts).map(|r| integrity_report_outcome(&r))
333            })
334        }
335        DoctorCommand::SafeExport(args) => {
336            let manifest = args.manifest.clone().unwrap_or_else(|| {
337                let mut p = args.out.clone();
338                let name = p
339                    .file_name()
340                    .map(|s| s.to_string_lossy().into_owned())
341                    .unwrap_or_else(|| "export".to_string());
342                p.set_file_name(format!("{name}.manifest.json"));
343                p
344            });
345            run_doctor_verb_with_error_outcome(
346                &args.db_path,
347                "safe-export",
348                CliOutcome::ExportFailure,
349                |e| {
350                    e.safe_export(&args.out, &manifest)
351                        .map(|r| (safe_export_json(&r), CliOutcome::Clean))
352                },
353            )
354        }
355        DoctorCommand::Trace(args) => run_doctor_verb(&args.db_path, "trace", |e| {
356            e.trace_source_ref(&args.source_ref).map(|r| (trace_report_json(&r), CliOutcome::Clean))
357        }),
358        DoctorCommand::VerifyEmbedder(args) => {
359            let identity = args.identity.clone();
360            let dimension = args.dimension;
361            run_doctor_verb(&args.db_path, "verify-embedder", |e| {
362                e.verify_embedder(&identity, dimension)
363                    .map(|r| (verify_embedder_report_json(&r), CliOutcome::Clean))
364            })
365        }
366        DoctorCommand::DumpSchema(args) => run_doctor_verb(&args.db_path, "dump-schema", |e| {
367            e.dump_schema().map(|r| (dump_schema_report_json(&r), CliOutcome::Clean))
368        }),
369        DoctorCommand::DumpRowCounts(args) => {
370            run_doctor_verb(&args.db_path, "dump-row-counts", |e| {
371                e.dump_row_counts().map(|r| (dump_row_counts_report_json(&r), CliOutcome::Clean))
372            })
373        }
374        DoctorCommand::DumpProfile(args) => run_doctor_verb(&args.db_path, "dump-profile", |e| {
375            e.dump_profile().map(|r| (dump_profile_report_json(&r), CliOutcome::Clean))
376        }),
377    }
378}
379
380/// Open the engine, invoke `f`, print the resulting JSON value, and map
381/// the outcome to an exit code. The closure returns `(json, outcome)`.
382fn run_doctor_verb<F>(db_path: &std::path::Path, verb: &str, f: F) -> i32
383where
384    F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
385{
386    run_doctor_verb_inner(db_path, verb, None, f)
387}
388
389/// Variant that overrides the `EngineError` → outcome mapping for verbs
390/// with a dedicated failure class (per `cli.md § Error → exit-code
391/// mapping`). Example: `doctor safe-export` maps engine errors to
392/// `ExportFailure` (66), not the default `Unrecoverable` (70).
393fn run_doctor_verb_with_error_outcome<F>(
394    db_path: &std::path::Path,
395    verb: &str,
396    error_outcome: CliOutcome,
397    f: F,
398) -> i32
399where
400    F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
401{
402    run_doctor_verb_inner(db_path, verb, Some(error_outcome), f)
403}
404
405fn run_doctor_verb_inner<F>(
406    db_path: &std::path::Path,
407    verb: &str,
408    error_outcome: Option<CliOutcome>,
409    f: F,
410) -> i32
411where
412    F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
413{
414    let opened = match Engine::open(db_path.to_path_buf()) {
415        Ok(o) => o,
416        Err(err) => return emit_engine_open_error(verb, &err),
417    };
418    match f(&opened.engine) {
419        Ok((value, outcome)) => {
420            println!("{value}");
421            outcome_to_exit_code(outcome)
422        }
423        Err(err) => match error_outcome {
424            Some(outcome) => emit_engine_error_with_outcome(verb, &err, outcome),
425            None => emit_engine_error(verb, &err),
426        },
427    }
428}
429
430/// Open the engine for `recover`, invoke `f`, print the JSON, and map the
431/// outcome to `RECOVERY_ACCEPTED_LOSS` (64) on success.
432fn wire_recover<F>(db_path: &std::path::Path, sub_verb: &str, f: F) -> i32
433where
434    F: FnOnce(&Engine) -> Result<Value, EngineError>,
435{
436    let opened = match Engine::open(db_path.to_path_buf()) {
437        Ok(o) => o,
438        Err(err) => return emit_engine_open_error(sub_verb, &err),
439    };
440    match f(&opened.engine) {
441        Ok(value) => {
442            println!("{value}");
443            outcome_to_exit_code(CliOutcome::RecoveryAcceptedLoss)
444        }
445        Err(err) => emit_engine_error(sub_verb, &err),
446    }
447}
448
449fn emit_engine_error(verb: &str, err: &EngineError) -> i32 {
450    emit_engine_error_with_outcome(verb, err, engine_error_to_outcome(err))
451}
452
453fn emit_engine_error_with_outcome(verb: &str, err: &EngineError, outcome: CliOutcome) -> i32 {
454    let payload = json!({
455        "status": "error",
456        "verb": verb,
457        "code": engine_error_code(err),
458        "detail": err.to_string(),
459    });
460    println!("{payload}");
461    outcome_to_exit_code(outcome)
462}
463
464fn emit_engine_open_error(verb: &str, err: &EngineOpenError) -> i32 {
465    let outcome = engine_open_error_to_outcome(err);
466    let payload = json!({
467        "status": "error",
468        "verb": verb,
469        "code": engine_open_error_code(err),
470        "detail": err.to_string(),
471    });
472    println!("{payload}");
473    outcome_to_exit_code(outcome)
474}
475
476fn engine_error_code(err: &EngineError) -> &'static str {
477    match err {
478        EngineError::Storage => "StorageError",
479        EngineError::Projection => "ProjectionError",
480        EngineError::Vector => "VectorError",
481        EngineError::Embedder => "EmbedderError",
482        EngineError::EmbedderNotConfigured => "EmbedderNotConfiguredError",
483        EngineError::KindNotVectorIndexed => "KindNotVectorIndexedError",
484        EngineError::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
485        EngineError::Scheduler => "SchedulerError",
486        EngineError::OpStore => "OpStoreError",
487        EngineError::WriteValidation => "WriteValidationError",
488        EngineError::SchemaValidation => "SchemaValidationError",
489        EngineError::Overloaded => "OverloadedError",
490        EngineError::Closing => "ClosingError",
491    }
492}
493
494fn engine_open_error_code(err: &EngineOpenError) -> &'static str {
495    match err {
496        EngineOpenError::DatabaseLocked { .. } => "DatabaseLockedError",
497        EngineOpenError::Corruption(_) => "CorruptionError",
498        EngineOpenError::IncompatibleSchemaVersion { .. } => "IncompatibleSchemaVersionError",
499        EngineOpenError::MigrationError { .. } => "MigrationError",
500        EngineOpenError::EmbedderIdentityMismatch { .. } => "EmbedderIdentityMismatchError",
501        EngineOpenError::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
502        EngineOpenError::Io { .. } => "IoError",
503    }
504}
505
506// ---- JSON serializers for engine report types ----
507
508fn integrity_report_outcome(report: &IntegrityReport) -> (Value, CliOutcome) {
509    let any_findings = matches!(report.physical, Section::Findings(_))
510        || matches!(report.logical, Section::Findings(_))
511        || matches!(report.semantic, Section::Findings(_));
512    let body = json!({
513        "verb": "check-integrity",
514        "physical": section_json(&report.physical),
515        "logical": section_json(&report.logical),
516        "semantic": section_json(&report.semantic),
517    });
518    let outcome = if any_findings { CliOutcome::Findings } else { CliOutcome::Clean };
519    (body, outcome)
520}
521
522fn section_json(section: &Section) -> Value {
523    match section {
524        Section::Clean => json!({ "status": "clean", "findings": [] }),
525        Section::Findings(findings) => json!({
526            "status": "findings",
527            "findings": findings.iter().map(finding_json).collect::<Vec<_>>(),
528        }),
529    }
530}
531
532fn finding_json(f: &Finding) -> Value {
533    json!({
534        "code": f.code,
535        "stage": f.stage,
536        "locator": locator_json(&f.locator),
537        "doc_anchor": f.doc_anchor,
538        "detail": f.detail,
539    })
540}
541
542fn locator_json(loc: &CorruptionLocator) -> Value {
543    match loc {
544        CorruptionLocator::FileOffset { offset } => {
545            json!({ "kind": "file_offset", "offset": offset })
546        }
547        CorruptionLocator::PageId { page } => json!({ "kind": "page_id", "page": page }),
548        CorruptionLocator::TableRow { table, rowid } => {
549            json!({ "kind": "table_row", "table": table, "rowid": rowid })
550        }
551        CorruptionLocator::Vec0ShadowRow { partition, rowid } => {
552            json!({ "kind": "vec0_shadow_row", "partition": partition, "rowid": rowid })
553        }
554        CorruptionLocator::MigrationStep { from, to } => {
555            json!({ "kind": "migration_step", "from": from, "to": to })
556        }
557        CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
558            json!({
559                "kind": "opaque_sqlite_error",
560                "sqlite_extended_code": sqlite_extended_code,
561            })
562        }
563    }
564}
565
566fn safe_export_json(a: &SafeExportArtifact) -> Value {
567    json!({
568        "verb": "safe-export",
569        "export_path": a.export_path.to_string_lossy(),
570        "manifest_path": a.manifest_path.to_string_lossy(),
571        "manifest_sha256": a.manifest_sha256,
572    })
573}
574
575fn trace_report_json(t: &TraceReport) -> Value {
576    json!({
577        "verb": "trace",
578        "source_ref": t.source_ref,
579        "events": t.events.iter().map(|e| json!({
580            "write_cursor": e.write_cursor,
581            "kind": e.kind,
582            "table": e.table,
583        })).collect::<Vec<_>>(),
584    })
585}
586
587fn rebuild_report_json(verb: &'static str, r: &RebuildReport) -> Value {
588    let kind = match r.kind {
589        RebuildKind::Projections => "projections",
590        RebuildKind::Vec0 => "vec0",
591    };
592    json!({
593        "verb": verb,
594        "kind": kind,
595        "rows_invalidated": r.rows_invalidated,
596        "rows_rebuilt": r.rows_rebuilt,
597        "projection_cursor_after": r.projection_cursor_after,
598    })
599}
600
601fn excise_report_json(r: &ExciseReport) -> Value {
602    json!({
603        "verb": "excise-source",
604        "source_ref": r.source_ref,
605        "nodes_excised": r.nodes_excised,
606        "edges_excised": r.edges_excised,
607        "projections_invalidated": r.projections_invalidated,
608    })
609}
610
611fn verify_embedder_report_json(r: &VerifyEmbedderReport) -> Value {
612    let status = match r.status {
613        VerifyEmbedderStatus::Match => "match",
614        VerifyEmbedderStatus::IdentityMismatch => "identity_mismatch",
615        VerifyEmbedderStatus::DimensionMismatch => "dimension_mismatch",
616        VerifyEmbedderStatus::BothMismatch => "both_mismatch",
617    };
618    json!({
619        "verb": "verify-embedder",
620        "stored_identity": r.stored_identity,
621        "stored_dimension": r.stored_dimension,
622        "supplied_identity": r.supplied_identity,
623        "supplied_dimension": r.supplied_dimension,
624        "status": status,
625    })
626}
627
628fn schema_object_json(o: &SchemaObject) -> Value {
629    json!({ "name": o.name, "sql": o.sql })
630}
631
632fn dump_schema_report_json(r: &DumpSchemaReport) -> Value {
633    json!({
634        "verb": "dump-schema",
635        "user_version": r.user_version,
636        "tables": r.tables.iter().map(schema_object_json).collect::<Vec<_>>(),
637        "indexes": r.indexes.iter().map(schema_object_json).collect::<Vec<_>>(),
638    })
639}
640
641fn dump_row_counts_report_json(r: &DumpRowCountsReport) -> Value {
642    json!({
643        "verb": "dump-row-counts",
644        "counts": r.counts.iter().map(|c| json!({
645            "name": c.name,
646            "rows": c.rows,
647        })).collect::<Vec<_>>(),
648    })
649}
650
651fn dump_profile_report_json(r: &DumpProfileReport) -> Value {
652    json!({
653        "verb": "dump-profile",
654        "embedder_identity": r.embedder_identity,
655        "embedder_dimension": r.embedder_dimension,
656        "vectorized_kinds": r.vectorized_kinds,
657    })
658}
659
660fn truncate_wal_report_json(r: &TruncateWalReport) -> Value {
661    let status = match r.status {
662        TruncateWalStatus::Done => "done",
663        TruncateWalStatus::Busy => "busy",
664    };
665    json!({
666        "verb": "truncate-wal",
667        "status": status,
668        "busy": r.busy,
669        "log_frames": r.log_frames,
670        "checkpointed_frames": r.checkpointed_frames,
671    })
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    #[test]
679    fn outcome_mapping_covers_cli_md_exit_classes() {
680        assert_eq!(outcome_to_exit_code(CliOutcome::Clean), 0);
681        assert_eq!(outcome_to_exit_code(CliOutcome::RecoveryAcceptedLoss), 64);
682        assert_eq!(outcome_to_exit_code(CliOutcome::Findings), 65);
683        assert_eq!(outcome_to_exit_code(CliOutcome::ExportFailure), 66);
684        assert_eq!(outcome_to_exit_code(CliOutcome::Unrecoverable), 70);
685        assert_eq!(outcome_to_exit_code(CliOutcome::LockHeld), 71);
686    }
687
688    #[test]
689    fn engine_error_storage_maps_to_unrecoverable() {
690        assert_eq!(engine_error_to_outcome(&EngineError::Storage), CliOutcome::Unrecoverable);
691        assert_eq!(engine_error_to_outcome(&EngineError::Closing), CliOutcome::LockHeld);
692    }
693
694    #[test]
695    fn engine_open_database_locked_maps_to_lock_held() {
696        let err = EngineOpenError::DatabaseLocked { holder_pid: Some(1234) };
697        assert_eq!(engine_open_error_to_outcome(&err), CliOutcome::LockHeld);
698    }
699}