1use std::path::PathBuf;
8
9use crate::{CliError, CliResult, RobotFormat};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum DoctorCommandSurface {
13 LegacyDoctor,
14 Check,
15 Repair,
16 Cleanup,
17 ArchiveScan,
18 ArchiveNormalize,
19 Backups,
20 Reconstruct,
21 Restore,
22 BaselineDiff,
23 SupportBundle,
24}
25
26const DOCTOR_COMMAND_SURFACES: &[DoctorCommandSurface] = &[
27 DoctorCommandSurface::LegacyDoctor,
28 DoctorCommandSurface::Check,
29 DoctorCommandSurface::Repair,
30 DoctorCommandSurface::Cleanup,
31 DoctorCommandSurface::ArchiveScan,
32 DoctorCommandSurface::ArchiveNormalize,
33 DoctorCommandSurface::Backups,
34 DoctorCommandSurface::Reconstruct,
35 DoctorCommandSurface::Restore,
36 DoctorCommandSurface::BaselineDiff,
37 DoctorCommandSurface::SupportBundle,
38];
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum DoctorExecutionMode {
42 ReadOnlyCheck,
43 RepairDryRun,
44 FingerprintApply,
45 CleanupDryRun,
46 CleanupApply,
47 ArchiveNormalizeDryRun,
48 ArchiveNormalizeApply,
49 RestoreRehearsal,
50 RestoreApply,
51 SafeAutoFix,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum DoctorBackupCommand {
56 List,
57 Verify,
58 Restore,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct DoctorCommandRequest {
63 pub surface: DoctorCommandSurface,
64 pub mode: DoctorExecutionMode,
65 pub data_dir: Option<PathBuf>,
66 pub db_path: Option<PathBuf>,
67 pub output_format: Option<RobotFormat>,
68 pub verbose: bool,
69 pub force_rebuild: bool,
70 pub allow_repeated_repair: bool,
71 pub repair: bool,
72 pub cleanup: bool,
73 pub archive_scan: bool,
74 pub archive_normalize: bool,
75 pub backup_command: Option<DoctorBackupCommand>,
76 pub backup_id: Option<String>,
77 pub dry_run: bool,
78 pub yes: bool,
79 pub plan_fingerprint: Option<String>,
80}
81
82impl DoctorCommandSurface {
83 pub fn stable_name(self) -> &'static str {
84 match self {
85 Self::LegacyDoctor => "legacy-doctor",
86 Self::Check => "check",
87 Self::Repair => "repair",
88 Self::Cleanup => "cleanup",
89 Self::ArchiveScan => "archive-scan",
90 Self::ArchiveNormalize => "archive-normalize",
91 Self::Backups => "backups",
92 Self::Reconstruct => "reconstruct",
93 Self::Restore => "restore",
94 Self::BaselineDiff => "baseline-diff",
95 Self::SupportBundle => "support-bundle",
96 }
97 }
98
99 pub fn mutates_by_default(self) -> bool {
100 matches!(
101 self,
102 Self::Repair
103 | Self::Cleanup
104 | Self::ArchiveNormalize
105 | Self::Reconstruct
106 | Self::Restore
107 )
108 }
109}
110
111impl DoctorExecutionMode {
112 pub fn stable_name(self) -> &'static str {
113 match self {
114 Self::ReadOnlyCheck => "read-only-check",
115 Self::RepairDryRun => "repair-dry-run",
116 Self::FingerprintApply => "fingerprint-apply",
117 Self::CleanupDryRun => "cleanup-dry-run",
118 Self::CleanupApply => "cleanup-apply",
119 Self::ArchiveNormalizeDryRun => "archive-normalize-dry-run",
120 Self::ArchiveNormalizeApply => "archive-normalize-apply",
121 Self::RestoreRehearsal => "restore-rehearsal",
122 Self::RestoreApply => "restore-apply",
123 Self::SafeAutoFix => "safe-auto-fix",
124 }
125 }
126
127 pub fn permits_mutation(self) -> bool {
128 matches!(
129 self,
130 Self::FingerprintApply
131 | Self::CleanupApply
132 | Self::ArchiveNormalizeApply
133 | Self::RestoreApply
134 | Self::SafeAutoFix
135 )
136 }
137
138 pub fn requires_plan_fingerprint(self) -> bool {
139 matches!(
140 self,
141 Self::FingerprintApply
142 | Self::CleanupApply
143 | Self::ArchiveNormalizeApply
144 | Self::RestoreApply
145 )
146 }
147}
148
149impl DoctorCommandRequest {
150 #[cfg(test)]
151 #[allow(clippy::too_many_arguments)]
152 pub(crate) fn from_cli_flags(
153 data_dir: Option<PathBuf>,
154 db_path: Option<PathBuf>,
155 output_format: Option<RobotFormat>,
156 check: bool,
157 fix: bool,
158 repair: bool,
159 cleanup: bool,
160 dry_run: bool,
161 yes: bool,
162 plan_fingerprint: Option<String>,
163 verbose: bool,
164 force_rebuild: bool,
165 allow_repeated_repair: bool,
166 ) -> CliResult<Self> {
167 Self::from_cli_flags_with_backups(
168 data_dir,
169 db_path,
170 output_format,
171 check,
172 fix,
173 repair,
174 cleanup,
175 false,
176 false,
177 false,
178 false,
179 false,
180 None,
181 dry_run,
182 yes,
183 plan_fingerprint,
184 verbose,
185 force_rebuild,
186 allow_repeated_repair,
187 )
188 }
189
190 #[allow(clippy::too_many_arguments)]
191 pub fn from_cli_flags_with_backups(
192 data_dir: Option<PathBuf>,
193 db_path: Option<PathBuf>,
194 output_format: Option<RobotFormat>,
195 check: bool,
196 fix: bool,
197 repair: bool,
198 cleanup: bool,
199 archive_scan: bool,
200 archive_normalize: bool,
201 backups_list: bool,
202 backups_verify: bool,
203 backups_restore: bool,
204 backup_id: Option<String>,
205 dry_run: bool,
206 yes: bool,
207 plan_fingerprint: Option<String>,
208 verbose: bool,
209 force_rebuild: bool,
210 allow_repeated_repair: bool,
211 ) -> CliResult<Self> {
212 let backup_command = if backups_list {
213 Some(DoctorBackupCommand::List)
214 } else if backups_verify {
215 Some(DoctorBackupCommand::Verify)
216 } else if backups_restore {
217 Some(DoctorBackupCommand::Restore)
218 } else {
219 None
220 };
221 let surface = if check {
222 DoctorCommandSurface::Check
223 } else if repair {
224 DoctorCommandSurface::Repair
225 } else if cleanup {
226 DoctorCommandSurface::Cleanup
227 } else if archive_scan {
228 DoctorCommandSurface::ArchiveScan
229 } else if archive_normalize {
230 DoctorCommandSurface::ArchiveNormalize
231 } else if backup_command.is_some() {
232 DoctorCommandSurface::Backups
233 } else {
234 DoctorCommandSurface::LegacyDoctor
235 };
236 if fix && surface != DoctorCommandSurface::LegacyDoctor {
237 let read_only_note = if surface == DoctorCommandSurface::Check
238 || surface == DoctorCommandSurface::ArchiveScan
239 {
240 " read-only"
241 } else {
242 ""
243 };
244 return Err(CliError {
245 code: 2,
246 kind: "usage",
247 message: format!(
248 "`cass doctor {}` is{} an explicit surface and does not accept legacy `--fix`",
249 surface.stable_name(),
250 read_only_note,
251 ),
252 hint: Some(
253 "Use the explicit dry-run/apply flow for typed doctor surfaces instead of legacy `--fix`."
254 .to_string(),
255 ),
256 retryable: false,
257 });
258 }
259 let mode = if repair && dry_run {
260 DoctorExecutionMode::RepairDryRun
261 } else if repair && yes && plan_fingerprint.is_some() {
262 DoctorExecutionMode::FingerprintApply
263 } else if cleanup && yes && plan_fingerprint.is_some() {
264 DoctorExecutionMode::CleanupApply
265 } else if cleanup {
266 DoctorExecutionMode::CleanupDryRun
267 } else if archive_normalize && yes && plan_fingerprint.is_some() {
268 DoctorExecutionMode::ArchiveNormalizeApply
269 } else if archive_normalize {
270 DoctorExecutionMode::ArchiveNormalizeDryRun
271 } else if backups_restore && yes && plan_fingerprint.is_some() {
272 DoctorExecutionMode::RestoreApply
273 } else if backups_restore {
274 DoctorExecutionMode::RestoreRehearsal
275 } else if fix {
276 DoctorExecutionMode::SafeAutoFix
277 } else {
278 DoctorExecutionMode::ReadOnlyCheck
279 };
280 let request = Self {
281 surface,
282 mode,
283 data_dir,
284 db_path,
285 output_format,
286 verbose,
287 force_rebuild,
288 allow_repeated_repair,
289 repair,
290 cleanup,
291 archive_scan,
292 archive_normalize,
293 backup_command,
294 backup_id,
295 dry_run,
296 yes,
297 plan_fingerprint,
298 };
299 request.validate()?;
300 Ok(request)
301 }
302
303 #[cfg(test)]
304 pub(crate) fn from_legacy_flags(
305 data_dir: Option<PathBuf>,
306 db_path: Option<PathBuf>,
307 output_format: Option<RobotFormat>,
308 fix: bool,
309 verbose: bool,
310 force_rebuild: bool,
311 allow_repeated_repair: bool,
312 ) -> CliResult<Self> {
313 Self::from_cli_flags(
314 data_dir,
315 db_path,
316 output_format,
317 false,
318 fix,
319 false,
320 false,
321 false,
322 false,
323 None,
324 verbose,
325 force_rebuild,
326 allow_repeated_repair,
327 )
328 }
329
330 pub fn validate(&self) -> CliResult<()> {
331 debug_assert!(DOCTOR_COMMAND_SURFACES.contains(&self.surface));
332 debug_assert!(!self.mode.stable_name().is_empty());
333 let explicit_surface_count = usize::from(self.surface == DoctorCommandSurface::Check)
334 + usize::from(self.repair)
335 + usize::from(self.cleanup)
336 + usize::from(self.archive_scan)
337 + usize::from(self.archive_normalize)
338 + usize::from(self.backup_command.is_some());
339 if explicit_surface_count > 1 {
340 return Err(CliError {
341 code: 2,
342 kind: "usage",
343 message: "cass doctor accepts only one explicit surface at a time".to_string(),
344 hint: Some(
345 "Use exactly one of `cass doctor check`, `cass doctor repair`, `cass doctor cleanup`, `cass doctor archive-scan`, `cass doctor archive-normalize`, or `cass doctor backups ...`."
346 .to_string(),
347 ),
348 retryable: false,
349 });
350 }
351 if self.archive_scan
352 && (self.dry_run
353 || self.yes
354 || self.plan_fingerprint.is_some()
355 || self.force_rebuild
356 || self.allow_repeated_repair)
357 {
358 return Err(CliError {
359 code: 2,
360 kind: "usage",
361 message: "`cass doctor archive-scan` is always read-only and does not accept repair, apply, or rebuild controls"
362 .to_string(),
363 hint: Some(
364 "Run `cass doctor archive-scan --json`; use `archive-normalize` only for additive metadata plans."
365 .to_string(),
366 ),
367 retryable: false,
368 });
369 }
370 let backup_restore = self.backup_command == Some(DoctorBackupCommand::Restore);
371 if self.dry_run
372 && !(self.repair || self.cleanup || self.archive_normalize || backup_restore)
373 {
374 return Err(CliError {
375 code: 2,
376 kind: "usage",
377 message: "`--dry-run` is only valid with `cass doctor repair`, `cass doctor cleanup`, `cass doctor archive-normalize`, or `cass doctor backups restore`"
378 .to_string(),
379 hint: Some(
380 "Use `cass doctor backups restore <backup-id> --json` for the default safe restore rehearsal."
381 .to_string(),
382 ),
383 retryable: false,
384 });
385 }
386 if self.yes && !(self.repair || self.cleanup || self.archive_normalize || backup_restore) {
387 return Err(CliError {
388 code: 2,
389 kind: "usage",
390 message: "`--yes` is only valid with `cass doctor repair`, `cass doctor cleanup`, `cass doctor archive-normalize`, or `cass doctor backups restore`"
391 .to_string(),
392 hint: Some(
393 "Use `--yes --plan-fingerprint <fingerprint>` only after inspecting the matching dry-run plan."
394 .to_string(),
395 ),
396 retryable: false,
397 });
398 }
399 if self.plan_fingerprint.is_some()
400 && !(self.repair || self.cleanup || self.archive_normalize || backup_restore)
401 {
402 return Err(CliError {
403 code: 2,
404 kind: "usage",
405 message: "`--plan-fingerprint` is only valid with `cass doctor repair`, `cass doctor cleanup`, `cass doctor archive-normalize`, or `cass doctor backups restore`"
406 .to_string(),
407 hint: Some(
408 "First run the matching dry-run command, then apply the exact fingerprint it reports."
409 .to_string(),
410 ),
411 retryable: false,
412 });
413 }
414 if self
415 .plan_fingerprint
416 .as_deref()
417 .is_some_and(|fingerprint| fingerprint.trim().is_empty())
418 {
419 return Err(CliError {
420 code: 2,
421 kind: "usage",
422 message: "`--plan-fingerprint` cannot be empty".to_string(),
423 hint: Some(
424 "Copy the exact non-empty plan_fingerprint from the matching dry-run or rehearsal output."
425 .to_string(),
426 ),
427 retryable: false,
428 });
429 }
430 if (self.repair || self.cleanup) && self.mode == DoctorExecutionMode::SafeAutoFix {
431 return Err(CliError {
432 code: 2,
433 kind: "usage",
434 message: format!(
435 "`cass doctor {}` does not accept legacy `--fix`",
436 self.surface.stable_name()
437 ),
438 hint: Some(
439 "Use the explicit dry-run/apply flow for repair or cleanup instead of legacy `--fix`."
440 .to_string(),
441 ),
442 retryable: false,
443 });
444 }
445 if (self.repair || self.cleanup) && self.dry_run && self.yes {
446 return Err(CliError {
447 code: 2,
448 kind: "usage",
449 message: format!(
450 "`cass doctor {}` cannot combine `--dry-run` and `--yes`",
451 self.surface.stable_name()
452 ),
453 hint: Some(
454 "Run the dry-run first, then run a separate apply command with the reported fingerprint."
455 .to_string(),
456 ),
457 retryable: false,
458 });
459 }
460 if backup_restore && self.dry_run && self.yes {
461 return Err(CliError {
462 code: 2,
463 kind: "usage",
464 message: "`cass doctor backups restore` cannot combine `--dry-run` and `--yes`"
465 .to_string(),
466 hint: Some(
467 "Run the restore rehearsal first, then run a separate apply command with the reported fingerprint."
468 .to_string(),
469 ),
470 retryable: false,
471 });
472 }
473 if self.repair && !self.dry_run && !self.yes {
474 return Err(CliError {
475 code: 2,
476 kind: "usage",
477 message: "`cass doctor repair` requires `--dry-run` or `--yes --plan-fingerprint <fingerprint>`"
478 .to_string(),
479 hint: Some(
480 "Start with `cass doctor repair --dry-run --json` so cass can print the exact apply command."
481 .to_string(),
482 ),
483 retryable: false,
484 });
485 }
486 if self.repair && self.yes && self.plan_fingerprint.is_none() {
487 return Err(CliError {
488 code: 2,
489 kind: "usage",
490 message: "`cass doctor repair --yes` requires `--plan-fingerprint <fingerprint>`"
491 .to_string(),
492 hint: Some(
493 "Copy the plan_fingerprint from `cass doctor repair --dry-run --json`."
494 .to_string(),
495 ),
496 retryable: false,
497 });
498 }
499 if self.repair && !self.yes && self.plan_fingerprint.is_some() {
500 return Err(CliError {
501 code: 2,
502 kind: "usage",
503 message: "`--plan-fingerprint` requires `--yes` for `cass doctor repair`"
504 .to_string(),
505 hint: Some(
506 "Use `cass doctor repair --yes --plan-fingerprint <fingerprint> --json` after inspecting the dry-run."
507 .to_string(),
508 ),
509 retryable: false,
510 });
511 }
512 if self.cleanup && self.yes && self.plan_fingerprint.is_none() {
513 return Err(CliError {
514 code: 2,
515 kind: "usage",
516 message: "`cass doctor cleanup --yes` requires `--plan-fingerprint <fingerprint>`"
517 .to_string(),
518 hint: Some(
519 "Copy the cleanup approval fingerprint from `cass doctor cleanup --json`."
520 .to_string(),
521 ),
522 retryable: false,
523 });
524 }
525 if self.cleanup && !self.yes && self.plan_fingerprint.is_some() {
526 return Err(CliError {
527 code: 2,
528 kind: "usage",
529 message: "`--plan-fingerprint` requires `--yes` for `cass doctor cleanup`"
530 .to_string(),
531 hint: Some(
532 "Use `cass doctor cleanup --yes --plan-fingerprint <fingerprint> --json` after inspecting the dry-run."
533 .to_string(),
534 ),
535 retryable: false,
536 });
537 }
538 if self.archive_normalize && self.dry_run && self.yes {
539 return Err(CliError {
540 code: 2,
541 kind: "usage",
542 message: "`cass doctor archive-normalize` cannot combine `--dry-run` and `--yes`"
543 .to_string(),
544 hint: Some(
545 "Run the dry-run first, then run a separate apply command with the reported fingerprint."
546 .to_string(),
547 ),
548 retryable: false,
549 });
550 }
551 if self.archive_normalize && self.yes && self.plan_fingerprint.is_none() {
552 return Err(CliError {
553 code: 2,
554 kind: "usage",
555 message:
556 "`cass doctor archive-normalize --yes` requires `--plan-fingerprint <fingerprint>`"
557 .to_string(),
558 hint: Some(
559 "Copy the plan_fingerprint from `cass doctor archive-normalize --dry-run --json`."
560 .to_string(),
561 ),
562 retryable: false,
563 });
564 }
565 if self.archive_normalize && !self.yes && self.plan_fingerprint.is_some() {
566 return Err(CliError {
567 code: 2,
568 kind: "usage",
569 message:
570 "`--plan-fingerprint` requires `--yes` for `cass doctor archive-normalize`"
571 .to_string(),
572 hint: Some(
573 "Use `cass doctor archive-normalize --yes --plan-fingerprint <fingerprint> --json` after inspecting the dry-run."
574 .to_string(),
575 ),
576 retryable: false,
577 });
578 }
579 if self.archive_normalize && (self.force_rebuild || self.allow_repeated_repair) {
580 return Err(CliError {
581 code: 2,
582 kind: "usage",
583 message: "`cass doctor archive-normalize` only accepts additive metadata plan controls"
584 .to_string(),
585 hint: Some(
586 "`--force-rebuild` and `--allow-repeated-repair` are repair controls, not archive-normalize controls."
587 .to_string(),
588 ),
589 retryable: false,
590 });
591 }
592 if let Some(backup_command) = self.backup_command {
593 match backup_command {
594 DoctorBackupCommand::List => {
595 if self.backup_id.is_some() {
596 return Err(CliError {
597 code: 2,
598 kind: "usage",
599 message: "`cass doctor backups list` does not accept a backup id"
600 .to_string(),
601 hint: Some(
602 "Run `cass doctor backups verify <backup-id> --json` for a specific backup."
603 .to_string(),
604 ),
605 retryable: false,
606 });
607 }
608 }
609 DoctorBackupCommand::Verify | DoctorBackupCommand::Restore => {
610 if self.backup_id.as_deref().is_none_or(str::is_empty) {
611 return Err(CliError {
612 code: 2,
613 kind: "usage",
614 message: format!(
615 "`cass doctor backups {}` requires a backup id",
616 backup_command.stable_name()
617 ),
618 hint: Some(
619 "Run `cass doctor backups list --json` and copy the backup_id field."
620 .to_string(),
621 ),
622 retryable: false,
623 });
624 }
625 }
626 }
627 if backup_command != DoctorBackupCommand::Restore
628 && (self.dry_run || self.yes || self.plan_fingerprint.is_some())
629 {
630 return Err(CliError {
631 code: 2,
632 kind: "usage",
633 message:
634 "`--dry-run`, `--yes`, and `--plan-fingerprint` are only valid with `cass doctor backups restore`"
635 .to_string(),
636 hint: Some(
637 "Use list and verify as read-only inspection commands before restore rehearsal."
638 .to_string(),
639 ),
640 retryable: false,
641 });
642 }
643 }
644 if backup_restore && self.yes && self.plan_fingerprint.is_none() {
645 return Err(CliError {
646 code: 2,
647 kind: "usage",
648 message: "`cass doctor backups restore --yes` requires `--plan-fingerprint <fingerprint>`"
649 .to_string(),
650 hint: Some(
651 "Copy the restore_plan.plan_fingerprint from the restore rehearsal output."
652 .to_string(),
653 ),
654 retryable: false,
655 });
656 }
657 if backup_restore && !self.yes && self.plan_fingerprint.is_some() {
658 return Err(CliError {
659 code: 2,
660 kind: "usage",
661 message: "`--plan-fingerprint` requires `--yes` for `cass doctor backups restore`"
662 .to_string(),
663 hint: Some(
664 "Run `cass doctor backups restore <backup-id> --yes --plan-fingerprint <fingerprint> --json` after inspecting the rehearsal."
665 .to_string(),
666 ),
667 retryable: false,
668 });
669 }
670 if self.backup_command.is_some() && self.force_rebuild {
671 return Err(CliError {
672 code: 2,
673 kind: "usage",
674 message: "`cass doctor backups` does not accept `--force-rebuild`".to_string(),
675 hint: Some(
676 "Backup inspection and restore are separate from derived index rebuild controls."
677 .to_string(),
678 ),
679 retryable: false,
680 });
681 }
682 let allow_repeated_repair_context = self.mode.permits_mutation()
683 || (self.surface == DoctorCommandSurface::Repair
684 && self.mode == DoctorExecutionMode::RepairDryRun);
685 if self.allow_repeated_repair && !allow_repeated_repair_context {
686 return Err(CliError {
687 code: 2,
688 kind: "usage",
689 message:
690 "`--allow-repeated-repair` is only valid with `cass doctor repair --dry-run` or a mutating doctor apply"
691 .to_string(),
692 hint: Some(
693 "Use it on the repair dry-run when a previous failure marker must be part of the approved fingerprint, then apply the exact reported command."
694 .to_string(),
695 ),
696 retryable: false,
697 });
698 }
699 if self.surface == DoctorCommandSurface::Check && self.mode.permits_mutation() {
700 return Err(CliError {
701 code: 2,
702 kind: "usage",
703 message: "`cass doctor check` is always read-only and cannot run with `--fix`"
704 .to_string(),
705 hint: Some(
706 "Run `cass doctor check --json` first, then use a separate explicit repair command after inspecting the check result."
707 .to_string(),
708 ),
709 retryable: false,
710 });
711 }
712 if self.surface == DoctorCommandSurface::Check && self.force_rebuild {
713 return Err(CliError {
714 code: 2,
715 kind: "usage",
716 message: "`cass doctor check` is read-only and does not accept `--force-rebuild`"
717 .to_string(),
718 hint: Some(
719 "Run `cass doctor check --json` first, then use `cass doctor --fix --force-rebuild --json` only after inspecting the check result."
720 .to_string(),
721 ),
722 retryable: false,
723 });
724 }
725 let read_only_repair_plan = self.surface == DoctorCommandSurface::Repair
726 && self.mode == DoctorExecutionMode::RepairDryRun;
727 let read_only_cleanup_plan = self.surface == DoctorCommandSurface::Cleanup
728 && self.mode == DoctorExecutionMode::CleanupDryRun;
729 let read_only_archive_normalize_plan = self.surface
730 == DoctorCommandSurface::ArchiveNormalize
731 && self.mode == DoctorExecutionMode::ArchiveNormalizeDryRun;
732 if self.surface.mutates_by_default()
733 && !self.mode.permits_mutation()
734 && !read_only_repair_plan
735 && !read_only_cleanup_plan
736 && !read_only_archive_normalize_plan
737 {
738 return Err(CliError {
739 code: 2,
740 kind: "usage",
741 message: format!(
742 "doctor surface `{}` requires an explicit mutating execution mode",
743 self.surface.stable_name()
744 ),
745 hint: Some(
746 "Use a read-only doctor check first, then apply the exact fingerprint-approved repair command."
747 .to_string(),
748 ),
749 retryable: false,
750 });
751 }
752 Ok(())
753 }
754}
755
756pub fn execute_doctor_command(request: DoctorCommandRequest) -> CliResult<()> {
757 execute_doctor_command_with_wrap(request, crate::WrapConfig::new(None, false))
758}
759
760pub(crate) fn execute_doctor_command_with_wrap(
761 request: DoctorCommandRequest,
762 wrap: crate::WrapConfig,
763) -> CliResult<()> {
764 request.validate()?;
765 if let Some(backup_command) = request.backup_command {
766 return crate::run_doctor_backups_impl(
767 &request.data_dir,
768 request.db_path,
769 request.output_format,
770 backup_command,
771 request.backup_id,
772 request.mode,
773 request.plan_fingerprint,
774 );
775 }
776 if request.surface == DoctorCommandSurface::ArchiveScan {
777 return crate::run_doctor_archive_scan_impl(
778 &request.data_dir,
779 request.db_path,
780 request.output_format,
781 request.verbose,
782 );
783 }
784 if request.surface == DoctorCommandSurface::ArchiveNormalize {
785 return crate::run_doctor_archive_normalize_impl(
786 &request.data_dir,
787 request.db_path,
788 request.output_format,
789 request.mode,
790 request.plan_fingerprint,
791 request.verbose,
792 );
793 }
794 crate::run_doctor_impl(
795 &request.data_dir,
796 request.db_path,
797 request.output_format,
798 request.mode.permits_mutation(),
799 request.verbose,
800 request.force_rebuild,
801 request.allow_repeated_repair,
802 request.surface,
803 request.mode,
804 request.plan_fingerprint,
805 wrap,
806 )
807}
808
809impl DoctorBackupCommand {
810 pub fn stable_name(self) -> &'static str {
811 match self {
812 Self::List => "list",
813 Self::Verify => "verify",
814 Self::Restore => "restore",
815 }
816 }
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 #[test]
824 fn legacy_read_only_flags_map_to_typed_check_mode() {
825 let request = DoctorCommandRequest::from_legacy_flags(
826 Some(PathBuf::from("/tmp/cass-data")),
827 None,
828 Some(RobotFormat::Json),
829 false,
830 true,
831 false,
832 false,
833 )
834 .expect("legacy read-only doctor flags should map");
835
836 assert_eq!(request.surface, DoctorCommandSurface::LegacyDoctor);
837 assert_eq!(request.mode, DoctorExecutionMode::ReadOnlyCheck);
838 assert_eq!(request.mode.stable_name(), "read-only-check");
839 assert!(!request.mode.permits_mutation());
840 assert!(request.verbose);
841 }
842
843 #[test]
844 fn legacy_fix_flags_map_to_safe_auto_fix_mode() {
845 let request = DoctorCommandRequest::from_legacy_flags(
846 None,
847 Some(PathBuf::from("/tmp/agent_search.db")),
848 Some(RobotFormat::Compact),
849 true,
850 false,
851 true,
852 true,
853 )
854 .expect("legacy fix doctor flags should map");
855
856 assert_eq!(request.mode, DoctorExecutionMode::SafeAutoFix);
857 assert_eq!(request.mode.stable_name(), "safe-auto-fix");
858 assert!(request.mode.permits_mutation());
859 assert!(request.force_rebuild);
860 assert!(request.allow_repeated_repair);
861 }
862
863 #[test]
864 fn check_subcommand_maps_to_explicit_read_only_surface() {
865 let request = DoctorCommandRequest::from_cli_flags(
866 Some(PathBuf::from("/tmp/cass-data")),
867 None,
868 Some(RobotFormat::Json),
869 true,
870 false,
871 false,
872 false,
873 false,
874 false,
875 None,
876 false,
877 false,
878 false,
879 )
880 .expect("doctor check flags should map");
881
882 assert_eq!(request.surface, DoctorCommandSurface::Check);
883 assert_eq!(request.surface.stable_name(), "check");
884 assert_eq!(request.mode, DoctorExecutionMode::ReadOnlyCheck);
885 assert!(!request.mode.permits_mutation());
886 }
887
888 #[test]
889 fn allow_repeated_repair_without_fix_fails_closed() {
890 let err = DoctorCommandRequest::from_legacy_flags(
891 None,
892 None,
893 Some(RobotFormat::Json),
894 false,
895 false,
896 false,
897 true,
898 )
899 .expect_err("allow repeated repair without fix must be rejected");
900
901 assert_eq!(err.code, 2);
902 assert_eq!(err.kind, "usage");
903 assert!(err.message.contains("--allow-repeated-repair"));
904 }
905
906 #[test]
907 fn check_subcommand_rejects_force_rebuild() {
908 let err = DoctorCommandRequest::from_cli_flags(
909 None,
910 None,
911 Some(RobotFormat::Json),
912 true,
913 false,
914 false,
915 false,
916 false,
917 false,
918 None,
919 false,
920 true,
921 false,
922 )
923 .expect_err("doctor check must reject force rebuild flags");
924
925 assert_eq!(err.code, 2);
926 assert_eq!(err.kind, "usage");
927 assert!(err.message.contains("doctor check"));
928 }
929
930 #[test]
931 fn check_subcommand_rejects_mutating_execution_mode_inside_typed_boundary() {
932 let err = DoctorCommandRequest::from_cli_flags(
933 None,
934 None,
935 Some(RobotFormat::Json),
936 true,
937 true,
938 false,
939 false,
940 false,
941 false,
942 None,
943 false,
944 false,
945 false,
946 )
947 .expect_err("doctor check must reject mutating execution mode");
948
949 assert_eq!(err.code, 2);
950 assert_eq!(err.kind, "usage");
951 assert!(err.message.contains("read-only"));
952 }
953
954 #[test]
955 fn mutating_surfaces_require_mutating_mode() {
956 let request = DoctorCommandRequest {
957 surface: DoctorCommandSurface::Reconstruct,
958 mode: DoctorExecutionMode::ReadOnlyCheck,
959 data_dir: None,
960 db_path: None,
961 output_format: Some(RobotFormat::Json),
962 verbose: false,
963 force_rebuild: false,
964 allow_repeated_repair: false,
965 repair: false,
966 cleanup: false,
967 archive_scan: false,
968 archive_normalize: false,
969 backup_command: None,
970 backup_id: None,
971 dry_run: false,
972 yes: false,
973 plan_fingerprint: None,
974 };
975 let err = request
976 .validate()
977 .expect_err("mutating doctor surfaces must fail closed without mutating mode");
978
979 assert_eq!(err.code, 2);
980 assert!(err.message.contains("reconstruct"));
981 }
982
983 #[test]
984 fn repair_dry_run_maps_to_non_mutating_plan_mode() {
985 let request = DoctorCommandRequest::from_cli_flags(
986 Some(PathBuf::from("/tmp/cass-data")),
987 None,
988 Some(RobotFormat::Json),
989 false,
990 false,
991 true,
992 false,
993 true,
994 false,
995 None,
996 false,
997 false,
998 false,
999 )
1000 .expect("doctor repair dry-run should map");
1001
1002 assert_eq!(request.surface, DoctorCommandSurface::Repair);
1003 assert_eq!(request.mode, DoctorExecutionMode::RepairDryRun);
1004 assert_eq!(request.mode.stable_name(), "repair-dry-run");
1005 assert!(!request.mode.permits_mutation());
1006 assert!(!request.mode.requires_plan_fingerprint());
1007 }
1008
1009 #[test]
1010 fn repair_dry_run_accepts_repeated_repair_override_for_matching_fingerprint() {
1011 let request = DoctorCommandRequest::from_cli_flags(
1012 Some(PathBuf::from("/tmp/cass-data")),
1013 None,
1014 Some(RobotFormat::Json),
1015 false,
1016 false,
1017 true,
1018 false,
1019 true,
1020 false,
1021 None,
1022 false,
1023 false,
1024 true,
1025 )
1026 .expect("repair dry-run should allow repeated-repair override in fingerprint inputs");
1027
1028 assert_eq!(request.surface, DoctorCommandSurface::Repair);
1029 assert_eq!(request.mode, DoctorExecutionMode::RepairDryRun);
1030 assert!(request.allow_repeated_repair);
1031 assert!(!request.mode.permits_mutation());
1032 }
1033
1034 #[test]
1035 fn repair_apply_requires_yes_and_plan_fingerprint() {
1036 let request = DoctorCommandRequest::from_cli_flags(
1037 None,
1038 None,
1039 Some(RobotFormat::Json),
1040 false,
1041 false,
1042 true,
1043 false,
1044 false,
1045 true,
1046 Some("doctor-repair-apply-plan-v1-abc".to_string()),
1047 false,
1048 false,
1049 false,
1050 )
1051 .expect("fingerprint-approved repair should map");
1052
1053 assert_eq!(request.surface, DoctorCommandSurface::Repair);
1054 assert_eq!(request.mode, DoctorExecutionMode::FingerprintApply);
1055 assert_eq!(request.mode.stable_name(), "fingerprint-apply");
1056 assert!(request.mode.permits_mutation());
1057 assert!(request.mode.requires_plan_fingerprint());
1058 }
1059
1060 #[test]
1061 fn cleanup_subcommand_maps_to_non_mutating_dry_run_by_default() {
1062 let request = DoctorCommandRequest::from_cli_flags(
1063 Some(PathBuf::from("/tmp/cass-data")),
1064 None,
1065 Some(RobotFormat::Json),
1066 false,
1067 false,
1068 false,
1069 true,
1070 false,
1071 false,
1072 None,
1073 false,
1074 false,
1075 false,
1076 )
1077 .expect("doctor cleanup should default to read-only cleanup dry-run");
1078
1079 assert_eq!(request.surface, DoctorCommandSurface::Cleanup);
1080 assert_eq!(request.mode, DoctorExecutionMode::CleanupDryRun);
1081 assert_eq!(request.mode.stable_name(), "cleanup-dry-run");
1082 assert!(!request.mode.permits_mutation());
1083 assert!(!request.mode.requires_plan_fingerprint());
1084 }
1085
1086 #[test]
1087 fn cleanup_apply_requires_yes_and_plan_fingerprint() {
1088 let request = DoctorCommandRequest::from_cli_flags(
1089 None,
1090 None,
1091 Some(RobotFormat::Json),
1092 false,
1093 false,
1094 false,
1095 true,
1096 false,
1097 true,
1098 Some("cleanup-v1-abc".to_string()),
1099 false,
1100 false,
1101 false,
1102 )
1103 .expect("fingerprint-approved cleanup should map");
1104
1105 assert_eq!(request.surface, DoctorCommandSurface::Cleanup);
1106 assert_eq!(request.mode, DoctorExecutionMode::CleanupApply);
1107 assert_eq!(request.mode.stable_name(), "cleanup-apply");
1108 assert!(request.mode.permits_mutation());
1109 assert!(request.mode.requires_plan_fingerprint());
1110 }
1111
1112 #[test]
1113 fn repair_rejects_missing_mode_or_mismatched_approval_flags() {
1114 let err = DoctorCommandRequest::from_cli_flags(
1115 None,
1116 None,
1117 Some(RobotFormat::Json),
1118 false,
1119 false,
1120 true,
1121 false,
1122 false,
1123 false,
1124 None,
1125 false,
1126 false,
1127 false,
1128 )
1129 .expect_err("repair must require dry-run or fingerprint apply");
1130 assert!(err.message.contains("requires"));
1131
1132 let err = DoctorCommandRequest::from_cli_flags(
1133 None,
1134 None,
1135 Some(RobotFormat::Json),
1136 false,
1137 false,
1138 true,
1139 false,
1140 true,
1141 true,
1142 Some("fp".to_string()),
1143 false,
1144 false,
1145 false,
1146 )
1147 .expect_err("dry-run and yes are mutually exclusive");
1148 assert!(err.message.contains("--dry-run"));
1149
1150 let err = DoctorCommandRequest::from_cli_flags(
1151 None,
1152 None,
1153 Some(RobotFormat::Json),
1154 false,
1155 false,
1156 true,
1157 false,
1158 false,
1159 true,
1160 None,
1161 false,
1162 false,
1163 false,
1164 )
1165 .expect_err("yes must require fingerprint");
1166 assert!(err.message.contains("--plan-fingerprint"));
1167 }
1168
1169 #[test]
1170 fn cleanup_rejects_missing_or_mismatched_approval_flags() {
1171 let err = DoctorCommandRequest::from_cli_flags(
1172 None,
1173 None,
1174 Some(RobotFormat::Json),
1175 false,
1176 false,
1177 false,
1178 true,
1179 true,
1180 true,
1181 Some("fp".to_string()),
1182 false,
1183 false,
1184 false,
1185 )
1186 .expect_err("cleanup dry-run and yes are mutually exclusive");
1187 assert!(err.message.contains("--dry-run"));
1188
1189 let err = DoctorCommandRequest::from_cli_flags(
1190 None,
1191 None,
1192 Some(RobotFormat::Json),
1193 false,
1194 false,
1195 false,
1196 true,
1197 false,
1198 true,
1199 None,
1200 false,
1201 false,
1202 false,
1203 )
1204 .expect_err("cleanup yes must require fingerprint");
1205 assert!(err.message.contains("--plan-fingerprint"));
1206
1207 let err = DoctorCommandRequest::from_cli_flags(
1208 None,
1209 None,
1210 Some(RobotFormat::Json),
1211 false,
1212 false,
1213 false,
1214 true,
1215 false,
1216 false,
1217 Some("fp".to_string()),
1218 false,
1219 false,
1220 false,
1221 )
1222 .expect_err("cleanup fingerprint must require yes");
1223 assert!(err.message.contains("--yes"));
1224 }
1225
1226 #[test]
1227 fn mutating_surfaces_reject_empty_plan_fingerprints() {
1228 let repair_err = DoctorCommandRequest::from_cli_flags(
1229 None,
1230 None,
1231 Some(RobotFormat::Json),
1232 false,
1233 false,
1234 true,
1235 false,
1236 false,
1237 true,
1238 Some(String::new()),
1239 false,
1240 false,
1241 false,
1242 )
1243 .expect_err("repair apply must reject an empty fingerprint at validation");
1244 assert_eq!(repair_err.code, 2);
1245 assert_eq!(repair_err.kind, "usage");
1246 assert!(repair_err.message.contains("cannot be empty"));
1247
1248 let cleanup_err = DoctorCommandRequest::from_cli_flags(
1249 None,
1250 None,
1251 Some(RobotFormat::Json),
1252 false,
1253 false,
1254 false,
1255 true,
1256 false,
1257 true,
1258 Some(" ".to_string()),
1259 false,
1260 false,
1261 false,
1262 )
1263 .expect_err("cleanup apply must reject a blank fingerprint at validation");
1264 assert_eq!(cleanup_err.code, 2);
1265 assert_eq!(cleanup_err.kind, "usage");
1266 assert!(cleanup_err.message.contains("cannot be empty"));
1267
1268 let archive_normalize_err = DoctorCommandRequest::from_cli_flags_with_backups(
1269 None,
1270 None,
1271 Some(RobotFormat::Json),
1272 false,
1273 false,
1274 false,
1275 false,
1276 false,
1277 true,
1278 false,
1279 false,
1280 false,
1281 None,
1282 false,
1283 true,
1284 Some(String::new()),
1285 false,
1286 false,
1287 false,
1288 )
1289 .expect_err("archive-normalize apply must reject an empty fingerprint");
1290 assert_eq!(archive_normalize_err.code, 2);
1291 assert_eq!(archive_normalize_err.kind, "usage");
1292 assert!(archive_normalize_err.message.contains("cannot be empty"));
1293
1294 let restore_err = DoctorCommandRequest::from_cli_flags_with_backups(
1295 None,
1296 None,
1297 Some(RobotFormat::Json),
1298 false,
1299 false,
1300 false,
1301 false,
1302 false,
1303 false,
1304 false,
1305 false,
1306 true,
1307 Some("backup-1".to_string()),
1308 false,
1309 true,
1310 Some(String::new()),
1311 false,
1312 false,
1313 false,
1314 )
1315 .expect_err("backup restore apply must reject an empty fingerprint");
1316 assert_eq!(restore_err.code, 2);
1317 assert_eq!(restore_err.kind, "usage");
1318 assert!(restore_err.message.contains("cannot be empty"));
1319 }
1320
1321 #[test]
1322 fn archive_scan_maps_to_read_only_surface_and_rejects_mutating_controls() {
1323 let request = DoctorCommandRequest::from_cli_flags_with_backups(
1324 Some(PathBuf::from("/tmp/cass-data")),
1325 None,
1326 Some(RobotFormat::Json),
1327 false,
1328 false,
1329 false,
1330 false,
1331 true,
1332 false,
1333 false,
1334 false,
1335 false,
1336 None,
1337 false,
1338 false,
1339 None,
1340 false,
1341 false,
1342 false,
1343 )
1344 .expect("archive-scan should map to read-only scan mode");
1345
1346 assert_eq!(request.surface, DoctorCommandSurface::ArchiveScan);
1347 assert_eq!(request.surface.stable_name(), "archive-scan");
1348 assert_eq!(request.mode, DoctorExecutionMode::ReadOnlyCheck);
1349 assert!(!request.mode.permits_mutation());
1350
1351 let err = DoctorCommandRequest::from_cli_flags_with_backups(
1352 None,
1353 None,
1354 Some(RobotFormat::Json),
1355 false,
1356 false,
1357 false,
1358 false,
1359 true,
1360 false,
1361 false,
1362 false,
1363 false,
1364 None,
1365 true,
1366 false,
1367 None,
1368 false,
1369 false,
1370 false,
1371 )
1372 .expect_err("archive-scan must reject repair dry-run controls");
1373 assert!(err.message.contains("always read-only"));
1374 }
1375
1376 #[test]
1377 fn archive_normalize_is_dry_run_by_default_and_apply_requires_fingerprint() {
1378 let request = DoctorCommandRequest::from_cli_flags_with_backups(
1379 Some(PathBuf::from("/tmp/cass-data")),
1380 None,
1381 Some(RobotFormat::Json),
1382 false,
1383 false,
1384 false,
1385 false,
1386 false,
1387 true,
1388 false,
1389 false,
1390 false,
1391 None,
1392 false,
1393 false,
1394 None,
1395 false,
1396 false,
1397 false,
1398 )
1399 .expect("archive-normalize should default to dry-run plan mode");
1400
1401 assert_eq!(request.surface, DoctorCommandSurface::ArchiveNormalize);
1402 assert_eq!(request.surface.stable_name(), "archive-normalize");
1403 assert_eq!(request.mode, DoctorExecutionMode::ArchiveNormalizeDryRun);
1404 assert_eq!(request.mode.stable_name(), "archive-normalize-dry-run");
1405 assert!(!request.mode.permits_mutation());
1406 assert!(!request.mode.requires_plan_fingerprint());
1407
1408 let request = DoctorCommandRequest::from_cli_flags_with_backups(
1409 None,
1410 None,
1411 Some(RobotFormat::Json),
1412 false,
1413 false,
1414 false,
1415 false,
1416 false,
1417 true,
1418 false,
1419 false,
1420 false,
1421 None,
1422 false,
1423 true,
1424 Some("archive-normalize-v1-abc".to_string()),
1425 false,
1426 false,
1427 false,
1428 )
1429 .expect("archive-normalize apply should require yes and fingerprint");
1430
1431 assert_eq!(request.mode, DoctorExecutionMode::ArchiveNormalizeApply);
1432 assert_eq!(request.mode.stable_name(), "archive-normalize-apply");
1433 assert!(request.mode.permits_mutation());
1434 assert!(request.mode.requires_plan_fingerprint());
1435
1436 let err = DoctorCommandRequest::from_cli_flags_with_backups(
1437 None,
1438 None,
1439 Some(RobotFormat::Json),
1440 false,
1441 false,
1442 false,
1443 false,
1444 false,
1445 true,
1446 false,
1447 false,
1448 false,
1449 None,
1450 false,
1451 true,
1452 None,
1453 false,
1454 false,
1455 false,
1456 )
1457 .expect_err("archive-normalize apply must require fingerprint");
1458 assert!(err.message.contains("--plan-fingerprint"));
1459 }
1460
1461 #[test]
1462 fn doctor_execution_mode_names_are_stable_for_robot_contracts() {
1463 let names = [
1464 DoctorExecutionMode::ReadOnlyCheck.stable_name(),
1465 DoctorExecutionMode::RepairDryRun.stable_name(),
1466 DoctorExecutionMode::FingerprintApply.stable_name(),
1467 DoctorExecutionMode::CleanupDryRun.stable_name(),
1468 DoctorExecutionMode::CleanupApply.stable_name(),
1469 DoctorExecutionMode::ArchiveNormalizeDryRun.stable_name(),
1470 DoctorExecutionMode::ArchiveNormalizeApply.stable_name(),
1471 DoctorExecutionMode::RestoreRehearsal.stable_name(),
1472 DoctorExecutionMode::RestoreApply.stable_name(),
1473 DoctorExecutionMode::SafeAutoFix.stable_name(),
1474 ];
1475
1476 assert_eq!(
1477 names,
1478 [
1479 "read-only-check",
1480 "repair-dry-run",
1481 "fingerprint-apply",
1482 "cleanup-dry-run",
1483 "cleanup-apply",
1484 "archive-normalize-dry-run",
1485 "archive-normalize-apply",
1486 "restore-rehearsal",
1487 "restore-apply",
1488 "safe-auto-fix",
1489 ]
1490 );
1491 }
1492
1493 #[test]
1494 fn doctor_surface_names_are_stable_for_robot_contracts() {
1495 let names = [
1496 DoctorCommandSurface::LegacyDoctor.stable_name(),
1497 DoctorCommandSurface::Check.stable_name(),
1498 DoctorCommandSurface::Repair.stable_name(),
1499 DoctorCommandSurface::Cleanup.stable_name(),
1500 DoctorCommandSurface::ArchiveScan.stable_name(),
1501 DoctorCommandSurface::ArchiveNormalize.stable_name(),
1502 DoctorCommandSurface::Backups.stable_name(),
1503 DoctorCommandSurface::Reconstruct.stable_name(),
1504 DoctorCommandSurface::Restore.stable_name(),
1505 DoctorCommandSurface::BaselineDiff.stable_name(),
1506 DoctorCommandSurface::SupportBundle.stable_name(),
1507 ];
1508
1509 assert_eq!(
1510 names,
1511 [
1512 "legacy-doctor",
1513 "check",
1514 "repair",
1515 "cleanup",
1516 "archive-scan",
1517 "archive-normalize",
1518 "backups",
1519 "reconstruct",
1520 "restore",
1521 "baseline-diff",
1522 "support-bundle",
1523 ]
1524 );
1525 }
1526
1527 #[test]
1528 fn legacy_cli_dispatch_routes_through_typed_doctor_module() {
1529 let lib_source = include_str!("lib.rs");
1530 assert!(
1531 lib_source.contains("doctor::DoctorCommandRequest::from_cli_flags"),
1532 "Commands::Doctor should build the typed doctor request before execution"
1533 );
1534 assert!(
1535 lib_source.contains("doctor::execute_doctor_command_with_wrap(request, wrap)?"),
1536 "Commands::Doctor should execute through the doctor module boundary"
1537 );
1538 assert!(
1539 !lib_source.contains("fn run_doctor("),
1540 "legacy run_doctor entrypoint should not remain as a bypassable implementation name"
1541 );
1542 assert_eq!(
1543 lib_source.matches("pub(crate) fn run_doctor_impl(").count(),
1544 1,
1545 "there should be exactly one internal doctor implementation body"
1546 );
1547
1548 let doctor_source = include_str!("doctor.rs");
1549 let executor_call = ["crate::", "run_doctor_impl("].concat();
1550 assert_eq!(
1551 doctor_source.matches(&executor_call).count(),
1552 1,
1553 "the doctor module should be the single call site for the internal executor"
1554 );
1555 }
1556}