memvid_cli/commands/
maintenance.rs

1//! Maintenance command handlers (verify, doctor, verify-single-file, nudge, process-queue).
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};
13use serde::Serialize;
14
15use crate::config::CliConfig;
16
17/// Arguments for the `nudge` subcommand
18#[derive(Args)]
19pub struct NudgeArgs {
20    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
21    pub file: PathBuf,
22}
23
24/// Arguments for the `verify` subcommand
25#[derive(Args)]
26pub struct VerifyArgs {
27    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
28    pub file: PathBuf,
29    #[arg(long)]
30    pub deep: bool,
31    #[arg(long)]
32    pub json: bool,
33}
34
35/// Arguments for the `doctor` subcommand
36#[derive(Args)]
37pub struct DoctorArgs {
38    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
39    pub file: PathBuf,
40    #[arg(long = "rebuild-time-index", action = ArgAction::SetTrue)]
41    pub rebuild_time_index: bool,
42    #[arg(long = "rebuild-lex-index", action = ArgAction::SetTrue)]
43    pub rebuild_lex_index: bool,
44    #[arg(long = "rebuild-vec-index", action = ArgAction::SetTrue)]
45    pub rebuild_vec_index: bool,
46    #[arg(long = "vacuum", action = ArgAction::SetTrue)]
47    pub vacuum: bool,
48    #[arg(long = "plan-only", action = ArgAction::SetTrue)]
49    pub plan_only: bool,
50    #[arg(long = "json", action = ArgAction::SetTrue)]
51    pub json: bool,
52}
53
54/// Arguments for the `verify-single-file` subcommand
55#[derive(Args)]
56pub struct VerifySingleFileArgs {
57    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
58    pub file: PathBuf,
59}
60
61// ============================================================================
62// Maintenance command handlers
63// ============================================================================
64
65pub fn handle_verify(_config: &CliConfig, args: VerifyArgs) -> Result<()> {
66    let report = Memvid::verify(&args.file, args.deep)?;
67    if args.json {
68        println!("{}", serde_json::to_string_pretty(&report)?);
69    } else {
70        println!("Verification report for {}", args.file.display());
71        for check in &report.checks {
72            match &check.details {
73                Some(details) => println!("- {}: {:?} ({details})", check.name, check.status),
74                None => println!("- {}: {:?}", check.name, check.status),
75            }
76        }
77        println!("Overall: {:?}", report.overall_status);
78    }
79
80    if report.overall_status == VerificationStatus::Failed {
81        anyhow::bail!("verification failed");
82    }
83    Ok(())
84}
85
86pub fn handle_doctor(_config: &CliConfig, args: DoctorArgs) -> Result<()> {
87    let options = DoctorOptions {
88        rebuild_time_index: args.rebuild_time_index,
89        rebuild_lex_index: args.rebuild_lex_index,
90        rebuild_vec_index: args.rebuild_vec_index,
91        vacuum: args.vacuum,
92        dry_run: args.plan_only,
93    };
94
95    let report = Memvid::doctor(&args.file, options)?;
96
97    if args.json {
98        println!("{}", serde_json::to_string_pretty(&report)?);
99    } else {
100        print_doctor_report(&args.file, &report);
101    }
102
103    match report.status {
104        DoctorStatus::Failed => anyhow::bail!("doctor failed; see findings for details"),
105        DoctorStatus::Partial => {
106            anyhow::bail!("doctor completed with partial repairs; rerun or restore from backup")
107        }
108        DoctorStatus::PlanOnly => {
109            if report.plan.is_noop() && !args.json {
110                println!(
111                    "No repairs required for {} (plan-only run)",
112                    args.file.display()
113                );
114            } else if !args.json {
115                println!("Plan generated. Re-run without --plan-only to apply repairs.");
116            }
117            Ok(())
118        }
119        _ => Ok(()),
120    }
121}
122
123fn print_doctor_report(path: &Path, report: &DoctorReport) {
124    println!("Doctor status for {}: {:?}", path.display(), report.status);
125
126    if !report.plan.findings.is_empty() {
127        println!("Findings:");
128        for finding in &report.plan.findings {
129            let severity = format_severity(finding.severity);
130            let code = format_finding_code(finding.code);
131            match &finding.detail {
132                Some(detail) => println!(
133                    "  - [{}] {}: {} ({detail})",
134                    severity, code, finding.message
135                ),
136                None => println!("  - [{}] {}: {}", severity, code, finding.message),
137            }
138        }
139    }
140
141    if report.plan.phases.is_empty() {
142        println!("Planned phases: (none)");
143    } else {
144        println!("Planned phases:");
145        for phase in &report.plan.phases {
146            println!("  - {}", label_phase(phase.phase));
147            for action in &phase.actions {
148                let mut notes: Vec<String> = Vec::new();
149                if action.required {
150                    notes.push("required".into());
151                }
152                if !action.reasons.is_empty() {
153                    let reasons: Vec<String> = action
154                        .reasons
155                        .iter()
156                        .map(|code| format_finding_code(*code))
157                        .collect();
158                    notes.push(format!("reasons: {}", reasons.join(", ")));
159                }
160                if let Some(detail) = &action.detail {
161                    notes.push(format_action_detail(detail));
162                }
163                if let Some(note) = &action.note {
164                    notes.push(note.clone());
165                }
166                let suffix = if notes.is_empty() {
167                    String::new()
168                } else {
169                    format!(" ({})", notes.join(" | "))
170                };
171                println!("    * {}{}", label_action(action.action), suffix);
172            }
173        }
174    }
175
176    if report.phases.is_empty() {
177        println!("Execution: (skipped)");
178    } else {
179        println!("Execution:");
180        for phase in &report.phases {
181            println!("  - {}: {:?}", label_phase(phase.phase), phase.status);
182            if let Some(duration) = phase.duration_ms {
183                println!("      duration: {} ms", duration);
184            }
185            for action in &phase.actions {
186                match &action.detail {
187                    Some(detail) => println!(
188                        "    * {}: {:?} ({detail})",
189                        label_action(action.action),
190                        action.status
191                    ),
192                    None => println!("    * {}: {:?}", label_action(action.action), action.status),
193                }
194            }
195        }
196    }
197
198    if report.metrics.total_duration_ms > 0 {
199        println!("Total duration: {} ms", report.metrics.total_duration_ms);
200    }
201}
202
203fn format_severity(severity: DoctorSeverity) -> &'static str {
204    match severity {
205        DoctorSeverity::Info => "info",
206        DoctorSeverity::Warning => "warning",
207        DoctorSeverity::Error => "error",
208    }
209}
210
211fn format_finding_code(code: DoctorFindingCode) -> String {
212    serde_json::to_string(&code)
213        .map(|value| value.trim_matches('"').replace('_', " "))
214        .unwrap_or_else(|_| format!("{code:?}"))
215}
216
217fn label_phase(kind: DoctorPhaseKind) -> &'static str {
218    match kind {
219        DoctorPhaseKind::Probe => "probe",
220        DoctorPhaseKind::HeaderHealing => "header healing",
221        DoctorPhaseKind::WalReplay => "wal replay",
222        DoctorPhaseKind::IndexRebuild => "index rebuild",
223        DoctorPhaseKind::Vacuum => "vacuum",
224        DoctorPhaseKind::Finalize => "finalize",
225        DoctorPhaseKind::Verify => "verify",
226    }
227}
228
229fn label_action(kind: DoctorActionKind) -> &'static str {
230    match kind {
231        DoctorActionKind::HealHeaderPointer => "heal header pointer",
232        DoctorActionKind::HealTocChecksum => "heal toc checksum",
233        DoctorActionKind::ReplayWal => "replay wal",
234        DoctorActionKind::DiscardWal => "discard wal",
235        DoctorActionKind::RebuildTimeIndex => "rebuild time index",
236        DoctorActionKind::RebuildLexIndex => "rebuild lex index",
237        DoctorActionKind::RebuildVecIndex => "rebuild vector index",
238        DoctorActionKind::VacuumCompaction => "vacuum compaction",
239        DoctorActionKind::RecomputeToc => "recompute toc",
240        DoctorActionKind::UpdateHeader => "update header",
241        DoctorActionKind::DeepVerify => "deep verify",
242        DoctorActionKind::NoOp => "no-op",
243    }
244}
245
246fn format_action_detail(detail: &DoctorActionDetail) -> String {
247    match detail {
248        DoctorActionDetail::HeaderPointer {
249            target_footer_offset,
250        } => {
251            format!("target offset: {}", target_footer_offset)
252        }
253        DoctorActionDetail::TocChecksum { expected } => {
254            let checksum: String = expected.iter().map(|b| format!("{:02x}", b)).collect();
255            format!("expected checksum: {}", checksum)
256        }
257        DoctorActionDetail::WalReplay {
258            from_sequence,
259            to_sequence,
260            pending_records,
261        } => format!(
262            "apply wal records {}→{} ({} pending)",
263            from_sequence, to_sequence, pending_records
264        ),
265        DoctorActionDetail::TimeIndex { expected_entries } => {
266            format!("expected entries: {}", expected_entries)
267        }
268        DoctorActionDetail::LexIndex { expected_docs } => {
269            format!("expected docs: {}", expected_docs)
270        }
271        DoctorActionDetail::VecIndex {
272            expected_vectors,
273            dimension,
274        } => format!(
275            "expected vectors: {}, dimension: {}",
276            expected_vectors, dimension
277        ),
278        DoctorActionDetail::VacuumStats { active_frames } => {
279            format!("active frames: {}", active_frames)
280        }
281    }
282}
283
284pub fn handle_verify_single_file(_config: &CliConfig, args: VerifySingleFileArgs) -> Result<()> {
285    let offenders = find_auxiliary_files(&args.file)?;
286    if offenders.is_empty() {
287        println!(
288            "\u{2713} Single file guarantee maintained ({})",
289            args.file.display()
290        );
291        Ok(())
292    } else {
293        println!("Found auxiliary files:");
294        for path in &offenders {
295            println!("- {}", path.display());
296        }
297        anyhow::bail!("auxiliary files detected")
298    }
299}
300
301pub fn handle_nudge(args: NudgeArgs) -> Result<()> {
302    match lockfile::current_owner(&args.file)? {
303        Some(owner) => {
304            if let Some(pid) = owner.pid {
305                #[cfg(unix)]
306                {
307                    let result = unsafe { libc::kill(pid as libc::pid_t, libc::SIGUSR1) };
308                    if result == 0 {
309                        println!("Sent SIGUSR1 to process {pid}");
310                    } else {
311                        return Err(std::io::Error::last_os_error().into());
312                    }
313                }
314                #[cfg(not(unix))]
315                {
316                    println!(
317                        "Active writer pid {pid}; nudging is not supported on this platform. Notify the process manually."
318                    );
319                }
320            } else {
321                bail!("Active writer does not expose a pid; cannot nudge");
322            }
323        }
324        None => {
325            println!("No active writer for {}", args.file.display());
326        }
327    }
328    Ok(())
329}
330
331fn find_auxiliary_files(memory: &Path) -> Result<Vec<PathBuf>> {
332    let parent = memory
333        .parent()
334        .map(Path::to_path_buf)
335        .unwrap_or_else(|| PathBuf::from("."));
336    let name = memory
337        .file_name()
338        .and_then(|n| n.to_str())
339        .ok_or_else(|| anyhow!("memory path must be a valid file name"))?;
340
341    let mut offenders = Vec::new();
342    let forbidden = ["-wal", "-shm", "-lock", "-journal"];
343    for suffix in &forbidden {
344        let candidate = parent.join(format!("{name}{suffix}"));
345        if candidate.exists() {
346            offenders.push(candidate);
347        }
348    }
349    let hidden_forbidden = [".wal", ".shm", ".lock", ".journal"];
350    for suffix in &hidden_forbidden {
351        let candidate = parent.join(format!(".{name}{suffix}"));
352        if candidate.exists() {
353            offenders.push(candidate);
354        }
355    }
356    Ok(offenders)
357}
358
359// ============================================================================
360// Process Queue Command - Progressive Ingestion Enrichment
361// ============================================================================
362
363/// Arguments for the `process-queue` subcommand
364#[derive(Args)]
365pub struct ProcessQueueArgs {
366    /// Path to the `.mv2` file
367    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
368    pub file: PathBuf,
369
370    /// Only show queue status without processing
371    #[arg(long)]
372    pub status: bool,
373
374    /// Output as JSON
375    #[arg(long)]
376    pub json: bool,
377}
378
379/// Result of processing the enrichment queue
380#[derive(Debug, Serialize)]
381pub struct ProcessQueueResult {
382    /// Number of frames in queue before processing
383    pub queue_before: usize,
384    /// Number of frames processed
385    pub frames_processed: usize,
386    /// Number of frames remaining in queue
387    pub queue_after: usize,
388    /// Total active frames
389    pub total_frames: usize,
390    /// Frames fully enriched
391    pub enriched_frames: usize,
392    /// Frames that are searchable but not enriched
393    pub searchable_only: usize,
394}
395
396/// Handle the `process-queue` command for progressive ingestion enrichment.
397///
398/// This command processes frames in the enrichment queue:
399/// - Re-extracts full text for skim (time-limited) extractions
400/// - Updates the Tantivy index with enriched content
401/// - Marks frames as Enriched when complete
402pub fn handle_process_queue(_config: &CliConfig, args: ProcessQueueArgs) -> Result<()> {
403    let mut mem = Memvid::open(&args.file)?;
404
405    // Get initial stats
406    let initial_stats = mem.enrichment_stats();
407    let queue_before = mem.enrichment_queue_len();
408
409    if args.status {
410        // Just show status
411        if args.json {
412            let result = ProcessQueueResult {
413                queue_before,
414                frames_processed: 0,
415                queue_after: queue_before,
416                total_frames: initial_stats.total_frames,
417                enriched_frames: initial_stats.enriched_frames,
418                searchable_only: initial_stats.searchable_only,
419            };
420            println!("{}", serde_json::to_string_pretty(&result)?);
421        } else {
422            println!("Enrichment Queue Status:");
423            println!("  Pending: {} frames", queue_before);
424            println!("  Total frames: {}", initial_stats.total_frames);
425            println!("  Enriched: {}", initial_stats.enriched_frames);
426            println!("  Searchable only: {}", initial_stats.searchable_only);
427
428            if queue_before == 0 {
429                println!("\nāœ“ No frames pending enrichment");
430            } else {
431                println!(
432                    "\nRun without --status to process {} pending frames",
433                    queue_before
434                );
435            }
436        }
437        return Ok(());
438    }
439
440    if queue_before == 0 {
441        if args.json {
442            let result = ProcessQueueResult {
443                queue_before: 0,
444                frames_processed: 0,
445                queue_after: 0,
446                total_frames: initial_stats.total_frames,
447                enriched_frames: initial_stats.enriched_frames,
448                searchable_only: initial_stats.searchable_only,
449            };
450            println!("{}", serde_json::to_string_pretty(&result)?);
451        } else {
452            println!("āœ“ No frames pending enrichment");
453        }
454        return Ok(());
455    }
456
457    // Process all pending enrichment tasks
458    if !args.json {
459        eprintln!("Processing {} pending frames...", queue_before);
460    }
461
462    let start = std::time::Instant::now();
463    let frames_processed = mem.process_all_enrichment();
464    let elapsed = start.elapsed();
465
466    // Commit changes
467    mem.commit()?;
468
469    // Get final stats
470    let final_stats = mem.enrichment_stats();
471    let queue_after = mem.enrichment_queue_len();
472
473    if args.json {
474        let result = ProcessQueueResult {
475            queue_before,
476            frames_processed,
477            queue_after,
478            total_frames: final_stats.total_frames,
479            enriched_frames: final_stats.enriched_frames,
480            searchable_only: final_stats.searchable_only,
481        };
482        println!("{}", serde_json::to_string_pretty(&result)?);
483    } else {
484        println!("Enrichment complete:");
485        println!("  Frames processed: {}", frames_processed);
486        println!("  Time: {:.2}s", elapsed.as_secs_f64());
487        println!(
488            "  Throughput: {:.1} frames/sec",
489            frames_processed as f64 / elapsed.as_secs_f64().max(0.001)
490        );
491        println!();
492        println!("Status:");
493        println!("  Total frames: {}", final_stats.total_frames);
494        println!("  Enriched: {}", final_stats.enriched_frames);
495        println!("  Searchable only: {}", final_stats.searchable_only);
496        println!("  Queue remaining: {}", queue_after);
497    }
498
499    Ok(())
500}