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}