Skip to main content

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