memvid_cli/commands/
maintenance.rs

1//! Maintenance command handlers (verify, doctor, verify-single-file, nudge).
2//! These commands surface integrity checks, healing workflows, and lock inspection
3//! for `.mv2` files without mutating unrelated state.
4
5use std::path::{Path, PathBuf};
6
7use anyhow::{anyhow, bail, Result};
8use clap::{ArgAction, Args};
9use memvid_core::{
10    lockfile, DoctorActionDetail, DoctorActionKind, DoctorFindingCode, DoctorOptions,
11    DoctorPhaseKind, DoctorReport, DoctorSeverity, DoctorStatus, Memvid, VerificationStatus,
12};
13
14use crate::config::CliConfig;
15
16/// Arguments for the `nudge` subcommand
17#[derive(Args)]
18pub struct NudgeArgs {
19    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
20    pub file: PathBuf,
21}
22
23/// Arguments for the `verify` subcommand
24#[derive(Args)]
25pub struct VerifyArgs {
26    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
27    pub file: PathBuf,
28    #[arg(long)]
29    pub deep: bool,
30    #[arg(long)]
31    pub json: bool,
32}
33
34/// Arguments for the `doctor` subcommand
35#[derive(Args)]
36pub struct DoctorArgs {
37    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
38    pub file: PathBuf,
39    #[arg(long = "rebuild-time-index", action = ArgAction::SetTrue)]
40    pub rebuild_time_index: bool,
41    #[arg(long = "rebuild-lex-index", action = ArgAction::SetTrue)]
42    pub rebuild_lex_index: bool,
43    #[arg(long = "rebuild-vec-index", action = ArgAction::SetTrue)]
44    pub rebuild_vec_index: bool,
45    #[arg(long = "vacuum", action = ArgAction::SetTrue)]
46    pub vacuum: bool,
47    #[arg(long = "plan-only", action = ArgAction::SetTrue)]
48    pub plan_only: bool,
49    #[arg(long = "json", action = ArgAction::SetTrue)]
50    pub json: bool,
51}
52
53/// Arguments for the `verify-single-file` subcommand
54#[derive(Args)]
55pub struct VerifySingleFileArgs {
56    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
57    pub file: PathBuf,
58}
59
60// ============================================================================
61// Maintenance command handlers
62// ============================================================================
63
64pub fn handle_verify(_config: &CliConfig, args: VerifyArgs) -> Result<()> {
65    let report = Memvid::verify(&args.file, args.deep)?;
66    if args.json {
67        println!("{}", serde_json::to_string_pretty(&report)?);
68    } else {
69        println!("Verification report for {}", args.file.display());
70        for check in &report.checks {
71            match &check.details {
72                Some(details) => println!("- {}: {:?} ({details})", check.name, check.status),
73                None => println!("- {}: {:?}", check.name, check.status),
74            }
75        }
76        println!("Overall: {:?}", report.overall_status);
77    }
78
79    if report.overall_status == VerificationStatus::Failed {
80        anyhow::bail!("verification failed");
81    }
82    Ok(())
83}
84
85pub fn handle_doctor(_config: &CliConfig, args: DoctorArgs) -> Result<()> {
86    let options = DoctorOptions {
87        rebuild_time_index: args.rebuild_time_index,
88        rebuild_lex_index: args.rebuild_lex_index,
89        rebuild_vec_index: args.rebuild_vec_index,
90        vacuum: args.vacuum,
91        dry_run: args.plan_only,
92    };
93
94    let report = Memvid::doctor(&args.file, options)?;
95
96    if args.json {
97        println!("{}", serde_json::to_string_pretty(&report)?);
98    } else {
99        print_doctor_report(&args.file, &report);
100    }
101
102    match report.status {
103        DoctorStatus::Failed => anyhow::bail!("doctor failed; see findings for details"),
104        DoctorStatus::Partial => {
105            anyhow::bail!("doctor completed with partial repairs; rerun or restore from backup")
106        }
107        DoctorStatus::PlanOnly => {
108            if report.plan.is_noop() && !args.json {
109                println!(
110                    "No repairs required for {} (plan-only run)",
111                    args.file.display()
112                );
113            } else if !args.json {
114                println!("Plan generated. Re-run without --plan-only to apply repairs.");
115            }
116            Ok(())
117        }
118        _ => Ok(()),
119    }
120}
121
122fn print_doctor_report(path: &Path, report: &DoctorReport) {
123    println!("Doctor status for {}: {:?}", path.display(), report.status);
124
125    if !report.plan.findings.is_empty() {
126        println!("Findings:");
127        for finding in &report.plan.findings {
128            let severity = format_severity(finding.severity);
129            let code = format_finding_code(finding.code);
130            match &finding.detail {
131                Some(detail) => println!(
132                    "  - [{}] {}: {} ({detail})",
133                    severity, code, finding.message
134                ),
135                None => println!("  - [{}] {}: {}", severity, code, finding.message),
136            }
137        }
138    }
139
140    if report.plan.phases.is_empty() {
141        println!("Planned phases: (none)");
142    } else {
143        println!("Planned phases:");
144        for phase in &report.plan.phases {
145            println!("  - {}", label_phase(phase.phase));
146            for action in &phase.actions {
147                let mut notes: Vec<String> = Vec::new();
148                if action.required {
149                    notes.push("required".into());
150                }
151                if !action.reasons.is_empty() {
152                    let reasons: Vec<String> = action
153                        .reasons
154                        .iter()
155                        .map(|code| format_finding_code(*code))
156                        .collect();
157                    notes.push(format!("reasons: {}", reasons.join(", ")));
158                }
159                if let Some(detail) = &action.detail {
160                    notes.push(format_action_detail(detail));
161                }
162                if let Some(note) = &action.note {
163                    notes.push(note.clone());
164                }
165                let suffix = if notes.is_empty() {
166                    String::new()
167                } else {
168                    format!(" ({})", notes.join(" | "))
169                };
170                println!("    * {}{}", label_action(action.action), suffix);
171            }
172        }
173    }
174
175    if report.phases.is_empty() {
176        println!("Execution: (skipped)");
177    } else {
178        println!("Execution:");
179        for phase in &report.phases {
180            println!("  - {}: {:?}", label_phase(phase.phase), phase.status);
181            if let Some(duration) = phase.duration_ms {
182                println!("      duration: {} ms", duration);
183            }
184            for action in &phase.actions {
185                match &action.detail {
186                    Some(detail) => println!(
187                        "    * {}: {:?} ({detail})",
188                        label_action(action.action),
189                        action.status
190                    ),
191                    None => println!("    * {}: {:?}", label_action(action.action), action.status),
192                }
193            }
194        }
195    }
196
197    if report.metrics.total_duration_ms > 0 {
198        println!("Total duration: {} ms", report.metrics.total_duration_ms);
199    }
200}
201
202fn format_severity(severity: DoctorSeverity) -> &'static str {
203    match severity {
204        DoctorSeverity::Info => "info",
205        DoctorSeverity::Warning => "warning",
206        DoctorSeverity::Error => "error",
207    }
208}
209
210fn format_finding_code(code: DoctorFindingCode) -> String {
211    serde_json::to_string(&code)
212        .map(|value| value.trim_matches('"').replace('_', " "))
213        .unwrap_or_else(|_| format!("{code:?}"))
214}
215
216fn label_phase(kind: DoctorPhaseKind) -> &'static str {
217    match kind {
218        DoctorPhaseKind::Probe => "probe",
219        DoctorPhaseKind::HeaderHealing => "header healing",
220        DoctorPhaseKind::WalReplay => "wal replay",
221        DoctorPhaseKind::IndexRebuild => "index rebuild",
222        DoctorPhaseKind::Vacuum => "vacuum",
223        DoctorPhaseKind::Finalize => "finalize",
224        DoctorPhaseKind::Verify => "verify",
225    }
226}
227
228fn label_action(kind: DoctorActionKind) -> &'static str {
229    match kind {
230        DoctorActionKind::HealHeaderPointer => "heal header pointer",
231        DoctorActionKind::HealTocChecksum => "heal toc checksum",
232        DoctorActionKind::ReplayWal => "replay wal",
233        DoctorActionKind::DiscardWal => "discard wal",
234        DoctorActionKind::RebuildTimeIndex => "rebuild time index",
235        DoctorActionKind::RebuildLexIndex => "rebuild lex index",
236        DoctorActionKind::RebuildVecIndex => "rebuild vector index",
237        DoctorActionKind::VacuumCompaction => "vacuum compaction",
238        DoctorActionKind::RecomputeToc => "recompute toc",
239        DoctorActionKind::UpdateHeader => "update header",
240        DoctorActionKind::DeepVerify => "deep verify",
241        DoctorActionKind::NoOp => "no-op",
242    }
243}
244
245fn format_action_detail(detail: &DoctorActionDetail) -> String {
246    match detail {
247        DoctorActionDetail::HeaderPointer {
248            target_footer_offset,
249        } => {
250            format!("target offset: {}", target_footer_offset)
251        }
252        DoctorActionDetail::TocChecksum { expected } => {
253            let checksum: String = expected.iter().map(|b| format!("{:02x}", b)).collect();
254            format!("expected checksum: {}", checksum)
255        }
256        DoctorActionDetail::WalReplay {
257            from_sequence,
258            to_sequence,
259            pending_records,
260        } => format!(
261            "apply wal records {}→{} ({} pending)",
262            from_sequence, to_sequence, pending_records
263        ),
264        DoctorActionDetail::TimeIndex { expected_entries } => {
265            format!("expected entries: {}", expected_entries)
266        }
267        DoctorActionDetail::LexIndex { expected_docs } => {
268            format!("expected docs: {}", expected_docs)
269        }
270        DoctorActionDetail::VecIndex {
271            expected_vectors,
272            dimension,
273        } => format!(
274            "expected vectors: {}, dimension: {}",
275            expected_vectors, dimension
276        ),
277        DoctorActionDetail::VacuumStats { active_frames } => {
278            format!("active frames: {}", active_frames)
279        }
280    }
281}
282
283pub fn handle_verify_single_file(_config: &CliConfig, args: VerifySingleFileArgs) -> Result<()> {
284    let offenders = find_auxiliary_files(&args.file)?;
285    if offenders.is_empty() {
286        println!(
287            "\u{2713} Single file guarantee maintained ({})",
288            args.file.display()
289        );
290        Ok(())
291    } else {
292        println!("Found auxiliary files:");
293        for path in &offenders {
294            println!("- {}", path.display());
295        }
296        anyhow::bail!("auxiliary files detected")
297    }
298}
299
300pub fn handle_nudge(args: NudgeArgs) -> Result<()> {
301    match lockfile::current_owner(&args.file)? {
302        Some(owner) => {
303            if let Some(pid) = owner.pid {
304                #[cfg(unix)]
305                {
306                    let result = unsafe { libc::kill(pid as libc::pid_t, libc::SIGUSR1) };
307                    if result == 0 {
308                        println!("Sent SIGUSR1 to process {pid}");
309                    } else {
310                        return Err(std::io::Error::last_os_error().into());
311                    }
312                }
313                #[cfg(not(unix))]
314                {
315                    println!(
316                        "Active writer pid {pid}; nudging is not supported on this platform. Notify the process manually."
317                    );
318                }
319            } else {
320                bail!("Active writer does not expose a pid; cannot nudge");
321            }
322        }
323        None => {
324            println!("No active writer for {}", args.file.display());
325        }
326    }
327    Ok(())
328}
329
330fn find_auxiliary_files(memory: &Path) -> Result<Vec<PathBuf>> {
331    let parent = memory
332        .parent()
333        .map(Path::to_path_buf)
334        .unwrap_or_else(|| PathBuf::from("."));
335    let name = memory
336        .file_name()
337        .and_then(|n| n.to_str())
338        .ok_or_else(|| anyhow!("memory path must be a valid file name"))?;
339
340    let mut offenders = Vec::new();
341    let forbidden = ["-wal", "-shm", "-lock", "-journal"];
342    for suffix in &forbidden {
343        let candidate = parent.join(format!("{name}{suffix}"));
344        if candidate.exists() {
345            offenders.push(candidate);
346        }
347    }
348    let hidden_forbidden = [".wal", ".shm", ".lock", ".journal"];
349    for suffix in &hidden_forbidden {
350        let candidate = parent.join(format!(".{name}{suffix}"));
351        if candidate.exists() {
352            offenders.push(candidate);
353        }
354    }
355    Ok(offenders)
356}