1use 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
21pub mod exit_code {
26 pub const OK: i32 = 0;
28
29 pub const RECOVERY_ACCEPTED_LOSS: i32 = 64;
31
32 pub const DOCTOR_FOUND_ISSUES: i32 = 65;
34
35 pub const EXPORT_FAILURE: i32 = 66;
37
38 pub const UNRECOVERABLE: i32 = 70;
40
41 pub const LOCK_HELD: i32 = 71;
43}
44
45#[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#[derive(Debug, Subcommand)]
58pub enum Command {
59 Recover(RecoverArgs),
61 Doctor(DoctorArgs),
63}
64
65#[derive(Debug, Args)]
67pub struct DoctorArgs {
68 #[command(subcommand)]
69 pub command: DoctorCommand,
70}
71
72#[derive(Debug, Args)]
77pub struct RecoverArgs {
78 #[arg(long)]
80 pub accept_data_loss: bool,
81
82 #[arg(long)]
84 pub truncate_wal: bool,
85
86 #[arg(long)]
88 pub rebuild_vec0: bool,
89
90 #[arg(long)]
92 pub rebuild_projections: bool,
93
94 #[arg(long)]
96 pub excise_source: Option<String>,
97
98 #[arg(long)]
100 pub json: bool,
101
102 pub db_path: PathBuf,
104}
105
106#[derive(Debug, Subcommand)]
108pub enum DoctorCommand {
109 CheckIntegrity(CheckIntegrityArgs),
111 SafeExport(SafeExportArgs),
113 VerifyEmbedder(VerifyEmbedderArgs),
115 Trace(TraceArgs),
117 DumpSchema(SimpleDoctorArgs),
119 DumpRowCounts(SimpleDoctorArgs),
121 DumpProfile(SimpleDoctorArgs),
123 WarmCache(WarmCacheArgs),
127 RecomputeMean(SimpleDoctorArgs),
131 DumpMutations(DumpMutationsArgs),
138}
139
140#[derive(Debug, Args)]
142pub struct WarmCacheArgs {
143 #[arg(long)]
145 pub json: bool,
146}
147
148#[derive(Debug, Args)]
152pub struct SimpleDoctorArgs {
153 #[arg(long)]
155 pub json: bool,
156
157 pub db_path: PathBuf,
159}
160
161const DUMP_MUTATIONS_DEFAULT_LIMIT: usize = 1000;
166
167const DUMP_MUTATIONS_MAX_LIMIT: usize = 1_000_000;
177
178#[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#[derive(Debug, Args)]
192pub struct DumpMutationsArgs {
193 pub collection: String,
195
196 #[arg(long = "after-id")]
200 pub after_id: Option<i64>,
201
202 #[arg(long)]
205 pub limit: Option<usize>,
206
207 #[arg(long)]
209 pub json: bool,
210
211 pub db_path: PathBuf,
213}
214
215#[derive(Debug, Args)]
217pub struct CheckIntegrityArgs {
218 #[arg(long)]
220 pub quick: bool,
221
222 #[arg(long)]
224 pub full: bool,
225
226 #[arg(long = "round-trip")]
228 pub round_trip: bool,
229
230 #[arg(long)]
232 pub pretty: bool,
233
234 #[arg(long)]
236 pub json: bool,
237
238 pub db_path: PathBuf,
240}
241
242#[derive(Debug, Args)]
244pub struct SafeExportArgs {
245 pub out: PathBuf,
247
248 #[arg(long)]
250 pub manifest: Option<PathBuf>,
251
252 #[arg(long)]
254 pub json: bool,
255
256 pub db_path: PathBuf,
258}
259
260#[derive(Debug, Args)]
264pub struct VerifyEmbedderArgs {
265 #[arg(long)]
268 pub identity: String,
269
270 #[arg(long)]
272 pub dimension: u32,
273
274 #[arg(long)]
276 pub json: bool,
277
278 pub db_path: PathBuf,
280}
281
282#[derive(Debug, Args)]
284pub struct TraceArgs {
285 #[arg(long = "source-ref")]
287 pub source_ref: String,
288
289 #[arg(long)]
291 pub json: bool,
292
293 pub db_path: PathBuf,
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum CliOutcome {
301 Clean,
303 Findings,
305 ExportFailure,
307 RecoveryAcceptedLoss,
309 LockHeld,
311 Unrecoverable,
313}
314
315#[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#[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#[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#[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 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 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 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
503fn 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
611fn 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
620fn 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
661fn 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 }
723}
724
725fn engine_open_error_code(err: &EngineOpenError) -> &'static str {
726 match err {
727 EngineOpenError::DatabaseLocked { .. } => "DatabaseLockedError",
728 EngineOpenError::Corruption(_) => "CorruptionError",
729 EngineOpenError::IncompatibleSchemaVersion { .. } => "IncompatibleSchemaVersionError",
730 EngineOpenError::MigrationError { .. } => "MigrationError",
731 EngineOpenError::EmbedderIdentityMismatch { .. } => "EmbedderIdentityMismatchError",
732 EngineOpenError::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
733 EngineOpenError::Embedder(_) => "EmbedderError",
734 EngineOpenError::Io { .. } => "IoError",
735 }
736}
737
738fn integrity_report_outcome(report: &IntegrityReport) -> (Value, CliOutcome) {
741 let any_findings = matches!(report.physical, Section::Findings(_))
742 || matches!(report.logical, Section::Findings(_))
743 || matches!(report.semantic, Section::Findings(_));
744 let body = json!({
745 "verb": "check-integrity",
746 "physical": section_json(&report.physical),
747 "logical": section_json(&report.logical),
748 "semantic": section_json(&report.semantic),
749 });
750 let outcome = if any_findings { CliOutcome::Findings } else { CliOutcome::Clean };
751 (body, outcome)
752}
753
754fn section_json(section: &Section) -> Value {
755 match section {
756 Section::Clean => json!({ "status": "clean", "findings": [] }),
757 Section::Findings(findings) => json!({
758 "status": "findings",
759 "findings": findings.iter().map(finding_json).collect::<Vec<_>>(),
760 }),
761 }
762}
763
764fn finding_json(f: &Finding) -> Value {
765 json!({
766 "code": f.code,
767 "stage": f.stage,
768 "locator": locator_json(&f.locator),
769 "doc_anchor": f.doc_anchor,
770 "detail": f.detail,
771 })
772}
773
774fn locator_json(loc: &CorruptionLocator) -> Value {
775 match loc {
776 CorruptionLocator::FileOffset { offset } => {
777 json!({ "kind": "file_offset", "offset": offset })
778 }
779 CorruptionLocator::PageId { page } => json!({ "kind": "page_id", "page": page }),
780 CorruptionLocator::TableRow { table, rowid } => {
781 json!({ "kind": "table_row", "table": table, "rowid": rowid })
782 }
783 CorruptionLocator::Vec0ShadowRow { partition, rowid } => {
784 json!({ "kind": "vec0_shadow_row", "partition": partition, "rowid": rowid })
785 }
786 CorruptionLocator::MigrationStep { from, to } => {
787 json!({ "kind": "migration_step", "from": from, "to": to })
788 }
789 CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
790 json!({
791 "kind": "opaque_sqlite_error",
792 "sqlite_extended_code": sqlite_extended_code,
793 })
794 }
795 }
796}
797
798fn safe_export_json(a: &SafeExportArtifact) -> Value {
799 json!({
800 "verb": "safe-export",
801 "export_path": a.export_path.to_string_lossy(),
802 "manifest_path": a.manifest_path.to_string_lossy(),
803 "manifest_sha256": a.manifest_sha256,
804 })
805}
806
807fn trace_report_json(t: &TraceReport) -> Value {
808 json!({
809 "verb": "trace",
810 "source_ref": t.source_ref,
811 "events": t.events.iter().map(|e| json!({
812 "write_cursor": e.write_cursor,
813 "kind": e.kind,
814 "table": e.table,
815 })).collect::<Vec<_>>(),
816 })
817}
818
819fn rebuild_report_json(verb: &'static str, r: &RebuildReport) -> Value {
820 let kind = match r.kind {
821 RebuildKind::Projections => "projections",
822 RebuildKind::Vec0 => "vec0",
823 };
824 json!({
825 "verb": verb,
826 "kind": kind,
827 "rows_invalidated": r.rows_invalidated,
828 "rows_rebuilt": r.rows_rebuilt,
829 "projection_cursor_after": r.projection_cursor_after,
830 })
831}
832
833fn excise_report_json(r: &ExciseReport) -> Value {
834 json!({
835 "verb": "excise-source",
836 "source_ref": r.source_ref,
837 "nodes_excised": r.nodes_excised,
838 "edges_excised": r.edges_excised,
839 "projections_invalidated": r.projections_invalidated,
840 })
841}
842
843fn verify_embedder_report_json(r: &VerifyEmbedderReport) -> Value {
844 let status = match r.status {
845 VerifyEmbedderStatus::Match => "match",
846 VerifyEmbedderStatus::IdentityMismatch => "identity_mismatch",
847 VerifyEmbedderStatus::DimensionMismatch => "dimension_mismatch",
848 VerifyEmbedderStatus::BothMismatch => "both_mismatch",
849 };
850 json!({
851 "verb": "verify-embedder",
852 "stored_identity": r.stored_identity,
853 "stored_dimension": r.stored_dimension,
854 "supplied_identity": r.supplied_identity,
855 "supplied_dimension": r.supplied_dimension,
856 "status": status,
857 })
858}
859
860fn schema_object_json(o: &SchemaObject) -> Value {
861 json!({ "name": o.name, "sql": o.sql })
862}
863
864fn dump_schema_report_json(r: &DumpSchemaReport) -> Value {
865 json!({
866 "verb": "dump-schema",
867 "user_version": r.user_version,
868 "tables": r.tables.iter().map(schema_object_json).collect::<Vec<_>>(),
869 "indexes": r.indexes.iter().map(schema_object_json).collect::<Vec<_>>(),
870 })
871}
872
873fn recompute_mean_report_json(r: &MeanRecomputeReport) -> Value {
875 json!({
876 "verb": "recompute-mean",
877 "status": "ok",
878 "dim": r.dim,
879 "old_doc_count": r.old_doc_count,
880 "doc_count_requantized": r.doc_count_requantized,
881 "drift_cos_before": r.drift_cos_before,
882 "mean_was_pinned": r.mean_was_pinned,
883 "elapsed_ms": r.elapsed_ms,
884 })
885}
886
887fn dump_row_counts_report_json(r: &DumpRowCountsReport) -> Value {
888 json!({
889 "verb": "dump-row-counts",
890 "counts": r.counts.iter().map(|c| json!({
891 "name": c.name,
892 "rows": c.rows,
893 })).collect::<Vec<_>>(),
894 })
895}
896
897fn dump_profile_report_json(r: &DumpProfileReport) -> Value {
898 json!({
899 "verb": "dump-profile",
900 "embedder_identity": r.embedder_identity,
901 "embedder_dimension": r.embedder_dimension,
902 "vectorized_kinds": r.vectorized_kinds,
903 })
904}
905
906fn truncate_wal_report_json(r: &TruncateWalReport) -> Value {
907 let status = match r.status {
908 TruncateWalStatus::Done => "done",
909 TruncateWalStatus::Busy => "busy",
910 };
911 json!({
912 "verb": "truncate-wal",
913 "status": status,
914 "busy": r.busy,
915 "log_frames": r.log_frames,
916 "checkpointed_frames": r.checkpointed_frames,
917 })
918}
919
920#[cfg(test)]
921mod tests {
922 use super::*;
923
924 #[test]
925 fn outcome_mapping_covers_cli_md_exit_classes() {
926 assert_eq!(outcome_to_exit_code(CliOutcome::Clean), 0);
927 assert_eq!(outcome_to_exit_code(CliOutcome::RecoveryAcceptedLoss), 64);
928 assert_eq!(outcome_to_exit_code(CliOutcome::Findings), 65);
929 assert_eq!(outcome_to_exit_code(CliOutcome::ExportFailure), 66);
930 assert_eq!(outcome_to_exit_code(CliOutcome::Unrecoverable), 70);
931 assert_eq!(outcome_to_exit_code(CliOutcome::LockHeld), 71);
932 }
933
934 #[test]
935 fn engine_error_storage_maps_to_unrecoverable() {
936 assert_eq!(engine_error_to_outcome(&EngineError::Storage), CliOutcome::Unrecoverable);
937 assert_eq!(engine_error_to_outcome(&EngineError::Closing), CliOutcome::LockHeld);
938 }
939
940 #[test]
941 fn engine_open_database_locked_maps_to_lock_held() {
942 let err = EngineOpenError::DatabaseLocked { holder_pid: Some(1234) };
943 assert_eq!(engine_open_error_to_outcome(&err), CliOutcome::LockHeld);
944 }
945}