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    /// Slice 34 (F4-READ / reserved-gap-34) — read back op-store
132    /// (`operational_mutations`) rows for one `append_only_log` collection
133    /// over the existing `Engine::read_mutations` seam. A read-only operator
134    /// diagnostic over the mutation log (the `dump-*` family, per
135    /// `ADR-0.6.0-cli-scope`), NOT the rejected `search`/`get`/`list`
136    /// application query surface. CLI-only; no SDK parity.
137    DumpMutations(DumpMutationsArgs),
138}
139
140/// EU-5b — `fathomdb doctor warm-cache` argument set.
141#[derive(Debug, Args)]
142pub struct WarmCacheArgs {
143    /// Emit machine-readable JSON output.
144    #[arg(long)]
145    pub json: bool,
146}
147
148/// Shared args for doctor verbs whose only options are `--json` and a
149/// required `<db_path>` positional. `cli.md` § Output posture: `--json` is
150/// the normative machine-readable contract on every verb.
151#[derive(Debug, Args)]
152pub struct SimpleDoctorArgs {
153    /// Emit machine-readable JSON output.
154    #[arg(long)]
155    pub json: bool,
156
157    /// Path to the database file to inspect.
158    pub db_path: PathBuf,
159}
160
161/// Default `--limit` page size for `doctor dump-mutations` when the operator
162/// omits it. A sane page that bounds output; the engine still clamps the
163/// effective SQL `LIMIT` to `READ_COLLECTION_MAX_LIMIT` (~1M), so no read is
164/// ever unbounded. See `dev/design/slice-34-cli-op-store-readback-design.md`.
165const DUMP_MUTATIONS_DEFAULT_LIMIT: usize = 1000;
166
167/// CLI-side mirror of the engine's `read_collection`/`read_mutations` page cap
168/// (`READ_COLLECTION_MAX_LIMIT`, ~1M — private to `fathomdb-engine`). The CLI
169/// clamps `--limit` to the SAME value via [`effective_dump_limit`] so the
170/// `next_after_id` "full page ⇒ maybe more" decision compares `rows.len()`
171/// against the EFFECTIVE limit the engine actually honors. Without this mirror,
172/// a `--limit` above the engine cap would make a full capped page
173/// (`rows.len() == cap < requested`) look exhausted → `next_after_id: null` →
174/// pagination would silently stop while rows remain. Keep in lockstep with
175/// `fathomdb-engine`'s `READ_COLLECTION_MAX_LIMIT`.
176const DUMP_MUTATIONS_MAX_LIMIT: usize = 1_000_000;
177
178/// Resolve the effective `doctor dump-mutations` page limit: the operator's
179/// `--limit` (or the default when omitted), clamped to the engine page cap
180/// [`DUMP_MUTATIONS_MAX_LIMIT`]. Pure + total so the clamp is unit-pinned
181/// without seeding a >1M-row log (`tests/parser.rs`).
182#[must_use]
183pub fn effective_dump_limit(requested: Option<usize>) -> usize {
184    requested.unwrap_or(DUMP_MUTATIONS_DEFAULT_LIMIT).min(DUMP_MUTATIONS_MAX_LIMIT)
185}
186
187/// Slice 34 — argument set for `doctor dump-mutations <collection>
188/// [--after-id <n>] [--limit <n>] [--json] <db_path>`. A read-only operator
189/// diagnostic that pages the op-store mutation log over the existing
190/// `Engine::read_mutations` seam.
191#[derive(Debug, Args)]
192pub struct DumpMutationsArgs {
193    /// The `append_only_log` collection whose appended rows to read back.
194    pub collection: String,
195
196    /// Exclusive cursor: return only rows with `id` strictly greater than this
197    /// value. A negative value is normalized to the start of the log; a value
198    /// past the last id yields an empty page.
199    #[arg(long = "after-id")]
200    pub after_id: Option<i64>,
201
202    /// Maximum rows in this page (default 1000). The engine clamps the
203    /// effective SQL `LIMIT` to the ~1M cap, so the read is never unbounded.
204    #[arg(long)]
205    pub limit: Option<usize>,
206
207    /// Emit machine-readable JSON output.
208    #[arg(long)]
209    pub json: bool,
210
211    /// Path to the database file to inspect.
212    pub db_path: PathBuf,
213}
214
215/// Per-verb argument set for `doctor check-integrity`.
216#[derive(Debug, Args)]
217pub struct CheckIntegrityArgs {
218    /// Run only the fast integrity probes.
219    #[arg(long)]
220    pub quick: bool,
221
222    /// Run the full per-page integrity sweep.
223    #[arg(long)]
224    pub full: bool,
225
226    /// Confirm round-trip equivalence between canonical + projection state.
227    #[arg(long = "round-trip")]
228    pub round_trip: bool,
229
230    /// Format human output.
231    #[arg(long)]
232    pub pretty: bool,
233
234    /// Emit machine-readable JSON output.
235    #[arg(long)]
236    pub json: bool,
237
238    /// Path to the database file to inspect.
239    pub db_path: PathBuf,
240}
241
242/// Per-verb argument set for `doctor safe-export`.
243#[derive(Debug, Args)]
244pub struct SafeExportArgs {
245    /// Destination path for the exported artifact.
246    pub out: PathBuf,
247
248    /// Optional manifest sidecar describing the exported artifact.
249    #[arg(long)]
250    pub manifest: Option<PathBuf>,
251
252    /// Emit machine-readable JSON output.
253    #[arg(long)]
254    pub json: bool,
255
256    /// Path to the database file to export.
257    pub db_path: PathBuf,
258}
259
260/// Per-verb argument set for `doctor verify-embedder`. `cli.md`
261/// (amended 2026-05-15) locks the invocation as
262/// `verify-embedder --identity <s> --dimension <n> <db_path>`.
263#[derive(Debug, Args)]
264pub struct VerifyEmbedderArgs {
265    /// Stored-embedder identity string the operator expects (typically
266    /// `<name>:<revision>`).
267    #[arg(long)]
268    pub identity: String,
269
270    /// Stored-embedder dimension the operator expects.
271    #[arg(long)]
272    pub dimension: u32,
273
274    /// Emit machine-readable JSON output.
275    #[arg(long)]
276    pub json: bool,
277
278    /// Path to the database file to inspect.
279    pub db_path: PathBuf,
280}
281
282/// Per-verb argument set for `doctor trace`.
283#[derive(Debug, Args)]
284pub struct TraceArgs {
285    /// Source reference to trace.
286    #[arg(long = "source-ref")]
287    pub source_ref: String,
288
289    /// Emit machine-readable JSON output.
290    #[arg(long)]
291    pub json: bool,
292
293    /// Path to the database file to inspect.
294    pub db_path: PathBuf,
295}
296
297/// Outcome classes that map to the stable exit-code matrix in
298/// `dev/interfaces/cli.md` § Exit-code classes.
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum CliOutcome {
301    /// Verb completed successfully with no findings.
302    Clean,
303    /// Doctor / verification surface found actionable non-clean state.
304    Findings,
305    /// Export / materialization failure on an artifact-producing doctor verb.
306    ExportFailure,
307    /// `recover` completed only because lossy action was explicitly accepted.
308    RecoveryAcceptedLoss,
309    /// Lock-held or equivalent precondition-blocked outcome.
310    LockHeld,
311    /// Unrecoverable command failure.
312    Unrecoverable,
313}
314
315/// Map an outcome to the stable exit code defined in `cli.md`.
316#[must_use]
317pub fn outcome_to_exit_code(outcome: CliOutcome) -> i32 {
318    match outcome {
319        CliOutcome::Clean => exit_code::OK,
320        CliOutcome::Findings => exit_code::DOCTOR_FOUND_ISSUES,
321        CliOutcome::ExportFailure => exit_code::EXPORT_FAILURE,
322        CliOutcome::RecoveryAcceptedLoss => exit_code::RECOVERY_ACCEPTED_LOSS,
323        CliOutcome::LockHeld => exit_code::LOCK_HELD,
324        CliOutcome::Unrecoverable => exit_code::UNRECOVERABLE,
325    }
326}
327
328/// Map an [`EngineError`] to the [`CliOutcome`] class per
329/// `dev/interfaces/cli.md` § Error to exit-code mapping.
330#[must_use]
331pub fn engine_error_to_outcome(err: &EngineError) -> CliOutcome {
332    match err {
333        EngineError::Closing => CliOutcome::LockHeld,
334        _ => CliOutcome::Unrecoverable,
335    }
336}
337
338/// Map an [`EngineOpenError`] to the [`CliOutcome`] class per
339/// `dev/interfaces/cli.md` § Error to exit-code mapping.
340#[must_use]
341pub fn engine_open_error_to_outcome(err: &EngineOpenError) -> CliOutcome {
342    match err {
343        EngineOpenError::DatabaseLocked { .. } => CliOutcome::LockHeld,
344        _ => CliOutcome::Unrecoverable,
345    }
346}
347
348/// Run a parsed CLI command.
349///
350/// Phase 10a wires the six landed engine seams. The CLI opens the engine
351/// at the verb's `<db_path>`, calls the seam, serializes the typed report
352/// under a `verb`-discriminated JSON envelope, and maps `EngineError` to
353/// the stable exit-code matrix.
354///
355/// `recover` invoked without `--accept-data-loss` is refused at the CLI
356/// layer (no engine call) per `dev/design/recovery.md`: recovery is the
357/// only lossy root and must not proceed without explicit acknowledgement.
358#[must_use]
359pub fn run(cli: Cli) -> i32 {
360    match cli.command {
361        Command::Recover(args) => run_recover(args),
362        Command::Doctor(d) => run_doctor(d.command),
363    }
364}
365
366fn run_recover(args: RecoverArgs) -> i32 {
367    if !args.accept_data_loss {
368        println!(
369            r#"{{"status":"refused","verb":"recover","code":"E_RECOVER_REQUIRES_ACCEPT_DATA_LOSS"}}"#
370        );
371        return exit_code::UNRECOVERABLE;
372    }
373
374    if args.rebuild_projections {
375        return wire_recover(&args.db_path, "rebuild-projections", |e| {
376            e.rebuild_projections().map(|r| rebuild_report_json("rebuild-projections", &r))
377        });
378    }
379    if args.rebuild_vec0 {
380        return wire_recover(&args.db_path, "rebuild-vec0", |e| {
381            e.rebuild_vec0().map(|r| rebuild_report_json("rebuild-vec0", &r))
382        });
383    }
384    if let Some(source_id) = args.excise_source.as_deref() {
385        return wire_recover(&args.db_path, "excise-source", |e| {
386            e.excise_source(source_id).map(|r| excise_report_json(&r))
387        });
388    }
389    if args.truncate_wal {
390        return wire_recover(&args.db_path, "truncate-wal", |e| {
391            e.truncate_wal().map(|r| truncate_wal_report_json(&r))
392        });
393    }
394
395    // No bound sub-action selected → stub.
396    println!(r#"{{"status":"not_implemented","verb":"recover"}}"#);
397    exit_code::UNRECOVERABLE
398}
399
400fn run_doctor(cmd: DoctorCommand) -> i32 {
401    match cmd {
402        DoctorCommand::CheckIntegrity(args) => {
403            let opts = CheckIntegrityOpts {
404                quick: args.quick,
405                full: args.full,
406                round_trip: args.round_trip,
407            };
408            run_doctor_verb(&args.db_path, "check-integrity", |e| {
409                e.check_integrity(opts).map(|r| integrity_report_outcome(&r))
410            })
411        }
412        DoctorCommand::SafeExport(args) => {
413            let manifest = args.manifest.clone().unwrap_or_else(|| {
414                let mut p = args.out.clone();
415                let name = p
416                    .file_name()
417                    .map(|s| s.to_string_lossy().into_owned())
418                    .unwrap_or_else(|| "export".to_string());
419                p.set_file_name(format!("{name}.manifest.json"));
420                p
421            });
422            run_doctor_verb_with_error_outcome(
423                &args.db_path,
424                "safe-export",
425                CliOutcome::ExportFailure,
426                |e| {
427                    e.safe_export(&args.out, &manifest)
428                        .map(|r| (safe_export_json(&r), CliOutcome::Clean))
429                },
430            )
431        }
432        DoctorCommand::Trace(args) => run_doctor_verb(&args.db_path, "trace", |e| {
433            e.trace_source_ref(&args.source_ref).map(|r| (trace_report_json(&r), CliOutcome::Clean))
434        }),
435        DoctorCommand::VerifyEmbedder(args) => {
436            let identity = args.identity.clone();
437            let dimension = args.dimension;
438            run_doctor_verb(&args.db_path, "verify-embedder", |e| {
439                e.verify_embedder(&identity, dimension)
440                    .map(|r| (verify_embedder_report_json(&r), CliOutcome::Clean))
441            })
442        }
443        DoctorCommand::DumpSchema(args) => run_doctor_verb(&args.db_path, "dump-schema", |e| {
444            e.dump_schema().map(|r| (dump_schema_report_json(&r), CliOutcome::Clean))
445        }),
446        DoctorCommand::DumpRowCounts(args) => {
447            run_doctor_verb(&args.db_path, "dump-row-counts", |e| {
448                e.dump_row_counts().map(|r| (dump_row_counts_report_json(&r), CliOutcome::Clean))
449            })
450        }
451        DoctorCommand::DumpProfile(args) => run_doctor_verb(&args.db_path, "dump-profile", |e| {
452            e.dump_profile().map(|r| (dump_profile_report_json(&r), CliOutcome::Clean))
453        }),
454        DoctorCommand::WarmCache(args) => run_doctor_warm_cache(args),
455        DoctorCommand::RecomputeMean(args) => {
456            run_doctor_verb(&args.db_path, "recompute-mean", |e| {
457                e.recompute_mean().map(|r| (recompute_mean_report_json(&r), CliOutcome::Clean))
458            })
459        }
460        DoctorCommand::DumpMutations(args) => {
461            let limit = effective_dump_limit(args.limit);
462            run_doctor_verb(&args.db_path, "dump-mutations", |e| {
463                // Read over the EXISTING Slice-30 seam (Slice-33 index-driven).
464                // The rows are serialized INLINE below so `OpStoreRow` is never
465                // named / re-exported — the facade public-type set is untouched.
466                e.read_mutations(&args.collection, args.after_id, limit).map(|rows| {
467                    let row_values = rows
468                        .iter()
469                        .map(|r| {
470                            json!({
471                                "id": r.id,
472                                "collection": r.collection,
473                                "record_key": r.record_key,
474                                "op_kind": r.op_kind,
475                                "payload": r.payload,
476                                "schema_id": r.schema_id,
477                                "write_cursor": r.write_cursor,
478                            })
479                        })
480                        .collect::<Vec<_>>();
481                    // `next_after_id` = the last row's id iff a full page was
482                    // returned (more rows may follow); else null (the log is
483                    // exhausted at this cursor). The engine cursor is exclusive,
484                    // so resuming with `--after-id <next_after_id>` never overlaps.
485                    let next_after_id =
486                        if rows.len() == limit { rows.last().map(|r| r.id) } else { None };
487                    let body = json!({
488                        "verb": "dump-mutations",
489                        "collection": args.collection,
490                        "after_id": args.after_id,
491                        "limit": limit,
492                        "count": row_values.len(),
493                        "rows": row_values,
494                        "next_after_id": next_after_id,
495                    });
496                    (body, CliOutcome::Clean)
497                })
498            })
499        }
500    }
501}
502
503/// EU-5b — invoke the default-embedder loader directly (no engine open)
504/// so users + CI can warm the on-disk cache before the first
505/// `Engine::open` triggers a download.
506fn run_doctor_warm_cache(args: WarmCacheArgs) -> i32 {
507    #[cfg(feature = "default-embedder")]
508    {
509        match fathomdb_embedder::loader::load_pinned_default_embedder() {
510            Ok(weights) => {
511                if args.json {
512                    let payload = json!({
513                        "verb": "warm-cache",
514                        "status": "ok",
515                        "config_json": weights.config_json_path.to_string_lossy(),
516                        "tokenizer_json": weights.tokenizer_json_path.to_string_lossy(),
517                        "model_safetensors": weights.model_safetensors_path.to_string_lossy(),
518                        "bytes_downloaded": weights.bytes_downloaded,
519                        "events": weights
520                            .events
521                            .iter()
522                            .map(warm_cache_event_json)
523                            .collect::<Vec<_>>(),
524                    });
525                    println!("{payload}");
526                } else {
527                    let kind = if weights.bytes_downloaded > 0 { "cold" } else { "warm" };
528                    println!("warm-cache: ok ({kind})");
529                    println!("  config.json:       {}", weights.config_json_path.display());
530                    println!("  tokenizer.json:    {}", weights.tokenizer_json_path.display());
531                    println!("  model.safetensors: {}", weights.model_safetensors_path.display());
532                    println!("  bytes downloaded:  {}", weights.bytes_downloaded);
533                    println!("  events:            {}", weights.events.len());
534                }
535                exit_code::OK
536            }
537            Err(err) => {
538                if args.json {
539                    let payload = json!({
540                        "verb": "warm-cache",
541                        "status": "error",
542                        "code": "EmbedderLoadError",
543                        "detail": err.to_string(),
544                    });
545                    println!("{payload}");
546                } else {
547                    eprintln!("warm-cache: error: {err}");
548                }
549                exit_code::UNRECOVERABLE
550            }
551        }
552    }
553    #[cfg(not(feature = "default-embedder"))]
554    {
555        let detail = "fathomdb CLI was built without the `default-embedder` feature; rebuild with --features default-embedder";
556        if args.json {
557            let payload = json!({
558                "verb": "warm-cache",
559                "status": "error",
560                "code": "DefaultEmbedderFeatureDisabled",
561                "detail": detail,
562            });
563            println!("{payload}");
564        } else {
565            eprintln!("warm-cache: error: {detail}");
566        }
567        exit_code::UNRECOVERABLE
568    }
569}
570
571#[cfg(feature = "default-embedder")]
572fn warm_cache_event_json(ev: &fathomdb_embedder::EmbedderEvent) -> Value {
573    use fathomdb_embedder::EmbedderEvent;
574    match ev {
575        EmbedderEvent::DefaultEmbedderDownload {
576            file,
577            url,
578            bytes,
579            sha256,
580            cache_path,
581            duration_ms,
582        } => json!({
583            "kind": "download",
584            "file": file,
585            "url": url,
586            "bytes": bytes,
587            "sha256": sha256,
588            "cache_path": cache_path.to_string_lossy(),
589            "duration_ms": duration_ms,
590        }),
591        EmbedderEvent::DefaultEmbedderCacheHit { file, sha256, cache_path } => json!({
592            "kind": "cache_hit",
593            "file": file,
594            "sha256": sha256,
595            "cache_path": cache_path.to_string_lossy(),
596        }),
597        EmbedderEvent::MeanVecPinned { dim, doc_count } => json!({
598            "kind": "mean_vec_pinned",
599            "dim": dim,
600            "doc_count": doc_count,
601        }),
602        EmbedderEvent::MeanVecRecomputed { dim, doc_count, trigger } => json!({
603            "kind": "mean_vec_recomputed",
604            "dim": dim,
605            "doc_count": doc_count,
606            "trigger": trigger.as_str(),
607        }),
608    }
609}
610
611/// Open the engine, invoke `f`, print the resulting JSON value, and map
612/// the outcome to an exit code. The closure returns `(json, outcome)`.
613fn run_doctor_verb<F>(db_path: &std::path::Path, verb: &str, f: F) -> i32
614where
615    F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
616{
617    run_doctor_verb_inner(db_path, verb, None, f)
618}
619
620/// Variant that overrides the `EngineError` → outcome mapping for verbs
621/// with a dedicated failure class (per `cli.md § Error → exit-code
622/// mapping`). Example: `doctor safe-export` maps engine errors to
623/// `ExportFailure` (66), not the default `Unrecoverable` (70).
624fn run_doctor_verb_with_error_outcome<F>(
625    db_path: &std::path::Path,
626    verb: &str,
627    error_outcome: CliOutcome,
628    f: F,
629) -> i32
630where
631    F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
632{
633    run_doctor_verb_inner(db_path, verb, Some(error_outcome), f)
634}
635
636fn run_doctor_verb_inner<F>(
637    db_path: &std::path::Path,
638    verb: &str,
639    error_outcome: Option<CliOutcome>,
640    f: F,
641) -> i32
642where
643    F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
644{
645    let opened = match Engine::open(db_path.to_path_buf()) {
646        Ok(o) => o,
647        Err(err) => return emit_engine_open_error(verb, &err),
648    };
649    match f(&opened.engine) {
650        Ok((value, outcome)) => {
651            println!("{value}");
652            outcome_to_exit_code(outcome)
653        }
654        Err(err) => match error_outcome {
655            Some(outcome) => emit_engine_error_with_outcome(verb, &err, outcome),
656            None => emit_engine_error(verb, &err),
657        },
658    }
659}
660
661/// Open the engine for `recover`, invoke `f`, print the JSON, and map the
662/// outcome to `RECOVERY_ACCEPTED_LOSS` (64) on success.
663fn wire_recover<F>(db_path: &std::path::Path, sub_verb: &str, f: F) -> i32
664where
665    F: FnOnce(&Engine) -> Result<Value, EngineError>,
666{
667    let opened = match Engine::open(db_path.to_path_buf()) {
668        Ok(o) => o,
669        Err(err) => return emit_engine_open_error(sub_verb, &err),
670    };
671    match f(&opened.engine) {
672        Ok(value) => {
673            println!("{value}");
674            outcome_to_exit_code(CliOutcome::RecoveryAcceptedLoss)
675        }
676        Err(err) => emit_engine_error(sub_verb, &err),
677    }
678}
679
680fn emit_engine_error(verb: &str, err: &EngineError) -> i32 {
681    emit_engine_error_with_outcome(verb, err, engine_error_to_outcome(err))
682}
683
684fn emit_engine_error_with_outcome(verb: &str, err: &EngineError, outcome: CliOutcome) -> i32 {
685    let payload = json!({
686        "status": "error",
687        "verb": verb,
688        "code": engine_error_code(err),
689        "detail": err.to_string(),
690    });
691    println!("{payload}");
692    outcome_to_exit_code(outcome)
693}
694
695fn emit_engine_open_error(verb: &str, err: &EngineOpenError) -> i32 {
696    let outcome = engine_open_error_to_outcome(err);
697    let payload = json!({
698        "status": "error",
699        "verb": verb,
700        "code": engine_open_error_code(err),
701        "detail": err.to_string(),
702    });
703    println!("{payload}");
704    outcome_to_exit_code(outcome)
705}
706
707fn engine_error_code(err: &EngineError) -> &'static str {
708    match err {
709        EngineError::Storage => "StorageError",
710        EngineError::Projection => "ProjectionError",
711        EngineError::Vector => "VectorError",
712        EngineError::Embedder => "EmbedderError",
713        EngineError::EmbedderNotConfigured => "EmbedderNotConfiguredError",
714        EngineError::KindNotVectorIndexed => "KindNotVectorIndexedError",
715        EngineError::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
716        EngineError::Scheduler => "SchedulerError",
717        EngineError::OpStore => "OpStoreError",
718        EngineError::WriteValidation => "WriteValidationError",
719        EngineError::SchemaValidation => "SchemaValidationError",
720        EngineError::Overloaded => "OverloadedError",
721        EngineError::Closing => "ClosingError",
722        EngineError::Extractor => "ExtractorError",
723        // G4 (Slice 35) — filter predicate construction error.
724        EngineError::InvalidFilter { .. } => "InvalidFilterError",
725        EngineError::InvalidArgument { .. } => "InvalidArgumentError",
726    }
727}
728
729fn engine_open_error_code(err: &EngineOpenError) -> &'static str {
730    match err {
731        EngineOpenError::DatabaseLocked { .. } => "DatabaseLockedError",
732        EngineOpenError::Corruption(_) => "CorruptionError",
733        EngineOpenError::IncompatibleSchemaVersion { .. } => "IncompatibleSchemaVersionError",
734        EngineOpenError::MigrationError { .. } => "MigrationError",
735        EngineOpenError::EmbedderIdentityMismatch { .. } => "EmbedderIdentityMismatchError",
736        EngineOpenError::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
737        EngineOpenError::Embedder(_) => "EmbedderError",
738        EngineOpenError::Io { .. } => "IoError",
739    }
740}
741
742// ---- JSON serializers for engine report types ----
743
744fn integrity_report_outcome(report: &IntegrityReport) -> (Value, CliOutcome) {
745    let any_findings = matches!(report.physical, Section::Findings(_))
746        || matches!(report.logical, Section::Findings(_))
747        || matches!(report.semantic, Section::Findings(_));
748    let body = json!({
749        "verb": "check-integrity",
750        "physical": section_json(&report.physical),
751        "logical": section_json(&report.logical),
752        "semantic": section_json(&report.semantic),
753    });
754    let outcome = if any_findings { CliOutcome::Findings } else { CliOutcome::Clean };
755    (body, outcome)
756}
757
758fn section_json(section: &Section) -> Value {
759    match section {
760        Section::Clean => json!({ "status": "clean", "findings": [] }),
761        Section::Findings(findings) => json!({
762            "status": "findings",
763            "findings": findings.iter().map(finding_json).collect::<Vec<_>>(),
764        }),
765    }
766}
767
768fn finding_json(f: &Finding) -> Value {
769    json!({
770        "code": f.code,
771        "stage": f.stage,
772        "locator": locator_json(&f.locator),
773        "doc_anchor": f.doc_anchor,
774        "detail": f.detail,
775    })
776}
777
778fn locator_json(loc: &CorruptionLocator) -> Value {
779    match loc {
780        CorruptionLocator::FileOffset { offset } => {
781            json!({ "kind": "file_offset", "offset": offset })
782        }
783        CorruptionLocator::PageId { page } => json!({ "kind": "page_id", "page": page }),
784        CorruptionLocator::TableRow { table, rowid } => {
785            json!({ "kind": "table_row", "table": table, "rowid": rowid })
786        }
787        CorruptionLocator::Vec0ShadowRow { partition, rowid } => {
788            json!({ "kind": "vec0_shadow_row", "partition": partition, "rowid": rowid })
789        }
790        CorruptionLocator::MigrationStep { from, to } => {
791            json!({ "kind": "migration_step", "from": from, "to": to })
792        }
793        CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
794            json!({
795                "kind": "opaque_sqlite_error",
796                "sqlite_extended_code": sqlite_extended_code,
797            })
798        }
799    }
800}
801
802fn safe_export_json(a: &SafeExportArtifact) -> Value {
803    json!({
804        "verb": "safe-export",
805        "export_path": a.export_path.to_string_lossy(),
806        "manifest_path": a.manifest_path.to_string_lossy(),
807        "manifest_sha256": a.manifest_sha256,
808    })
809}
810
811fn trace_report_json(t: &TraceReport) -> Value {
812    json!({
813        "verb": "trace",
814        "source_ref": t.source_ref,
815        "events": t.events.iter().map(|e| json!({
816            "write_cursor": e.write_cursor,
817            "kind": e.kind,
818            "table": e.table,
819        })).collect::<Vec<_>>(),
820    })
821}
822
823fn rebuild_report_json(verb: &'static str, r: &RebuildReport) -> Value {
824    let kind = match r.kind {
825        RebuildKind::Projections => "projections",
826        RebuildKind::Vec0 => "vec0",
827    };
828    json!({
829        "verb": verb,
830        "kind": kind,
831        "rows_invalidated": r.rows_invalidated,
832        "rows_rebuilt": r.rows_rebuilt,
833        "projection_cursor_after": r.projection_cursor_after,
834    })
835}
836
837fn excise_report_json(r: &ExciseReport) -> Value {
838    json!({
839        "verb": "excise-source",
840        "source_ref": r.source_ref,
841        "nodes_excised": r.nodes_excised,
842        "edges_excised": r.edges_excised,
843        "projections_invalidated": r.projections_invalidated,
844    })
845}
846
847fn verify_embedder_report_json(r: &VerifyEmbedderReport) -> Value {
848    let status = match r.status {
849        VerifyEmbedderStatus::Match => "match",
850        VerifyEmbedderStatus::IdentityMismatch => "identity_mismatch",
851        VerifyEmbedderStatus::DimensionMismatch => "dimension_mismatch",
852        VerifyEmbedderStatus::BothMismatch => "both_mismatch",
853    };
854    json!({
855        "verb": "verify-embedder",
856        "stored_identity": r.stored_identity,
857        "stored_dimension": r.stored_dimension,
858        "supplied_identity": r.supplied_identity,
859        "supplied_dimension": r.supplied_dimension,
860        "status": status,
861    })
862}
863
864fn schema_object_json(o: &SchemaObject) -> Value {
865    json!({ "name": o.name, "sql": o.sql })
866}
867
868fn dump_schema_report_json(r: &DumpSchemaReport) -> Value {
869    json!({
870        "verb": "dump-schema",
871        "user_version": r.user_version,
872        "tables": r.tables.iter().map(schema_object_json).collect::<Vec<_>>(),
873        "indexes": r.indexes.iter().map(schema_object_json).collect::<Vec<_>>(),
874    })
875}
876
877/// 0.7.2 PR-2b — `doctor recompute-mean` `--json` normative contract.
878fn recompute_mean_report_json(r: &MeanRecomputeReport) -> Value {
879    json!({
880        "verb": "recompute-mean",
881        "status": "ok",
882        "dim": r.dim,
883        "old_doc_count": r.old_doc_count,
884        "doc_count_requantized": r.doc_count_requantized,
885        "drift_cos_before": r.drift_cos_before,
886        "mean_was_pinned": r.mean_was_pinned,
887        "elapsed_ms": r.elapsed_ms,
888    })
889}
890
891fn dump_row_counts_report_json(r: &DumpRowCountsReport) -> Value {
892    json!({
893        "verb": "dump-row-counts",
894        "counts": r.counts.iter().map(|c| json!({
895            "name": c.name,
896            "rows": c.rows,
897        })).collect::<Vec<_>>(),
898    })
899}
900
901fn dump_profile_report_json(r: &DumpProfileReport) -> Value {
902    json!({
903        "verb": "dump-profile",
904        "embedder_identity": r.embedder_identity,
905        "embedder_dimension": r.embedder_dimension,
906        "vectorized_kinds": r.vectorized_kinds,
907    })
908}
909
910fn truncate_wal_report_json(r: &TruncateWalReport) -> Value {
911    let status = match r.status {
912        TruncateWalStatus::Done => "done",
913        TruncateWalStatus::Busy => "busy",
914    };
915    json!({
916        "verb": "truncate-wal",
917        "status": status,
918        "busy": r.busy,
919        "log_frames": r.log_frames,
920        "checkpointed_frames": r.checkpointed_frames,
921    })
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927
928    #[test]
929    fn outcome_mapping_covers_cli_md_exit_classes() {
930        assert_eq!(outcome_to_exit_code(CliOutcome::Clean), 0);
931        assert_eq!(outcome_to_exit_code(CliOutcome::RecoveryAcceptedLoss), 64);
932        assert_eq!(outcome_to_exit_code(CliOutcome::Findings), 65);
933        assert_eq!(outcome_to_exit_code(CliOutcome::ExportFailure), 66);
934        assert_eq!(outcome_to_exit_code(CliOutcome::Unrecoverable), 70);
935        assert_eq!(outcome_to_exit_code(CliOutcome::LockHeld), 71);
936    }
937
938    #[test]
939    fn engine_error_storage_maps_to_unrecoverable() {
940        assert_eq!(engine_error_to_outcome(&EngineError::Storage), CliOutcome::Unrecoverable);
941        assert_eq!(engine_error_to_outcome(&EngineError::Closing), CliOutcome::LockHeld);
942    }
943
944    #[test]
945    fn engine_open_database_locked_maps_to_lock_held() {
946        let err = EngineOpenError::DatabaseLocked { holder_pid: Some(1234) };
947        assert_eq!(engine_open_error_to_outcome(&err), CliOutcome::LockHeld);
948    }
949}