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 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#[derive(Args)]
366pub struct ProcessQueueArgs {
367 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
369 pub file: PathBuf,
370
371 #[arg(long)]
373 pub status: bool,
374
375 #[arg(long)]
377 pub json: bool,
378}
379
380#[derive(Debug, Serialize)]
382pub struct ProcessQueueResult {
383 pub queue_before: usize,
385 pub frames_processed: usize,
387 pub queue_after: usize,
389 pub total_frames: usize,
391 pub enriched_frames: usize,
393 pub searchable_only: usize,
395}
396
397pub fn handle_process_queue(_config: &CliConfig, args: ProcessQueueArgs) -> Result<()> {
404 let mut mem = Memvid::open(&args.file)?;
405
406 let initial_stats = mem.enrichment_stats();
408 let queue_before = mem.enrichment_queue_len();
409
410 if args.status {
411 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 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 mem.commit()?;
469
470 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}