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}
132
133#[derive(Debug, Args)]
135pub struct WarmCacheArgs {
136 #[arg(long)]
138 pub json: bool,
139}
140
141#[derive(Debug, Args)]
145pub struct SimpleDoctorArgs {
146 #[arg(long)]
148 pub json: bool,
149
150 pub db_path: PathBuf,
152}
153
154#[derive(Debug, Args)]
156pub struct CheckIntegrityArgs {
157 #[arg(long)]
159 pub quick: bool,
160
161 #[arg(long)]
163 pub full: bool,
164
165 #[arg(long = "round-trip")]
167 pub round_trip: bool,
168
169 #[arg(long)]
171 pub pretty: bool,
172
173 #[arg(long)]
175 pub json: bool,
176
177 pub db_path: PathBuf,
179}
180
181#[derive(Debug, Args)]
183pub struct SafeExportArgs {
184 pub out: PathBuf,
186
187 #[arg(long)]
189 pub manifest: Option<PathBuf>,
190
191 #[arg(long)]
193 pub json: bool,
194
195 pub db_path: PathBuf,
197}
198
199#[derive(Debug, Args)]
203pub struct VerifyEmbedderArgs {
204 #[arg(long)]
207 pub identity: String,
208
209 #[arg(long)]
211 pub dimension: u32,
212
213 #[arg(long)]
215 pub json: bool,
216
217 pub db_path: PathBuf,
219}
220
221#[derive(Debug, Args)]
223pub struct TraceArgs {
224 #[arg(long = "source-ref")]
226 pub source_ref: String,
227
228 #[arg(long)]
230 pub json: bool,
231
232 pub db_path: PathBuf,
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub enum CliOutcome {
240 Clean,
242 Findings,
244 ExportFailure,
246 RecoveryAcceptedLoss,
248 LockHeld,
250 Unrecoverable,
252}
253
254#[must_use]
256pub fn outcome_to_exit_code(outcome: CliOutcome) -> i32 {
257 match outcome {
258 CliOutcome::Clean => exit_code::OK,
259 CliOutcome::Findings => exit_code::DOCTOR_FOUND_ISSUES,
260 CliOutcome::ExportFailure => exit_code::EXPORT_FAILURE,
261 CliOutcome::RecoveryAcceptedLoss => exit_code::RECOVERY_ACCEPTED_LOSS,
262 CliOutcome::LockHeld => exit_code::LOCK_HELD,
263 CliOutcome::Unrecoverable => exit_code::UNRECOVERABLE,
264 }
265}
266
267#[must_use]
270pub fn engine_error_to_outcome(err: &EngineError) -> CliOutcome {
271 match err {
272 EngineError::Closing => CliOutcome::LockHeld,
273 _ => CliOutcome::Unrecoverable,
274 }
275}
276
277#[must_use]
280pub fn engine_open_error_to_outcome(err: &EngineOpenError) -> CliOutcome {
281 match err {
282 EngineOpenError::DatabaseLocked { .. } => CliOutcome::LockHeld,
283 _ => CliOutcome::Unrecoverable,
284 }
285}
286
287#[must_use]
298pub fn run(cli: Cli) -> i32 {
299 match cli.command {
300 Command::Recover(args) => run_recover(args),
301 Command::Doctor(d) => run_doctor(d.command),
302 }
303}
304
305fn run_recover(args: RecoverArgs) -> i32 {
306 if !args.accept_data_loss {
307 println!(
308 r#"{{"status":"refused","verb":"recover","code":"E_RECOVER_REQUIRES_ACCEPT_DATA_LOSS"}}"#
309 );
310 return exit_code::UNRECOVERABLE;
311 }
312
313 if args.rebuild_projections {
314 return wire_recover(&args.db_path, "rebuild-projections", |e| {
315 e.rebuild_projections().map(|r| rebuild_report_json("rebuild-projections", &r))
316 });
317 }
318 if args.rebuild_vec0 {
319 return wire_recover(&args.db_path, "rebuild-vec0", |e| {
320 e.rebuild_vec0().map(|r| rebuild_report_json("rebuild-vec0", &r))
321 });
322 }
323 if let Some(source_id) = args.excise_source.as_deref() {
324 return wire_recover(&args.db_path, "excise-source", |e| {
325 e.excise_source(source_id).map(|r| excise_report_json(&r))
326 });
327 }
328 if args.truncate_wal {
329 return wire_recover(&args.db_path, "truncate-wal", |e| {
330 e.truncate_wal().map(|r| truncate_wal_report_json(&r))
331 });
332 }
333
334 println!(r#"{{"status":"not_implemented","verb":"recover"}}"#);
336 exit_code::UNRECOVERABLE
337}
338
339fn run_doctor(cmd: DoctorCommand) -> i32 {
340 match cmd {
341 DoctorCommand::CheckIntegrity(args) => {
342 let opts = CheckIntegrityOpts {
343 quick: args.quick,
344 full: args.full,
345 round_trip: args.round_trip,
346 };
347 run_doctor_verb(&args.db_path, "check-integrity", |e| {
348 e.check_integrity(opts).map(|r| integrity_report_outcome(&r))
349 })
350 }
351 DoctorCommand::SafeExport(args) => {
352 let manifest = args.manifest.clone().unwrap_or_else(|| {
353 let mut p = args.out.clone();
354 let name = p
355 .file_name()
356 .map(|s| s.to_string_lossy().into_owned())
357 .unwrap_or_else(|| "export".to_string());
358 p.set_file_name(format!("{name}.manifest.json"));
359 p
360 });
361 run_doctor_verb_with_error_outcome(
362 &args.db_path,
363 "safe-export",
364 CliOutcome::ExportFailure,
365 |e| {
366 e.safe_export(&args.out, &manifest)
367 .map(|r| (safe_export_json(&r), CliOutcome::Clean))
368 },
369 )
370 }
371 DoctorCommand::Trace(args) => run_doctor_verb(&args.db_path, "trace", |e| {
372 e.trace_source_ref(&args.source_ref).map(|r| (trace_report_json(&r), CliOutcome::Clean))
373 }),
374 DoctorCommand::VerifyEmbedder(args) => {
375 let identity = args.identity.clone();
376 let dimension = args.dimension;
377 run_doctor_verb(&args.db_path, "verify-embedder", |e| {
378 e.verify_embedder(&identity, dimension)
379 .map(|r| (verify_embedder_report_json(&r), CliOutcome::Clean))
380 })
381 }
382 DoctorCommand::DumpSchema(args) => run_doctor_verb(&args.db_path, "dump-schema", |e| {
383 e.dump_schema().map(|r| (dump_schema_report_json(&r), CliOutcome::Clean))
384 }),
385 DoctorCommand::DumpRowCounts(args) => {
386 run_doctor_verb(&args.db_path, "dump-row-counts", |e| {
387 e.dump_row_counts().map(|r| (dump_row_counts_report_json(&r), CliOutcome::Clean))
388 })
389 }
390 DoctorCommand::DumpProfile(args) => run_doctor_verb(&args.db_path, "dump-profile", |e| {
391 e.dump_profile().map(|r| (dump_profile_report_json(&r), CliOutcome::Clean))
392 }),
393 DoctorCommand::WarmCache(args) => run_doctor_warm_cache(args),
394 DoctorCommand::RecomputeMean(args) => {
395 run_doctor_verb(&args.db_path, "recompute-mean", |e| {
396 e.recompute_mean().map(|r| (recompute_mean_report_json(&r), CliOutcome::Clean))
397 })
398 }
399 }
400}
401
402fn run_doctor_warm_cache(args: WarmCacheArgs) -> i32 {
406 #[cfg(feature = "default-embedder")]
407 {
408 match fathomdb_embedder::loader::load_pinned_default_embedder() {
409 Ok(weights) => {
410 if args.json {
411 let payload = json!({
412 "verb": "warm-cache",
413 "status": "ok",
414 "config_json": weights.config_json_path.to_string_lossy(),
415 "tokenizer_json": weights.tokenizer_json_path.to_string_lossy(),
416 "model_safetensors": weights.model_safetensors_path.to_string_lossy(),
417 "bytes_downloaded": weights.bytes_downloaded,
418 "events": weights
419 .events
420 .iter()
421 .map(warm_cache_event_json)
422 .collect::<Vec<_>>(),
423 });
424 println!("{payload}");
425 } else {
426 let kind = if weights.bytes_downloaded > 0 { "cold" } else { "warm" };
427 println!("warm-cache: ok ({kind})");
428 println!(" config.json: {}", weights.config_json_path.display());
429 println!(" tokenizer.json: {}", weights.tokenizer_json_path.display());
430 println!(" model.safetensors: {}", weights.model_safetensors_path.display());
431 println!(" bytes downloaded: {}", weights.bytes_downloaded);
432 println!(" events: {}", weights.events.len());
433 }
434 exit_code::OK
435 }
436 Err(err) => {
437 if args.json {
438 let payload = json!({
439 "verb": "warm-cache",
440 "status": "error",
441 "code": "EmbedderLoadError",
442 "detail": err.to_string(),
443 });
444 println!("{payload}");
445 } else {
446 eprintln!("warm-cache: error: {err}");
447 }
448 exit_code::UNRECOVERABLE
449 }
450 }
451 }
452 #[cfg(not(feature = "default-embedder"))]
453 {
454 let detail = "fathomdb CLI was built without the `default-embedder` feature; rebuild with --features default-embedder";
455 if args.json {
456 let payload = json!({
457 "verb": "warm-cache",
458 "status": "error",
459 "code": "DefaultEmbedderFeatureDisabled",
460 "detail": detail,
461 });
462 println!("{payload}");
463 } else {
464 eprintln!("warm-cache: error: {detail}");
465 }
466 exit_code::UNRECOVERABLE
467 }
468}
469
470#[cfg(feature = "default-embedder")]
471fn warm_cache_event_json(ev: &fathomdb_embedder::EmbedderEvent) -> Value {
472 use fathomdb_embedder::EmbedderEvent;
473 match ev {
474 EmbedderEvent::DefaultEmbedderDownload {
475 file,
476 url,
477 bytes,
478 sha256,
479 cache_path,
480 duration_ms,
481 } => json!({
482 "kind": "download",
483 "file": file,
484 "url": url,
485 "bytes": bytes,
486 "sha256": sha256,
487 "cache_path": cache_path.to_string_lossy(),
488 "duration_ms": duration_ms,
489 }),
490 EmbedderEvent::DefaultEmbedderCacheHit { file, sha256, cache_path } => json!({
491 "kind": "cache_hit",
492 "file": file,
493 "sha256": sha256,
494 "cache_path": cache_path.to_string_lossy(),
495 }),
496 EmbedderEvent::MeanVecPinned { dim, doc_count } => json!({
497 "kind": "mean_vec_pinned",
498 "dim": dim,
499 "doc_count": doc_count,
500 }),
501 EmbedderEvent::MeanVecRecomputed { dim, doc_count, trigger } => json!({
502 "kind": "mean_vec_recomputed",
503 "dim": dim,
504 "doc_count": doc_count,
505 "trigger": trigger.as_str(),
506 }),
507 }
508}
509
510fn run_doctor_verb<F>(db_path: &std::path::Path, verb: &str, f: F) -> i32
513where
514 F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
515{
516 run_doctor_verb_inner(db_path, verb, None, f)
517}
518
519fn run_doctor_verb_with_error_outcome<F>(
524 db_path: &std::path::Path,
525 verb: &str,
526 error_outcome: CliOutcome,
527 f: F,
528) -> i32
529where
530 F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
531{
532 run_doctor_verb_inner(db_path, verb, Some(error_outcome), f)
533}
534
535fn run_doctor_verb_inner<F>(
536 db_path: &std::path::Path,
537 verb: &str,
538 error_outcome: Option<CliOutcome>,
539 f: F,
540) -> i32
541where
542 F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
543{
544 let opened = match Engine::open(db_path.to_path_buf()) {
545 Ok(o) => o,
546 Err(err) => return emit_engine_open_error(verb, &err),
547 };
548 match f(&opened.engine) {
549 Ok((value, outcome)) => {
550 println!("{value}");
551 outcome_to_exit_code(outcome)
552 }
553 Err(err) => match error_outcome {
554 Some(outcome) => emit_engine_error_with_outcome(verb, &err, outcome),
555 None => emit_engine_error(verb, &err),
556 },
557 }
558}
559
560fn wire_recover<F>(db_path: &std::path::Path, sub_verb: &str, f: F) -> i32
563where
564 F: FnOnce(&Engine) -> Result<Value, EngineError>,
565{
566 let opened = match Engine::open(db_path.to_path_buf()) {
567 Ok(o) => o,
568 Err(err) => return emit_engine_open_error(sub_verb, &err),
569 };
570 match f(&opened.engine) {
571 Ok(value) => {
572 println!("{value}");
573 outcome_to_exit_code(CliOutcome::RecoveryAcceptedLoss)
574 }
575 Err(err) => emit_engine_error(sub_verb, &err),
576 }
577}
578
579fn emit_engine_error(verb: &str, err: &EngineError) -> i32 {
580 emit_engine_error_with_outcome(verb, err, engine_error_to_outcome(err))
581}
582
583fn emit_engine_error_with_outcome(verb: &str, err: &EngineError, outcome: CliOutcome) -> i32 {
584 let payload = json!({
585 "status": "error",
586 "verb": verb,
587 "code": engine_error_code(err),
588 "detail": err.to_string(),
589 });
590 println!("{payload}");
591 outcome_to_exit_code(outcome)
592}
593
594fn emit_engine_open_error(verb: &str, err: &EngineOpenError) -> i32 {
595 let outcome = engine_open_error_to_outcome(err);
596 let payload = json!({
597 "status": "error",
598 "verb": verb,
599 "code": engine_open_error_code(err),
600 "detail": err.to_string(),
601 });
602 println!("{payload}");
603 outcome_to_exit_code(outcome)
604}
605
606fn engine_error_code(err: &EngineError) -> &'static str {
607 match err {
608 EngineError::Storage => "StorageError",
609 EngineError::Projection => "ProjectionError",
610 EngineError::Vector => "VectorError",
611 EngineError::Embedder => "EmbedderError",
612 EngineError::EmbedderNotConfigured => "EmbedderNotConfiguredError",
613 EngineError::KindNotVectorIndexed => "KindNotVectorIndexedError",
614 EngineError::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
615 EngineError::Scheduler => "SchedulerError",
616 EngineError::OpStore => "OpStoreError",
617 EngineError::WriteValidation => "WriteValidationError",
618 EngineError::SchemaValidation => "SchemaValidationError",
619 EngineError::Overloaded => "OverloadedError",
620 EngineError::Closing => "ClosingError",
621 }
622}
623
624fn engine_open_error_code(err: &EngineOpenError) -> &'static str {
625 match err {
626 EngineOpenError::DatabaseLocked { .. } => "DatabaseLockedError",
627 EngineOpenError::Corruption(_) => "CorruptionError",
628 EngineOpenError::IncompatibleSchemaVersion { .. } => "IncompatibleSchemaVersionError",
629 EngineOpenError::MigrationError { .. } => "MigrationError",
630 EngineOpenError::EmbedderIdentityMismatch { .. } => "EmbedderIdentityMismatchError",
631 EngineOpenError::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
632 EngineOpenError::Embedder(_) => "EmbedderError",
633 EngineOpenError::Io { .. } => "IoError",
634 }
635}
636
637fn integrity_report_outcome(report: &IntegrityReport) -> (Value, CliOutcome) {
640 let any_findings = matches!(report.physical, Section::Findings(_))
641 || matches!(report.logical, Section::Findings(_))
642 || matches!(report.semantic, Section::Findings(_));
643 let body = json!({
644 "verb": "check-integrity",
645 "physical": section_json(&report.physical),
646 "logical": section_json(&report.logical),
647 "semantic": section_json(&report.semantic),
648 });
649 let outcome = if any_findings { CliOutcome::Findings } else { CliOutcome::Clean };
650 (body, outcome)
651}
652
653fn section_json(section: &Section) -> Value {
654 match section {
655 Section::Clean => json!({ "status": "clean", "findings": [] }),
656 Section::Findings(findings) => json!({
657 "status": "findings",
658 "findings": findings.iter().map(finding_json).collect::<Vec<_>>(),
659 }),
660 }
661}
662
663fn finding_json(f: &Finding) -> Value {
664 json!({
665 "code": f.code,
666 "stage": f.stage,
667 "locator": locator_json(&f.locator),
668 "doc_anchor": f.doc_anchor,
669 "detail": f.detail,
670 })
671}
672
673fn locator_json(loc: &CorruptionLocator) -> Value {
674 match loc {
675 CorruptionLocator::FileOffset { offset } => {
676 json!({ "kind": "file_offset", "offset": offset })
677 }
678 CorruptionLocator::PageId { page } => json!({ "kind": "page_id", "page": page }),
679 CorruptionLocator::TableRow { table, rowid } => {
680 json!({ "kind": "table_row", "table": table, "rowid": rowid })
681 }
682 CorruptionLocator::Vec0ShadowRow { partition, rowid } => {
683 json!({ "kind": "vec0_shadow_row", "partition": partition, "rowid": rowid })
684 }
685 CorruptionLocator::MigrationStep { from, to } => {
686 json!({ "kind": "migration_step", "from": from, "to": to })
687 }
688 CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
689 json!({
690 "kind": "opaque_sqlite_error",
691 "sqlite_extended_code": sqlite_extended_code,
692 })
693 }
694 }
695}
696
697fn safe_export_json(a: &SafeExportArtifact) -> Value {
698 json!({
699 "verb": "safe-export",
700 "export_path": a.export_path.to_string_lossy(),
701 "manifest_path": a.manifest_path.to_string_lossy(),
702 "manifest_sha256": a.manifest_sha256,
703 })
704}
705
706fn trace_report_json(t: &TraceReport) -> Value {
707 json!({
708 "verb": "trace",
709 "source_ref": t.source_ref,
710 "events": t.events.iter().map(|e| json!({
711 "write_cursor": e.write_cursor,
712 "kind": e.kind,
713 "table": e.table,
714 })).collect::<Vec<_>>(),
715 })
716}
717
718fn rebuild_report_json(verb: &'static str, r: &RebuildReport) -> Value {
719 let kind = match r.kind {
720 RebuildKind::Projections => "projections",
721 RebuildKind::Vec0 => "vec0",
722 };
723 json!({
724 "verb": verb,
725 "kind": kind,
726 "rows_invalidated": r.rows_invalidated,
727 "rows_rebuilt": r.rows_rebuilt,
728 "projection_cursor_after": r.projection_cursor_after,
729 })
730}
731
732fn excise_report_json(r: &ExciseReport) -> Value {
733 json!({
734 "verb": "excise-source",
735 "source_ref": r.source_ref,
736 "nodes_excised": r.nodes_excised,
737 "edges_excised": r.edges_excised,
738 "projections_invalidated": r.projections_invalidated,
739 })
740}
741
742fn verify_embedder_report_json(r: &VerifyEmbedderReport) -> Value {
743 let status = match r.status {
744 VerifyEmbedderStatus::Match => "match",
745 VerifyEmbedderStatus::IdentityMismatch => "identity_mismatch",
746 VerifyEmbedderStatus::DimensionMismatch => "dimension_mismatch",
747 VerifyEmbedderStatus::BothMismatch => "both_mismatch",
748 };
749 json!({
750 "verb": "verify-embedder",
751 "stored_identity": r.stored_identity,
752 "stored_dimension": r.stored_dimension,
753 "supplied_identity": r.supplied_identity,
754 "supplied_dimension": r.supplied_dimension,
755 "status": status,
756 })
757}
758
759fn schema_object_json(o: &SchemaObject) -> Value {
760 json!({ "name": o.name, "sql": o.sql })
761}
762
763fn dump_schema_report_json(r: &DumpSchemaReport) -> Value {
764 json!({
765 "verb": "dump-schema",
766 "user_version": r.user_version,
767 "tables": r.tables.iter().map(schema_object_json).collect::<Vec<_>>(),
768 "indexes": r.indexes.iter().map(schema_object_json).collect::<Vec<_>>(),
769 })
770}
771
772fn recompute_mean_report_json(r: &MeanRecomputeReport) -> Value {
774 json!({
775 "verb": "recompute-mean",
776 "status": "ok",
777 "dim": r.dim,
778 "old_doc_count": r.old_doc_count,
779 "doc_count_requantized": r.doc_count_requantized,
780 "drift_cos_before": r.drift_cos_before,
781 "mean_was_pinned": r.mean_was_pinned,
782 "elapsed_ms": r.elapsed_ms,
783 })
784}
785
786fn dump_row_counts_report_json(r: &DumpRowCountsReport) -> Value {
787 json!({
788 "verb": "dump-row-counts",
789 "counts": r.counts.iter().map(|c| json!({
790 "name": c.name,
791 "rows": c.rows,
792 })).collect::<Vec<_>>(),
793 })
794}
795
796fn dump_profile_report_json(r: &DumpProfileReport) -> Value {
797 json!({
798 "verb": "dump-profile",
799 "embedder_identity": r.embedder_identity,
800 "embedder_dimension": r.embedder_dimension,
801 "vectorized_kinds": r.vectorized_kinds,
802 })
803}
804
805fn truncate_wal_report_json(r: &TruncateWalReport) -> Value {
806 let status = match r.status {
807 TruncateWalStatus::Done => "done",
808 TruncateWalStatus::Busy => "busy",
809 };
810 json!({
811 "verb": "truncate-wal",
812 "status": status,
813 "busy": r.busy,
814 "log_frames": r.log_frames,
815 "checkpointed_frames": r.checkpointed_frames,
816 })
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 #[test]
824 fn outcome_mapping_covers_cli_md_exit_classes() {
825 assert_eq!(outcome_to_exit_code(CliOutcome::Clean), 0);
826 assert_eq!(outcome_to_exit_code(CliOutcome::RecoveryAcceptedLoss), 64);
827 assert_eq!(outcome_to_exit_code(CliOutcome::Findings), 65);
828 assert_eq!(outcome_to_exit_code(CliOutcome::ExportFailure), 66);
829 assert_eq!(outcome_to_exit_code(CliOutcome::Unrecoverable), 70);
830 assert_eq!(outcome_to_exit_code(CliOutcome::LockHeld), 71);
831 }
832
833 #[test]
834 fn engine_error_storage_maps_to_unrecoverable() {
835 assert_eq!(engine_error_to_outcome(&EngineError::Storage), CliOutcome::Unrecoverable);
836 assert_eq!(engine_error_to_outcome(&EngineError::Closing), CliOutcome::LockHeld);
837 }
838
839 #[test]
840 fn engine_open_database_locked_maps_to_lock_held() {
841 let err = EngineOpenError::DatabaseLocked { holder_pid: Some(1234) };
842 assert_eq!(engine_open_error_to_outcome(&err), CliOutcome::LockHeld);
843 }
844}