Skip to main content

coding_agent_search/
doctor.rs

1//! Typed cass doctor command boundary.
2//!
3//! The safety-critical doctor executor is intentionally reached through this
4//! module so legacy flag spellings and future subcommands share one command
5//! model before any repair code can run.
6
7use 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}