Skip to main content

muster/adapter/cli/
doctor.rs

1use std::{
2    error::Error,
3    fs,
4    path::{Path, PathBuf},
5};
6
7use getset::{CopyGetters, Getters};
8use typed_builder::TypedBuilder;
9
10use super::{
11    check::{CheckOutcome, VALID_SUFFIX, check, error_chain},
12    completions::{CompletionShell, registration_line},
13    report::{Report, Row, RowKind, summary_line},
14};
15use crate::{
16    adapter::{
17        clipboard,
18        hooks::{HookState, HookStatus},
19        path::absolutize,
20    },
21    domain::port::{AgentSessionStore, ProjectRegistry},
22};
23
24/// Title of the doctor report box.
25const DOCTOR_TITLE: &str = "muster doctor";
26/// Summary word for passing probes.
27const SUMMARY_OK: &str = "ok";
28/// Summary words for advisory probes.
29const SUMMARY_HINT: &str = "hint";
30const SUMMARY_HINTS: &str = "hints";
31/// Summary words for failing probes.
32const SUMMARY_FAILURE: &str = "failure";
33const SUMMARY_FAILURES: &str = "failures";
34/// Probe labels.
35const CONFIG_LABEL: &str = "config";
36const REGISTRY_LABEL: &str = "projects";
37const SESSIONS_LABEL: &str = "sessions";
38const HOOKS_LABEL: &str = "agent hooks";
39const CLIPBOARD_LABEL: &str = "clipboard";
40const COMPLETIONS_LABEL: &str = "completions";
41/// Prefix of a commented-out rc line, ignored by every probed shell.
42const COMMENT_PREFIX: &str = "#";
43/// Hint shown when providers need (re)installation.
44const HOOKS_HINT: &str = "run `muster hooks setup`";
45/// Bash shell rc file for sourcing completions.
46const BASH_RC: &str = ".bashrc";
47/// Zsh shell rc file for sourcing completions.
48const ZSH_RC: &str = ".zshrc";
49/// Fish shell rc file for sourcing completions.
50const FISH_RC: &str = "fish/config.fish";
51/// Fish shell completions file for direct drop-in.
52const FISH_COMPLETIONS: &str = "fish/completions/muster.fish";
53/// Elvish shell rc file for sourcing completions.
54const ELVISH_RC: &str = ".elvish/rc.elv";
55/// Elvish rc under the XDG config root, the modern location.
56const ELVISH_XDG_RC: &str = "elvish/rc.elv";
57/// PowerShell profile for sourcing completions on Unix.
58#[cfg(not(windows))]
59const POWERSHELL_RC: &str = "powershell/Microsoft.PowerShell_profile.ps1";
60/// PowerShell 7 profile relative to the user's home directory on Windows.
61#[cfg(windows)]
62const POWERSHELL_RC: &str = "Documents/PowerShell/Microsoft.PowerShell_profile.ps1";
63/// Windows PowerShell 5 profile relative to the user's home directory.
64#[cfg(windows)]
65const WINDOWS_POWERSHELL_RC: &str = "Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1";
66
67/// Severity of a probe result.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum ProbeOutcome {
70    /// Healthy.
71    Ok,
72    /// Advisory; does not fail the doctor run.
73    Warn,
74    /// Broken; the doctor run exits non-zero.
75    Fail,
76}
77
78/// One diagnostic line: what was probed, how it went, and the detail text.
79#[derive(Debug, Getters, CopyGetters, TypedBuilder)]
80pub struct Probe {
81    /// What was probed.
82    #[getset(get = "pub")]
83    label: String,
84    /// Severity of the result.
85    #[getset(get_copy = "pub")]
86    outcome: ProbeOutcome,
87    /// Human detail for the line.
88    #[getset(get = "pub")]
89    detail: String,
90}
91
92/// Builds one probe.
93fn probe(label: &str, outcome: ProbeOutcome, detail: String) -> Probe {
94    Probe::builder()
95        .label(label.to_string())
96        .outcome(outcome)
97        .detail(detail)
98        .build()
99}
100
101/// Validates the workspace config.
102pub fn config_probe(config_path: PathBuf) -> Probe {
103    let display = config_path.display().to_string();
104    match check(config_path) {
105        Ok(CheckOutcome::Valid) => probe(
106            CONFIG_LABEL,
107            ProbeOutcome::Ok,
108            format!("{display} {VALID_SUFFIX}"),
109        ),
110        Ok(CheckOutcome::Invalid(report)) => probe(CONFIG_LABEL, ProbeOutcome::Fail, report),
111        // The doctor is a reporter: even an unreadable config becomes a
112        // failing probe rather than aborting the other probes.
113        Err(error) => probe(CONFIG_LABEL, ProbeOutcome::Fail, error_chain(&error)),
114    }
115}
116
117/// Reads the registry and flags projects whose config file is not a reachable
118/// regular file - missing, a directory, or a dangling symlink all fail.
119pub fn registry_probe(registry: &dyn ProjectRegistry) -> Probe {
120    match registry.projects() {
121        Ok(projects) => {
122            let dangling: Vec<String> = projects
123                .iter()
124                .filter(|project| {
125                    !fs::metadata(absolutize(project.config()))
126                        .map(|meta| meta.is_file())
127                        .unwrap_or(false)
128                })
129                .map(|project| project.name().as_ref().to_string())
130                .collect();
131            if dangling.is_empty() {
132                probe(
133                    REGISTRY_LABEL,
134                    ProbeOutcome::Ok,
135                    format!("{} registered", projects.len()),
136                )
137            } else {
138                probe(
139                    REGISTRY_LABEL,
140                    ProbeOutcome::Fail,
141                    format!("missing config for: {}", dangling.join(", ")),
142                )
143            }
144        },
145        Err(error) => probe(REGISTRY_LABEL, ProbeOutcome::Fail, error_chain(&error)),
146    }
147}
148
149/// Confirms the agent-session store is readable.
150pub fn sessions_probe(store: &dyn AgentSessionStore) -> Probe {
151    match store.sessions() {
152        Ok(sessions) => probe(
153            SESSIONS_LABEL,
154            ProbeOutcome::Ok,
155            format!("{} stored", sessions.len()),
156        ),
157        Err(error) => probe(SESSIONS_LABEL, ProbeOutcome::Fail, error_chain(&error)),
158    }
159}
160
161/// Aggregates provider hook states into one line.
162pub fn hooks_probe(statuses: &[HookStatus]) -> Probe {
163    let broken: Vec<String> = statuses
164        .iter()
165        .filter(|status| status.state() != HookState::Installed)
166        .map(|status| format!("{} ({})", status.provider(), status.state()))
167        .collect();
168    if broken.is_empty() {
169        probe(
170            HOOKS_LABEL,
171            ProbeOutcome::Ok,
172            format!("{} providers installed", statuses.len()),
173        )
174    } else {
175        probe(
176            HOOKS_LABEL,
177            ProbeOutcome::Fail,
178            format!("{}; {HOOKS_HINT}", broken.join(", ")),
179        )
180    }
181}
182
183/// A hooks probe for when the status scan itself failed.
184pub fn hooks_probe_error(error: &dyn Error) -> Probe {
185    probe(HOOKS_LABEL, ProbeOutcome::Fail, error_chain(error))
186}
187
188/// Reports which clipboard path a copy would take. Informational only.
189pub fn clipboard_probe() -> Probe {
190    let tool = clipboard::preferred_tool();
191    let detail = match (clipboard::prefers_osc52(), &tool) {
192        (true, _) => "remote session; OSC 52 via the terminal".to_string(),
193        (false, Some(tool_name)) => format!("native tool: {tool_name}"),
194        (false, None) => "no native tool; OSC 52 via the terminal".to_string(),
195    };
196    let outcome = if tool.is_some() || clipboard::prefers_osc52() {
197        ProbeOutcome::Ok
198    } else {
199        ProbeOutcome::Warn
200    };
201    probe(CLIPBOARD_LABEL, outcome, detail)
202}
203
204/// Best-effort check that the shell's rc file registers completions; warns
205/// with the exact line to add when it does not.
206pub fn completions_probe(shell_path: Option<&str>, home: &Path, xdg_config: &Path) -> Probe {
207    let Some(shell) = detect_shell(shell_path) else {
208        return probe(
209            COMPLETIONS_LABEL,
210            ProbeOutcome::Warn,
211            "unknown shell; see `muster completions --help`".to_string(),
212        );
213    };
214    let matched = rc_paths(shell, home, xdg_config).into_iter().find(|path| {
215        fs::read_to_string(path).is_ok_and(|content| registers_completions(&content, shell))
216    });
217    if let Some(matched) = matched {
218        probe(
219            COMPLETIONS_LABEL,
220            ProbeOutcome::Ok,
221            format!("registered in {}", matched.display()),
222        )
223    } else {
224        probe(
225            COMPLETIONS_LABEL,
226            ProbeOutcome::Warn,
227            format!("not registered; add: {}", registration_line(shell)),
228        )
229    }
230}
231
232/// Whether any active (non-comment) line contains the shell's registration
233/// hook; a commented-out line is never evaluated by the shell.
234fn registers_completions(content: &str, shell: CompletionShell) -> bool {
235    content.lines().any(|line| {
236        let active = line.trim_start();
237        !active.starts_with(COMMENT_PREFIX) && active.contains(registration_line(shell))
238    })
239}
240
241/// The shell whose completions the probe should check: `$SHELL` when it
242/// names a known shell, else PowerShell on Windows, where `$SHELL` is
243/// normally unset even though a profile may register completions.
244fn detect_shell(shell_env: Option<&str>) -> Option<CompletionShell> {
245    let named = shell_env.and_then(shell_from_path);
246    if named.is_some() {
247        return named;
248    }
249    if cfg!(windows) {
250        Some(CompletionShell::Powershell)
251    } else {
252        None
253    }
254}
255
256/// The completion shell inferred from a `$SHELL` path.
257fn shell_from_path(shell_path: &str) -> Option<CompletionShell> {
258    let name = Path::new(shell_path).file_name()?.to_str()?;
259    match name {
260        "bash" => Some(CompletionShell::Bash),
261        "zsh" => Some(CompletionShell::Zsh),
262        "fish" => Some(CompletionShell::Fish),
263        "elvish" => Some(CompletionShell::Elvish),
264        "pwsh" | "powershell" => Some(CompletionShell::Powershell),
265        _ => None,
266    }
267}
268
269/// The rc files probed for each shell, relative to home. Fish returns two
270/// locations so both the sourcing approach and the completions drop-in are
271/// detected.
272fn rc_paths(shell: CompletionShell, home: &Path, xdg_config: &Path) -> Vec<PathBuf> {
273    match shell {
274        CompletionShell::Bash => vec![home.join(BASH_RC)],
275        CompletionShell::Zsh => vec![home.join(ZSH_RC)],
276        CompletionShell::Fish => vec![xdg_config.join(FISH_RC), xdg_config.join(FISH_COMPLETIONS)],
277        CompletionShell::Elvish => vec![home.join(ELVISH_RC), xdg_config.join(ELVISH_XDG_RC)],
278        #[cfg(not(windows))]
279        CompletionShell::Powershell => vec![xdg_config.join(POWERSHELL_RC)],
280        // Best effort: $PROFILE can point elsewhere (e.g. OneDrive-redirected
281        // Documents), which a spawned-shell query would be needed to resolve.
282        #[cfg(windows)]
283        CompletionShell::Powershell => {
284            vec![home.join(POWERSHELL_RC), home.join(WINDOWS_POWERSHELL_RC)]
285        },
286    }
287}
288
289/// The report over all probes: one labeled row each, closed by a summary
290/// counting outcomes (shown in the boxed layout only).
291pub fn doctor_report(probes: &[Probe]) -> Report {
292    let rows = probes.iter().map(probe_row).collect();
293    let ok = outcome_count(probes, ProbeOutcome::Ok);
294    let hints = outcome_count(probes, ProbeOutcome::Warn);
295    let failures = outcome_count(probes, ProbeOutcome::Fail);
296    let mut parts = vec![format!("{ok} {SUMMARY_OK}")];
297    if hints > 0 {
298        parts.push(format!(
299            "{hints} {}",
300            plural(hints, SUMMARY_HINT, SUMMARY_HINTS)
301        ));
302    }
303    if failures > 0 {
304        parts.push(format!(
305            "{failures} {}",
306            plural(failures, SUMMARY_FAILURE, SUMMARY_FAILURES)
307        ));
308    }
309    Report::new(DOCTOR_TITLE, rows).with_summary(summary_line(&parts))
310}
311
312/// One probe as a report row: outcome glyph, bold label, detail.
313fn probe_row(probe: &Probe) -> Row {
314    let kind = match probe.outcome() {
315        ProbeOutcome::Ok => RowKind::Ok,
316        ProbeOutcome::Warn => RowKind::Hint,
317        ProbeOutcome::Fail => RowKind::Fail,
318    };
319    Row::labeled(kind, probe.label().clone(), probe.detail().clone())
320}
321
322/// How many probes ended with `outcome`.
323fn outcome_count(probes: &[Probe], outcome: ProbeOutcome) -> usize {
324    probes
325        .iter()
326        .filter(|probe| probe.outcome() == outcome)
327        .count()
328}
329
330/// The singular or plural word for a count.
331fn plural<'a>(count: usize, singular: &'a str, many: &'a str) -> &'a str {
332    if count == 1 { singular } else { many }
333}
334
335/// Whether any probe failed (warnings do not fail the run).
336pub fn any_failed(probes: &[Probe]) -> bool {
337    probes
338        .iter()
339        .any(|probe| probe.outcome() == ProbeOutcome::Fail)
340}
341
342#[cfg(test)]
343mod tests {
344    use std::{cell::RefCell, path::Path};
345
346    use super::*;
347    use crate::domain::{
348        config::ConfigError, process::AgentTool, project::Project, value::ProjectName,
349    };
350
351    /// A registry recording saves of projects and workspaces.
352    #[derive(Default)]
353    struct RecordingRegistry {
354        projects: Vec<Project>,
355        saved_projects: RefCell<Option<Vec<Project>>>,
356    }
357
358    impl crate::domain::port::ProjectRegistry for RecordingRegistry {
359        fn projects(&self) -> Result<Vec<Project>, ConfigError> {
360            Ok(self.projects.clone())
361        }
362
363        fn workspace(
364            &self,
365            _config_path: &Path,
366        ) -> Result<crate::domain::config::WorkspaceConfig, ConfigError> {
367            unreachable!("doctor never loads a workspace")
368        }
369
370        fn workspace_exists(&self, _config_path: &Path) -> bool {
371            false
372        }
373
374        fn save(&self, projects: &[Project]) -> Result<(), ConfigError> {
375            *self.saved_projects.borrow_mut() = Some(projects.to_vec());
376            Ok(())
377        }
378
379        fn save_workspace(
380            &self,
381            _config_path: &Path,
382            _config: &crate::domain::config::WorkspaceConfig,
383        ) -> Result<(), ConfigError> {
384            unreachable!("doctor never saves a workspace")
385        }
386    }
387
388    fn project(name: &str, config: &str) -> Project {
389        Project::builder()
390            .name(ProjectName::try_new(name).unwrap())
391            .config(PathBuf::from(config))
392            .build()
393    }
394
395    fn hook_status(provider: AgentTool, state: HookState) -> HookStatus {
396        HookStatus::builder()
397            .provider(provider)
398            .path(PathBuf::from("/dummy/path"))
399            .state(state)
400            .build()
401    }
402
403    /// A missing config fails the config probe.
404    #[test]
405    fn config_probe_fails_on_a_missing_file() {
406        let probe = config_probe(std::path::PathBuf::from("/definitely/missing/muster.yml"));
407        assert_eq!(probe.outcome(), ProbeOutcome::Fail);
408    }
409
410    /// Registry entries whose config is gone are flagged.
411    #[test]
412    fn registry_probe_flags_dangling_projects() {
413        let registry = RecordingRegistry {
414            projects: vec![project("gone", "/definitely/missing/muster.yml")],
415            ..RecordingRegistry::default()
416        };
417        let probe = registry_probe(&registry);
418        assert_eq!(probe.outcome(), ProbeOutcome::Fail);
419        assert!(probe.detail().contains("gone"));
420    }
421
422    /// A project whose config path is a dangling symlink is flagged as missing.
423    #[cfg(unix)]
424    #[test]
425    fn registry_probe_flags_dangling_symlink_project() {
426        use std::os::unix::fs::symlink;
427
428        let dir =
429            std::env::temp_dir().join(format!("muster-probe-dangling-{}", uuid::Uuid::new_v4()));
430        std::fs::create_dir_all(&dir).unwrap();
431        let config_path = dir.join("muster.yml");
432        symlink(dir.join("nonexistent.yml"), &config_path).unwrap();
433
434        let registry = RecordingRegistry {
435            projects: vec![project("dangling", config_path.to_str().unwrap())],
436            ..RecordingRegistry::default()
437        };
438        let probe = registry_probe(&registry);
439
440        assert_eq!(probe.outcome(), ProbeOutcome::Fail);
441        assert!(
442            probe.detail().contains("dangling"),
443            "dangling project named in detail"
444        );
445
446        std::fs::remove_dir_all(dir).unwrap();
447    }
448
449    /// Hook statuses aggregate: any missing or stale provider fails the probe.
450    #[test]
451    fn hooks_probe_fails_when_any_provider_is_missing() {
452        let statuses = vec![
453            hook_status(AgentTool::Claude, HookState::Installed),
454            hook_status(AgentTool::Codex, HookState::Missing),
455        ];
456        let probe = hooks_probe(&statuses);
457        assert_eq!(probe.outcome(), ProbeOutcome::Fail);
458        assert!(probe.detail().contains("Codex"));
459    }
460
461    /// All-installed hooks pass.
462    #[test]
463    fn hooks_probe_passes_when_everything_is_installed() {
464        let statuses = vec![hook_status(AgentTool::Claude, HookState::Installed)];
465        assert_eq!(hooks_probe(&statuses).outcome(), ProbeOutcome::Ok);
466    }
467
468    /// The clipboard probe never fails; it informs.
469    #[test]
470    fn clipboard_probe_is_informational() {
471        let probe = clipboard_probe();
472        assert_ne!(probe.outcome(), ProbeOutcome::Fail);
473    }
474
475    /// The completions probe warns with the exact line when unregistered.
476    #[test]
477    fn completions_probe_warns_with_the_hook_line() {
478        let dir = std::env::temp_dir().join(format!("muster-doc-{}", uuid::Uuid::new_v4()));
479        std::fs::create_dir_all(&dir).unwrap();
480        std::fs::write(dir.join(".zshrc"), "# nothing here\n").unwrap();
481        let probe = completions_probe(Some("/bin/zsh"), &dir, &dir.join(".config"));
482        assert_eq!(probe.outcome(), ProbeOutcome::Warn);
483        assert!(probe.detail().contains("COMPLETE=zsh"));
484        std::fs::remove_dir_all(dir).unwrap();
485    }
486
487    /// An rc file that mentions COMPLETE and muster in unrelated contexts must
488    /// NOT be reported as registered.
489    #[test]
490    fn completions_probe_ignores_unrelated_complete_mention() {
491        let dir = std::env::temp_dir().join(format!("muster-doc-needle-{}", uuid::Uuid::new_v4()));
492        std::fs::create_dir_all(&dir).unwrap();
493        // Contains both words but not the actual hook line.
494        std::fs::write(dir.join(".zshrc"), "# COMPLETE list for muster tasks\n").unwrap();
495        let probe = completions_probe(Some("/bin/zsh"), &dir, &dir.join(".config"));
496        assert_eq!(probe.outcome(), ProbeOutcome::Warn);
497        std::fs::remove_dir_all(dir).unwrap();
498    }
499
500    /// When the hook line is in the second fish location (completions file),
501    /// the probe reports Ok and names that file.
502    #[test]
503    fn completions_probe_detects_fish_completions_file() {
504        let dir = std::env::temp_dir().join(format!("muster-doc-fish-{}", uuid::Uuid::new_v4()));
505        let completions_path = dir.join(".config").join(FISH_COMPLETIONS);
506        std::fs::create_dir_all(completions_path.parent().unwrap()).unwrap();
507        // Write only to the completions drop-in, not config.fish.
508        std::fs::write(&completions_path, registration_line(CompletionShell::Fish)).unwrap();
509        let probe = completions_probe(Some("/usr/bin/fish"), &dir, &dir.join(".config"));
510        assert_eq!(probe.outcome(), ProbeOutcome::Ok);
511        assert!(probe.detail().contains("completions/muster.fish"));
512        std::fs::remove_dir_all(dir).unwrap();
513    }
514
515    /// The completions probe detects a PowerShell profile containing the exact
516    /// registration line, including the spaces around `=` that would foil a
517    /// `COMPLETE=` needle.
518    #[test]
519    fn completions_probe_detects_powershell_registration() {
520        let dir = std::env::temp_dir().join(format!("muster-pwsh-{}", uuid::Uuid::new_v4()));
521        std::fs::create_dir_all(&dir).unwrap();
522        let rc_path = dir.join(".config").join(POWERSHELL_RC);
523        std::fs::create_dir_all(rc_path.parent().unwrap()).unwrap();
524        std::fs::write(&rc_path, registration_line(CompletionShell::Powershell)).unwrap();
525
526        let probe = completions_probe(Some("/usr/bin/pwsh"), &dir, &dir.join(".config"));
527
528        assert_eq!(probe.outcome(), ProbeOutcome::Ok);
529        std::fs::remove_dir_all(dir).unwrap();
530    }
531
532    /// PowerShell paths map to the powershell completion shell.
533    #[test]
534    fn shell_from_path_recognizes_powershell() {
535        assert_eq!(
536            shell_from_path("/usr/bin/pwsh"),
537            Some(CompletionShell::Powershell)
538        );
539    }
540
541    /// A commented-out registration line does not count as registered.
542    #[test]
543    fn completions_probe_ignores_commented_registrations() {
544        let dir = std::env::temp_dir().join(format!("muster-doc-comment-{}", uuid::Uuid::new_v4()));
545        std::fs::create_dir_all(&dir).unwrap();
546        std::fs::write(
547            dir.join(".zshrc"),
548            format!("# {}\n", registration_line(CompletionShell::Zsh)),
549        )
550        .unwrap();
551
552        let probe = completions_probe(Some("/bin/zsh"), &dir, &dir.join(".config"));
553
554        assert_eq!(probe.outcome(), ProbeOutcome::Warn);
555        std::fs::remove_dir_all(dir).unwrap();
556    }
557
558    /// A named shell always wins; without one, only Windows assumes a shell.
559    #[test]
560    fn detect_shell_prefers_the_environment_then_the_platform() {
561        assert_eq!(detect_shell(Some("/bin/zsh")), Some(CompletionShell::Zsh));
562        #[cfg(windows)]
563        assert_eq!(detect_shell(None), Some(CompletionShell::Powershell));
564        #[cfg(not(windows))]
565        assert_eq!(detect_shell(None), None);
566    }
567
568    /// The doctor report maps probes to labeled rows and counts the summary.
569    #[test]
570    fn doctor_report_maps_probes_and_summarizes() {
571        let probes = vec![
572            Probe::builder()
573                .label("config".to_string())
574                .outcome(ProbeOutcome::Ok)
575                .detail("fine".to_string())
576                .build(),
577            Probe::builder()
578                .label("completions".to_string())
579                .outcome(ProbeOutcome::Warn)
580                .detail("not registered".to_string())
581                .build(),
582        ];
583
584        let report = doctor_report(&probes);
585
586        assert_eq!(report.rows().len(), 2);
587        assert_eq!(report.rows()[0].kind(), RowKind::Ok);
588        assert_eq!(report.rows()[0].label().as_deref(), Some("config"));
589        assert_eq!(report.rows()[1].kind(), RowKind::Hint);
590        assert_eq!(report.summary().as_deref(), Some("1 ok ยท 1 hint"));
591    }
592}