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};
13
14use crate::config::CliConfig;
15
16#[derive(Args)]
18pub struct NudgeArgs {
19 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
20 pub file: PathBuf,
21}
22
23#[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#[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#[derive(Args)]
55pub struct VerifySingleFileArgs {
56 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
57 pub file: PathBuf,
58}
59
60pub 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}