1use std::path::PathBuf;
39
40use clap::{Args, Parser, Subcommand};
41use fathomdb::{
42 CheckIntegrityOpts, CorruptionLocator, DumpProfileReport, DumpRowCountsReport,
43 DumpSchemaReport, Engine, EngineError, EngineOpenError, ExciseRecordReport, ExciseReport,
44 Finding, IntegrityReport, MeanRecomputeReport, OrphanProvenanceReport, RebuildKind,
45 RebuildReport, SafeExportArtifact, SchemaObject, Section, TraceReport, TruncateWalReport,
46 TruncateWalStatus, VerifyEmbedderReport, VerifyEmbedderStatus,
47};
48use serde_json::{json, Value};
49
50pub mod exit_code {
55 pub const OK: i32 = 0;
57
58 pub const RECOVERY_ACCEPTED_LOSS: i32 = 64;
60
61 pub const DOCTOR_FOUND_ISSUES: i32 = 65;
63
64 pub const EXPORT_FAILURE: i32 = 66;
66
67 pub const UNRECOVERABLE: i32 = 70;
69
70 pub const LOCK_HELD: i32 = 71;
72}
73
74#[derive(Debug, Parser)]
76#[command(name = "fathomdb", version, about = "FathomDB operator CLI", long_about = None)]
77pub struct Cli {
78 #[command(subcommand)]
79 pub command: Command,
80}
81
82#[derive(Debug, Subcommand)]
87pub enum Command {
88 Recover(RecoverArgs),
90 Doctor(DoctorArgs),
92}
93
94#[derive(Debug, Args)]
96pub struct DoctorArgs {
97 #[command(subcommand)]
98 pub command: DoctorCommand,
99}
100
101#[derive(Debug, Args)]
106pub struct RecoverArgs {
107 #[arg(long)]
109 pub accept_data_loss: bool,
110
111 #[arg(long)]
113 pub truncate_wal: bool,
114
115 #[arg(long)]
117 pub rebuild_vec0: bool,
118
119 #[arg(long)]
121 pub rebuild_projections: bool,
122
123 #[arg(long)]
125 pub excise_source: Option<String>,
126
127 #[arg(long, requires = "excise_record_key")]
130 pub excise_collection: Option<String>,
131
132 #[arg(long, requires = "excise_collection")]
136 pub excise_record_key: Option<String>,
137
138 #[arg(long)]
140 pub json: bool,
141
142 pub db_path: PathBuf,
144}
145
146#[derive(Debug, Subcommand)]
148pub enum DoctorCommand {
149 CheckIntegrity(CheckIntegrityArgs),
151 SafeExport(SafeExportArgs),
153 VerifyEmbedder(VerifyEmbedderArgs),
155 Trace(TraceArgs),
157 DumpSchema(SimpleDoctorArgs),
159 DumpRowCounts(SimpleDoctorArgs),
161 DumpProfile(SimpleDoctorArgs),
163 WarmCache(WarmCacheArgs),
167 RecomputeMean(SimpleDoctorArgs),
171 DumpMutations(DumpMutationsArgs),
178 OrphanProvenance(SimpleDoctorArgs),
185}
186
187#[derive(Debug, Args)]
189pub struct WarmCacheArgs {
190 #[arg(long)]
192 pub json: bool,
193}
194
195#[derive(Debug, Args)]
199pub struct SimpleDoctorArgs {
200 #[arg(long)]
202 pub json: bool,
203
204 pub db_path: PathBuf,
206}
207
208const DUMP_MUTATIONS_DEFAULT_LIMIT: usize = 1000;
213
214const DUMP_MUTATIONS_MAX_LIMIT: usize = 1_000_000;
224
225#[must_use]
230pub fn effective_dump_limit(requested: Option<usize>) -> usize {
231 requested.unwrap_or(DUMP_MUTATIONS_DEFAULT_LIMIT).min(DUMP_MUTATIONS_MAX_LIMIT)
232}
233
234#[derive(Debug, Args)]
239pub struct DumpMutationsArgs {
240 pub collection: String,
242
243 #[arg(long = "after-id")]
247 pub after_id: Option<i64>,
248
249 #[arg(long)]
252 pub limit: Option<usize>,
253
254 #[arg(long)]
256 pub json: bool,
257
258 pub db_path: PathBuf,
260}
261
262#[derive(Debug, Args)]
264pub struct CheckIntegrityArgs {
265 #[arg(long)]
267 pub quick: bool,
268
269 #[arg(long)]
271 pub full: bool,
272
273 #[arg(long = "round-trip")]
275 pub round_trip: bool,
276
277 #[arg(long)]
279 pub pretty: bool,
280
281 #[arg(long)]
283 pub json: bool,
284
285 pub db_path: PathBuf,
287}
288
289#[derive(Debug, Args)]
291pub struct SafeExportArgs {
292 pub out: PathBuf,
294
295 #[arg(long)]
297 pub manifest: Option<PathBuf>,
298
299 #[arg(long)]
301 pub json: bool,
302
303 pub db_path: PathBuf,
305}
306
307#[derive(Debug, Args)]
311pub struct VerifyEmbedderArgs {
312 #[arg(long)]
315 pub identity: String,
316
317 #[arg(long)]
319 pub dimension: u32,
320
321 #[arg(long)]
323 pub json: bool,
324
325 pub db_path: PathBuf,
327}
328
329#[derive(Debug, Args)]
331pub struct TraceArgs {
332 #[arg(long = "source-ref")]
334 pub source_ref: String,
335
336 #[arg(long)]
338 pub json: bool,
339
340 pub db_path: PathBuf,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
347pub enum CliOutcome {
348 Clean,
350 Findings,
352 ExportFailure,
354 RecoveryAcceptedLoss,
356 LockHeld,
358 Unrecoverable,
360}
361
362#[must_use]
364pub fn outcome_to_exit_code(outcome: CliOutcome) -> i32 {
365 match outcome {
366 CliOutcome::Clean => exit_code::OK,
367 CliOutcome::Findings => exit_code::DOCTOR_FOUND_ISSUES,
368 CliOutcome::ExportFailure => exit_code::EXPORT_FAILURE,
369 CliOutcome::RecoveryAcceptedLoss => exit_code::RECOVERY_ACCEPTED_LOSS,
370 CliOutcome::LockHeld => exit_code::LOCK_HELD,
371 CliOutcome::Unrecoverable => exit_code::UNRECOVERABLE,
372 }
373}
374
375#[must_use]
378pub fn engine_error_to_outcome(err: &EngineError) -> CliOutcome {
379 match err {
380 EngineError::Closing => CliOutcome::LockHeld,
381 EngineError::ErasureIncomplete { .. } => CliOutcome::LockHeld,
399 _ => CliOutcome::Unrecoverable,
400 }
401}
402
403#[must_use]
406pub fn engine_open_error_to_outcome(err: &EngineOpenError) -> CliOutcome {
407 match err {
408 EngineOpenError::DatabaseLocked { .. } => CliOutcome::LockHeld,
409 _ => CliOutcome::Unrecoverable,
410 }
411}
412
413#[must_use]
424pub fn run(cli: Cli) -> i32 {
425 match cli.command {
426 Command::Recover(args) => run_recover(args),
427 Command::Doctor(d) => run_doctor(d.command),
428 }
429}
430
431fn run_recover(args: RecoverArgs) -> i32 {
432 if !args.accept_data_loss {
433 println!(
434 r#"{{"status":"refused","verb":"recover","code":"E_RECOVER_REQUIRES_ACCEPT_DATA_LOSS"}}"#
435 );
436 return exit_code::UNRECOVERABLE;
437 }
438
439 if args.rebuild_projections {
440 return wire_recover(&args.db_path, "rebuild-projections", |e| {
441 e.rebuild_projections().map(|r| rebuild_report_json("rebuild-projections", &r))
442 });
443 }
444 if args.rebuild_vec0 {
445 return wire_recover(&args.db_path, "rebuild-vec0", |e| {
446 e.rebuild_vec0().map(|r| rebuild_report_json("rebuild-vec0", &r))
447 });
448 }
449 if let Some(source_id) = args.excise_source.as_deref() {
450 return wire_recover(&args.db_path, "excise-source", |e| {
451 e.excise_source(source_id).map(|r| excise_report_json(&r))
452 });
453 }
454 if let (Some(collection), Some(record_key)) =
457 (args.excise_collection.as_deref(), args.excise_record_key.as_deref())
458 {
459 return wire_recover(&args.db_path, "excise-record", |e| {
460 e.excise_collection_record(collection, record_key)
461 .map(|r| excise_record_report_json(&r))
462 });
463 }
464 if args.truncate_wal {
465 return wire_recover(&args.db_path, "truncate-wal", |e| {
466 e.truncate_wal().map(|r| truncate_wal_report_json(&r))
467 });
468 }
469
470 println!(r#"{{"status":"not_implemented","verb":"recover"}}"#);
472 exit_code::UNRECOVERABLE
473}
474
475fn run_doctor(cmd: DoctorCommand) -> i32 {
476 match cmd {
477 DoctorCommand::CheckIntegrity(args) => {
478 let opts = CheckIntegrityOpts {
479 quick: args.quick,
480 full: args.full,
481 round_trip: args.round_trip,
482 };
483 run_doctor_verb(&args.db_path, "check-integrity", |e| {
484 e.check_integrity(opts).map(|r| integrity_report_outcome(&r))
485 })
486 }
487 DoctorCommand::SafeExport(args) => {
488 let manifest = args.manifest.clone().unwrap_or_else(|| {
489 let mut p = args.out.clone();
490 let name = p
491 .file_name()
492 .map(|s| s.to_string_lossy().into_owned())
493 .unwrap_or_else(|| "export".to_string());
494 p.set_file_name(format!("{name}.manifest.json"));
495 p
496 });
497 run_doctor_verb_with_error_outcome(
498 &args.db_path,
499 "safe-export",
500 CliOutcome::ExportFailure,
501 |e| {
502 e.safe_export(&args.out, &manifest)
503 .map(|r| (safe_export_json(&r), CliOutcome::Clean))
504 },
505 )
506 }
507 DoctorCommand::Trace(args) => run_doctor_verb(&args.db_path, "trace", |e| {
508 e.trace_source_ref(&args.source_ref).map(|r| (trace_report_json(&r), CliOutcome::Clean))
509 }),
510 DoctorCommand::VerifyEmbedder(args) => {
511 let identity = args.identity.clone();
512 let dimension = args.dimension;
513 run_doctor_verb(&args.db_path, "verify-embedder", |e| {
514 e.verify_embedder(&identity, dimension)
515 .map(|r| (verify_embedder_report_json(&r), CliOutcome::Clean))
516 })
517 }
518 DoctorCommand::DumpSchema(args) => run_doctor_verb(&args.db_path, "dump-schema", |e| {
519 e.dump_schema().map(|r| (dump_schema_report_json(&r), CliOutcome::Clean))
520 }),
521 DoctorCommand::DumpRowCounts(args) => {
522 run_doctor_verb(&args.db_path, "dump-row-counts", |e| {
523 e.dump_row_counts().map(|r| (dump_row_counts_report_json(&r), CliOutcome::Clean))
524 })
525 }
526 DoctorCommand::DumpProfile(args) => run_doctor_verb(&args.db_path, "dump-profile", |e| {
527 e.dump_profile().map(|r| (dump_profile_report_json(&r), CliOutcome::Clean))
528 }),
529 DoctorCommand::OrphanProvenance(args) => {
530 run_doctor_verb(&args.db_path, "orphan-provenance", |e| {
531 e.orphan_provenance().map(|r| {
532 let outcome = if r.unerasable_rows > 0 {
537 CliOutcome::Findings
538 } else {
539 CliOutcome::Clean
540 };
541 (orphan_provenance_report_json(&r), outcome)
542 })
543 })
544 }
545 DoctorCommand::WarmCache(args) => run_doctor_warm_cache(args),
546 DoctorCommand::RecomputeMean(args) => {
547 run_doctor_verb(&args.db_path, "recompute-mean", |e| {
548 e.recompute_mean().map(|r| (recompute_mean_report_json(&r), CliOutcome::Clean))
549 })
550 }
551 DoctorCommand::DumpMutations(args) => {
552 let limit = effective_dump_limit(args.limit);
553 run_doctor_verb(&args.db_path, "dump-mutations", |e| {
554 e.read_mutations(&args.collection, args.after_id, limit).map(|rows| {
558 let row_values = rows
559 .iter()
560 .map(|r| {
561 json!({
562 "id": r.id,
563 "collection": r.collection,
564 "record_key": r.record_key,
565 "op_kind": r.op_kind,
566 "payload": r.payload,
567 "schema_id": r.schema_id,
568 "write_cursor": r.write_cursor,
569 })
570 })
571 .collect::<Vec<_>>();
572 let next_after_id =
577 if rows.len() == limit { rows.last().map(|r| r.id) } else { None };
578 let body = json!({
579 "verb": "dump-mutations",
580 "collection": args.collection,
581 "after_id": args.after_id,
582 "limit": limit,
583 "count": row_values.len(),
584 "rows": row_values,
585 "next_after_id": next_after_id,
586 });
587 (body, CliOutcome::Clean)
588 })
589 })
590 }
591 }
592}
593
594fn run_doctor_warm_cache(args: WarmCacheArgs) -> i32 {
598 #[cfg(feature = "default-embedder")]
599 {
600 match fathomdb_embedder::loader::load_pinned_default_embedder() {
601 Ok(weights) => {
602 if args.json {
603 let payload = json!({
604 "verb": "warm-cache",
605 "status": "ok",
606 "config_json": weights.config_json_path.to_string_lossy(),
607 "tokenizer_json": weights.tokenizer_json_path.to_string_lossy(),
608 "model_safetensors": weights.model_safetensors_path.to_string_lossy(),
609 "bytes_downloaded": weights.bytes_downloaded,
610 "events": weights
611 .events
612 .iter()
613 .map(warm_cache_event_json)
614 .collect::<Vec<_>>(),
615 });
616 println!("{payload}");
617 } else {
618 let kind = if weights.bytes_downloaded > 0 { "cold" } else { "warm" };
619 println!("warm-cache: ok ({kind})");
620 println!(" config.json: {}", weights.config_json_path.display());
621 println!(" tokenizer.json: {}", weights.tokenizer_json_path.display());
622 println!(" model.safetensors: {}", weights.model_safetensors_path.display());
623 println!(" bytes downloaded: {}", weights.bytes_downloaded);
624 println!(" events: {}", weights.events.len());
625 }
626 exit_code::OK
627 }
628 Err(err) => {
629 if args.json {
630 let payload = json!({
631 "verb": "warm-cache",
632 "status": "error",
633 "code": "EmbedderLoadError",
634 "detail": err.to_string(),
635 });
636 println!("{payload}");
637 } else {
638 eprintln!("warm-cache: error: {err}");
639 }
640 exit_code::UNRECOVERABLE
641 }
642 }
643 }
644 #[cfg(not(feature = "default-embedder"))]
645 {
646 let detail = "fathomdb CLI was built without the `default-embedder` feature; rebuild with --features default-embedder";
647 if args.json {
648 let payload = json!({
649 "verb": "warm-cache",
650 "status": "error",
651 "code": "DefaultEmbedderFeatureDisabled",
652 "detail": detail,
653 });
654 println!("{payload}");
655 } else {
656 eprintln!("warm-cache: error: {detail}");
657 }
658 exit_code::UNRECOVERABLE
659 }
660}
661
662#[cfg(feature = "default-embedder")]
663fn warm_cache_event_json(ev: &fathomdb_embedder::EmbedderEvent) -> Value {
664 use fathomdb_embedder::EmbedderEvent;
665 match ev {
666 EmbedderEvent::DefaultEmbedderDownload {
667 file,
668 url,
669 bytes,
670 sha256,
671 cache_path,
672 duration_ms,
673 } => json!({
674 "kind": "download",
675 "file": file,
676 "url": url,
677 "bytes": bytes,
678 "sha256": sha256,
679 "cache_path": cache_path.to_string_lossy(),
680 "duration_ms": duration_ms,
681 }),
682 EmbedderEvent::DefaultEmbedderCacheHit { file, sha256, cache_path } => json!({
683 "kind": "cache_hit",
684 "file": file,
685 "sha256": sha256,
686 "cache_path": cache_path.to_string_lossy(),
687 }),
688 EmbedderEvent::MeanVecPinned { dim, doc_count } => json!({
689 "kind": "mean_vec_pinned",
690 "dim": dim,
691 "doc_count": doc_count,
692 }),
693 EmbedderEvent::MeanVecRecomputed { dim, doc_count, trigger } => json!({
694 "kind": "mean_vec_recomputed",
695 "dim": dim,
696 "doc_count": doc_count,
697 "trigger": trigger.as_str(),
698 }),
699 }
700}
701
702fn run_doctor_verb<F>(db_path: &std::path::Path, verb: &str, f: F) -> i32
705where
706 F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
707{
708 run_doctor_verb_inner(db_path, verb, None, f)
709}
710
711fn run_doctor_verb_with_error_outcome<F>(
716 db_path: &std::path::Path,
717 verb: &str,
718 error_outcome: CliOutcome,
719 f: F,
720) -> i32
721where
722 F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
723{
724 run_doctor_verb_inner(db_path, verb, Some(error_outcome), f)
725}
726
727fn run_doctor_verb_inner<F>(
728 db_path: &std::path::Path,
729 verb: &str,
730 error_outcome: Option<CliOutcome>,
731 f: F,
732) -> i32
733where
734 F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
735{
736 let opened = match Engine::open(db_path.to_path_buf()) {
737 Ok(o) => o,
738 Err(err) => return emit_engine_open_error(verb, &err),
739 };
740 match f(&opened.engine) {
741 Ok((value, outcome)) => {
742 println!("{value}");
743 outcome_to_exit_code(outcome)
744 }
745 Err(err) => match error_outcome {
746 Some(outcome) => emit_engine_error_with_outcome(verb, &err, outcome),
747 None => emit_engine_error(verb, &err),
748 },
749 }
750}
751
752fn wire_recover<F>(db_path: &std::path::Path, sub_verb: &str, f: F) -> i32
755where
756 F: FnOnce(&Engine) -> Result<Value, EngineError>,
757{
758 let opened = match Engine::open(db_path.to_path_buf()) {
759 Ok(o) => o,
760 Err(err) => return emit_engine_open_error(sub_verb, &err),
761 };
762 match f(&opened.engine) {
763 Ok(value) => {
764 println!("{value}");
765 outcome_to_exit_code(CliOutcome::RecoveryAcceptedLoss)
766 }
767 Err(err) => emit_engine_error(sub_verb, &err),
768 }
769}
770
771fn emit_engine_error(verb: &str, err: &EngineError) -> i32 {
772 emit_engine_error_with_outcome(verb, err, engine_error_to_outcome(err))
773}
774
775fn emit_engine_error_with_outcome(verb: &str, err: &EngineError, outcome: CliOutcome) -> i32 {
776 let payload = json!({
777 "status": "error",
778 "verb": verb,
779 "code": engine_error_code(err),
780 "detail": err.to_string(),
781 });
782 println!("{payload}");
783 outcome_to_exit_code(outcome)
784}
785
786fn emit_engine_open_error(verb: &str, err: &EngineOpenError) -> i32 {
787 let outcome = engine_open_error_to_outcome(err);
788 let payload = json!({
789 "status": "error",
790 "verb": verb,
791 "code": engine_open_error_code(err),
792 "detail": err.to_string(),
793 });
794 println!("{payload}");
795 outcome_to_exit_code(outcome)
796}
797
798fn engine_error_code(err: &EngineError) -> &'static str {
799 match err {
800 EngineError::Storage => "StorageError",
801 EngineError::Projection => "ProjectionError",
802 EngineError::Vector => "VectorError",
803 EngineError::Embedder => "EmbedderError",
804 EngineError::EmbedderNotConfigured => "EmbedderNotConfiguredError",
805 EngineError::KindNotVectorIndexed => "KindNotVectorIndexedError",
806 EngineError::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
807 EngineError::Scheduler => "SchedulerError",
808 EngineError::OpStore => "OpStoreError",
809 EngineError::WriteValidation => "WriteValidationError",
810 EngineError::SchemaValidation => "SchemaValidationError",
811 EngineError::Overloaded => "OverloadedError",
812 EngineError::Closing => "ClosingError",
813 EngineError::Extractor => "ExtractorError",
814 EngineError::Consolidator => "ConsolidatorError",
815 EngineError::InvalidFilter { .. } => "InvalidFilterError",
817 EngineError::InvalidArgument { .. } => "InvalidArgumentError",
818 EngineError::VectorEquivalenceMismatch { .. } => "VectorEquivalenceMismatchError",
820 EngineError::IllegalTransition { .. } => "IllegalTransitionError",
822 EngineError::NotLifecycleAddressable { .. } => "NotLifecycleAddressableError",
823 EngineError::ErasureIncomplete { .. } => "ErasureIncompleteError",
825 EngineError::ProjectionDestructive { .. } => "ProjectionDestructiveError",
827 }
828}
829
830fn engine_open_error_code(err: &EngineOpenError) -> &'static str {
831 match err {
832 EngineOpenError::DatabaseLocked { .. } => "DatabaseLockedError",
833 EngineOpenError::Corruption(_) => "CorruptionError",
834 EngineOpenError::IncompatibleSchemaVersion { .. } => "IncompatibleSchemaVersionError",
835 EngineOpenError::MigrationError { .. } => "MigrationError",
836 EngineOpenError::EmbedderIdentityMismatch { .. } => "EmbedderIdentityMismatchError",
837 EngineOpenError::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
838 EngineOpenError::Embedder(_) => "EmbedderError",
839 EngineOpenError::Io { .. } => "IoError",
840 }
841}
842
843fn integrity_report_outcome(report: &IntegrityReport) -> (Value, CliOutcome) {
846 let any_findings = matches!(report.physical, Section::Findings(_))
847 || matches!(report.logical, Section::Findings(_))
848 || matches!(report.semantic, Section::Findings(_));
849 let body = json!({
850 "verb": "check-integrity",
851 "physical": section_json(&report.physical),
852 "logical": section_json(&report.logical),
853 "semantic": section_json(&report.semantic),
854 });
855 let outcome = if any_findings { CliOutcome::Findings } else { CliOutcome::Clean };
856 (body, outcome)
857}
858
859fn section_json(section: &Section) -> Value {
860 match section {
861 Section::Clean => json!({ "status": "clean", "findings": [] }),
862 Section::Findings(findings) => json!({
863 "status": "findings",
864 "findings": findings.iter().map(finding_json).collect::<Vec<_>>(),
865 }),
866 }
867}
868
869fn finding_json(f: &Finding) -> Value {
870 json!({
871 "code": f.code,
872 "stage": f.stage,
873 "locator": locator_json(&f.locator),
874 "doc_anchor": f.doc_anchor,
875 "detail": f.detail,
876 })
877}
878
879fn locator_json(loc: &CorruptionLocator) -> Value {
880 match loc {
881 CorruptionLocator::FileOffset { offset } => {
882 json!({ "kind": "file_offset", "offset": offset })
883 }
884 CorruptionLocator::PageId { page } => json!({ "kind": "page_id", "page": page }),
885 CorruptionLocator::TableRow { table, rowid } => {
886 json!({ "kind": "table_row", "table": table, "rowid": rowid })
887 }
888 CorruptionLocator::Vec0ShadowRow { partition, rowid } => {
889 json!({ "kind": "vec0_shadow_row", "partition": partition, "rowid": rowid })
890 }
891 CorruptionLocator::MigrationStep { from, to } => {
892 json!({ "kind": "migration_step", "from": from, "to": to })
893 }
894 CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
895 json!({
896 "kind": "opaque_sqlite_error",
897 "sqlite_extended_code": sqlite_extended_code,
898 })
899 }
900 }
901}
902
903fn safe_export_json(a: &SafeExportArtifact) -> Value {
904 json!({
905 "verb": "safe-export",
906 "export_path": a.export_path.to_string_lossy(),
907 "manifest_path": a.manifest_path.to_string_lossy(),
908 "manifest_sha256": a.manifest_sha256,
909 })
910}
911
912fn trace_report_json(t: &TraceReport) -> Value {
913 json!({
914 "verb": "trace",
915 "source_ref": t.source_ref,
916 "events": t.events.iter().map(|e| json!({
917 "write_cursor": e.write_cursor,
918 "kind": e.kind,
919 "table": e.table,
920 })).collect::<Vec<_>>(),
921 })
922}
923
924fn rebuild_report_json(verb: &'static str, r: &RebuildReport) -> Value {
925 let kind = match r.kind {
926 RebuildKind::Projections => "projections",
927 RebuildKind::Vec0 => "vec0",
928 };
929 json!({
930 "verb": verb,
931 "kind": kind,
932 "rows_invalidated": r.rows_invalidated,
933 "rows_rebuilt": r.rows_rebuilt,
934 "projection_cursor_after": r.projection_cursor_after,
935 })
936}
937
938fn excise_report_json(r: &ExciseReport) -> Value {
939 json!({
940 "verb": "excise-source",
941 "source_ref": r.source_ref,
942 "nodes_excised": r.nodes_excised,
943 "edges_excised": r.edges_excised,
944 "projections_invalidated": r.projections_invalidated,
945 })
946}
947
948fn excise_record_report_json(r: &ExciseRecordReport) -> Value {
953 json!({
954 "verb": "excise-record",
955 "collection": r.collection,
956 "record_digest": r.record_digest,
957 "records_excised": r.records_excised,
958 "state_rows_excised": r.state_rows_excised,
959 })
960}
961
962fn verify_embedder_report_json(r: &VerifyEmbedderReport) -> Value {
963 let status = match r.status {
964 VerifyEmbedderStatus::Match => "match",
965 VerifyEmbedderStatus::IdentityMismatch => "identity_mismatch",
966 VerifyEmbedderStatus::DimensionMismatch => "dimension_mismatch",
967 VerifyEmbedderStatus::BothMismatch => "both_mismatch",
968 };
969 json!({
970 "verb": "verify-embedder",
971 "stored_identity": r.stored_identity,
972 "stored_dimension": r.stored_dimension,
973 "supplied_identity": r.supplied_identity,
974 "supplied_dimension": r.supplied_dimension,
975 "status": status,
976 })
977}
978
979fn schema_object_json(o: &SchemaObject) -> Value {
980 json!({ "name": o.name, "sql": o.sql })
981}
982
983fn dump_schema_report_json(r: &DumpSchemaReport) -> Value {
984 json!({
985 "verb": "dump-schema",
986 "user_version": r.user_version,
987 "tables": r.tables.iter().map(schema_object_json).collect::<Vec<_>>(),
988 "indexes": r.indexes.iter().map(schema_object_json).collect::<Vec<_>>(),
989 })
990}
991
992fn recompute_mean_report_json(r: &MeanRecomputeReport) -> Value {
994 json!({
995 "verb": "recompute-mean",
996 "status": "ok",
997 "dim": r.dim,
998 "old_doc_count": r.old_doc_count,
999 "doc_count_requantized": r.doc_count_requantized,
1000 "drift_cos_before": r.drift_cos_before,
1001 "mean_was_pinned": r.mean_was_pinned,
1002 "elapsed_ms": r.elapsed_ms,
1003 })
1004}
1005
1006fn dump_row_counts_report_json(r: &DumpRowCountsReport) -> Value {
1007 json!({
1008 "verb": "dump-row-counts",
1009 "counts": r.counts.iter().map(|c| json!({
1010 "name": c.name,
1011 "rows": c.rows,
1012 })).collect::<Vec<_>>(),
1013 })
1014}
1015
1016fn orphan_provenance_report_json(r: &OrphanProvenanceReport) -> Value {
1017 json!({
1018 "verb": "orphan-provenance",
1019 "sources": r.sources.iter().map(|s| json!({
1020 "source_id": s.source_id,
1021 "rows": s.rows,
1022 "governed_rows": s.governed_rows,
1023 "reserved": s.reserved,
1024 })).collect::<Vec<_>>(),
1025 "total_rows": r.total_rows,
1026 "unerasable_rows": r.unerasable_rows,
1027 })
1028}
1029
1030fn dump_profile_report_json(r: &DumpProfileReport) -> Value {
1031 json!({
1032 "verb": "dump-profile",
1033 "embedder_identity": r.embedder_identity,
1034 "embedder_dimension": r.embedder_dimension,
1035 "vectorized_kinds": r.vectorized_kinds,
1036 })
1037}
1038
1039fn truncate_wal_report_json(r: &TruncateWalReport) -> Value {
1040 let status = match r.status {
1041 TruncateWalStatus::Done => "done",
1042 TruncateWalStatus::Busy => "busy",
1043 };
1044 json!({
1045 "verb": "truncate-wal",
1046 "status": status,
1047 "busy": r.busy,
1048 "log_frames": r.log_frames,
1049 "checkpointed_frames": r.checkpointed_frames,
1050 })
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055 use super::*;
1056
1057 #[test]
1058 fn outcome_mapping_covers_cli_md_exit_classes() {
1059 assert_eq!(outcome_to_exit_code(CliOutcome::Clean), 0);
1060 assert_eq!(outcome_to_exit_code(CliOutcome::RecoveryAcceptedLoss), 64);
1061 assert_eq!(outcome_to_exit_code(CliOutcome::Findings), 65);
1062 assert_eq!(outcome_to_exit_code(CliOutcome::ExportFailure), 66);
1063 assert_eq!(outcome_to_exit_code(CliOutcome::Unrecoverable), 70);
1064 assert_eq!(outcome_to_exit_code(CliOutcome::LockHeld), 71);
1065 }
1066
1067 #[test]
1068 fn engine_error_storage_maps_to_unrecoverable() {
1069 assert_eq!(engine_error_to_outcome(&EngineError::Storage), CliOutcome::Unrecoverable);
1070 assert_eq!(engine_error_to_outcome(&EngineError::Closing), CliOutcome::LockHeld);
1071 }
1072
1073 #[test]
1074 fn engine_open_database_locked_maps_to_lock_held() {
1075 let err = EngineOpenError::DatabaseLocked { holder_pid: Some(1234) };
1076 assert_eq!(engine_open_error_to_outcome(&err), CliOutcome::LockHeld);
1077 }
1078}