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 EngineError::Extractor => "ExtractorError",
723 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
742fn 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
877fn 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}