1use 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#[derive(Args)]
19pub struct NudgeArgs {
20 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
21 pub file: PathBuf,
22}
23
24#[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#[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#[derive(Args)]
56pub struct VerifySingleFileArgs {
57 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
58 pub file: PathBuf,
59}
60
61pub 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#[derive(Args)]
365pub struct ProcessQueueArgs {
366 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
368 pub file: PathBuf,
369
370 #[arg(long)]
372 pub status: bool,
373
374 #[arg(long)]
376 pub json: bool,
377}
378
379#[derive(Debug, Serialize)]
381pub struct ProcessQueueResult {
382 pub queue_before: usize,
384 pub frames_processed: usize,
386 pub queue_after: usize,
388 pub total_frames: usize,
390 pub enriched_frames: usize,
392 pub searchable_only: usize,
394}
395
396pub fn handle_process_queue(_config: &CliConfig, args: ProcessQueueArgs) -> Result<()> {
403 let mut mem = Memvid::open(&args.file)?;
404
405 let initial_stats = mem.enrichment_stats();
407 let queue_before = mem.enrichment_queue_len();
408
409 if args.status {
410 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 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 mem.commit()?;
468
469 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}