Skip to main content

bhtune_cli/commands/
recovery.rs

1use std::{future::Future, process::ExitCode};
2
3use bhtune_runtime::tune::{
4    RecoveryStepReport, RecoveryStepStatus, RestoreLoopReport, RestoreLoopRequest, restore_loop,
5};
6
7use crate::{EXIT_RESTORE_INCOMPLETE, args::RestoreLoopArgs, cancel::CtrlC, output::OutputFormat};
8
9pub async fn run(
10    pool: &bhtune_db::SqlitePool,
11    args: RestoreLoopArgs,
12    ctrl_c: &mut CtrlC,
13) -> anyhow::Result<ExitCode> {
14    let output = args.output;
15    let recovery = restore_loop(
16        pool,
17        RestoreLoopRequest {
18            run_id: args.run_id,
19            yes: args.yes,
20            bridge_host: args.bridge_host,
21            server: args.server,
22        },
23        ctrl_c,
24    );
25    run_with_report(recovery, output).await
26}
27
28async fn run_with_report<F>(recovery: F, output: OutputFormat) -> anyhow::Result<ExitCode>
29where
30    F: Future<Output = anyhow::Result<RestoreLoopReport>>,
31{
32    let report = recovery.await?;
33    print_report(&report, output)?;
34    match report.status {
35        bhtune_db::models::RecoveryAttemptStatus::Confirmed if report.persisted => {
36            Ok(ExitCode::SUCCESS)
37        }
38        _ => {
39            eprintln!(
40                "WARNING: loop recovery was not confirmed for run {}. Inspect the loop and the per-step evidence manually{}",
41                report.run_id,
42                report
43                    .detail
44                    .as_deref()
45                    .map(|detail| format!(": {detail}"))
46                    .unwrap_or_default()
47            );
48            Ok(ExitCode::from(EXIT_RESTORE_INCOMPLETE))
49        }
50    }
51}
52
53fn print_report(report: &RestoreLoopReport, output: OutputFormat) -> anyhow::Result<()> {
54    match output {
55        OutputFormat::Json => {
56            println!("{}", serde_json::to_string_pretty(report)?);
57        }
58        OutputFormat::Table => print_table(report),
59    }
60    Ok(())
61}
62
63fn print_table(report: &RestoreLoopReport) {
64    println!("Restore loop run: {}", report.run_id);
65    println!("Recovery attempt: {}", report.attempt_id);
66    println!("Status: {:?}", report.status);
67    println!(
68        "Audit persisted: {}",
69        if report.persisted { "yes" } else { "no" }
70    );
71    println!("Evidence export: {}", report.export_path);
72    println!();
73    println!("{:<18} {:<13} {:<24} DETAIL", "STEP", "STATUS", "TARGET");
74    for step in &report.steps {
75        print_step(step);
76    }
77    if let Some(detail) = &report.detail {
78        println!();
79        println!("Detail: {}", single_line(detail));
80    }
81}
82
83fn print_step(step: &RecoveryStepReport) {
84    println!(
85        "{:<18} {:<13} {:<24} {}",
86        step.step,
87        step_status_label(step.status),
88        step.target.as_deref().unwrap_or("-"),
89        step.detail.as_deref().map(single_line).unwrap_or_default()
90    );
91}
92
93fn step_status_label(status: RecoveryStepStatus) -> &'static str {
94    match status {
95        RecoveryStepStatus::NotNeeded => "not needed",
96        RecoveryStepStatus::Succeeded => "succeeded",
97        RecoveryStepStatus::Failed => "failed",
98    }
99}
100
101fn single_line(value: &str) -> String {
102    value.replace(['\r', '\n'], " ")
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    fn report(
110        status: bhtune_db::models::RecoveryAttemptStatus,
111        persisted: bool,
112    ) -> RestoreLoopReport {
113        RestoreLoopReport {
114            run_id: 12,
115            attempt_id: 34,
116            status,
117            persisted,
118            export_path: "bhtune.db.recovery/evidence.json".to_string(),
119            steps: vec![
120                RecoveryStepReport {
121                    step: "mv".to_string(),
122                    status: RecoveryStepStatus::Succeeded,
123                    target: Some("50".to_string()),
124                    detail: Some("confirmed".to_string()),
125                },
126                RecoveryStepReport {
127                    step: "mode".to_string(),
128                    status: RecoveryStepStatus::NotNeeded,
129                    target: None,
130                    detail: None,
131                },
132            ],
133            detail: Some("first line\nsecond line".to_string()),
134        }
135    }
136
137    #[test]
138    fn step_statuses_have_human_readable_labels() {
139        assert_eq!(
140            step_status_label(RecoveryStepStatus::NotNeeded),
141            "not needed"
142        );
143        assert_eq!(
144            step_status_label(RecoveryStepStatus::Succeeded),
145            "succeeded"
146        );
147        assert_eq!(step_status_label(RecoveryStepStatus::Failed), "failed");
148        assert_eq!(single_line("one\ntwo\rthree"), "one two three");
149    }
150
151    #[test]
152    fn recovery_report_has_one_json_value() {
153        let value = report(bhtune_db::models::RecoveryAttemptStatus::Incomplete, false);
154        let json = serde_json::to_string(&value).unwrap();
155        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
156        assert_eq!(value["run_id"], 12);
157        assert_eq!(value["steps"].as_array().unwrap().len(), 2);
158    }
159
160    #[tokio::test]
161    async fn run_requires_explicit_confirmation_before_recovery() {
162        let pool = bhtune_db::connect_in_memory().await.unwrap();
163        let args = RestoreLoopArgs {
164            run_id: 12,
165            bridge_host: None,
166            server: None,
167            yes: false,
168            output: OutputFormat::Table,
169        };
170        let (mut ctrl_c, _handle) = CtrlC::manual();
171        let error = run(&pool, args, &mut ctrl_c).await.unwrap_err();
172        assert!(error.to_string().contains("requires --yes"));
173    }
174
175    #[tokio::test]
176    async fn run_report_distinguishes_persisted_confirmation_and_incomplete_recovery() {
177        let confirmed = run_with_report(
178            async {
179                Ok(report(
180                    bhtune_db::models::RecoveryAttemptStatus::Confirmed,
181                    true,
182                ))
183            },
184            OutputFormat::Table,
185        )
186        .await
187        .unwrap();
188        assert_eq!(confirmed, ExitCode::SUCCESS);
189
190        let incomplete = run_with_report(
191            async {
192                Ok(report(
193                    bhtune_db::models::RecoveryAttemptStatus::Incomplete,
194                    false,
195                ))
196            },
197            OutputFormat::Json,
198        )
199        .await
200        .unwrap();
201        assert_eq!(incomplete, ExitCode::from(EXIT_RESTORE_INCOMPLETE));
202
203        let error = run_with_report(
204            async { Err(anyhow::anyhow!("recovery setup failed")) },
205            OutputFormat::Table,
206        )
207        .await
208        .unwrap_err();
209        assert!(error.to_string().contains("recovery setup failed"));
210    }
211}