use std::{
error::Error,
fs,
path::{Path, PathBuf},
};
use getset::{CopyGetters, Getters};
use typed_builder::TypedBuilder;
use super::{
check::{CheckOutcome, VALID_SUFFIX, check, error_chain},
completions::{CompletionShell, registration_line},
report::{Report, Row, RowKind, summary_line},
};
use crate::{
adapter::{
clipboard,
hooks::{HookState, HookStatus},
path::absolutize,
},
domain::port::{AgentSessionStore, ProjectRegistry},
};
const DOCTOR_TITLE: &str = "muster doctor";
const SUMMARY_OK: &str = "ok";
const SUMMARY_HINT: &str = "hint";
const SUMMARY_HINTS: &str = "hints";
const SUMMARY_FAILURE: &str = "failure";
const SUMMARY_FAILURES: &str = "failures";
const CONFIG_LABEL: &str = "config";
const REGISTRY_LABEL: &str = "projects";
const SESSIONS_LABEL: &str = "sessions";
const HOOKS_LABEL: &str = "agent hooks";
const CLIPBOARD_LABEL: &str = "clipboard";
const COMPLETIONS_LABEL: &str = "completions";
const COMMENT_PREFIX: &str = "#";
const HOOKS_HINT: &str = "run `muster hooks setup`";
const BASH_RC: &str = ".bashrc";
const ZSH_RC: &str = ".zshrc";
const FISH_RC: &str = "fish/config.fish";
const FISH_COMPLETIONS: &str = "fish/completions/muster.fish";
const ELVISH_RC: &str = ".elvish/rc.elv";
const ELVISH_XDG_RC: &str = "elvish/rc.elv";
#[cfg(not(windows))]
const POWERSHELL_RC: &str = "powershell/Microsoft.PowerShell_profile.ps1";
#[cfg(windows)]
const POWERSHELL_RC: &str = "Documents/PowerShell/Microsoft.PowerShell_profile.ps1";
#[cfg(windows)]
const WINDOWS_POWERSHELL_RC: &str = "Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProbeOutcome {
Ok,
Warn,
Fail,
}
#[derive(Debug, Getters, CopyGetters, TypedBuilder)]
pub struct Probe {
#[getset(get = "pub")]
label: String,
#[getset(get_copy = "pub")]
outcome: ProbeOutcome,
#[getset(get = "pub")]
detail: String,
}
fn probe(label: &str, outcome: ProbeOutcome, detail: String) -> Probe {
Probe::builder()
.label(label.to_string())
.outcome(outcome)
.detail(detail)
.build()
}
pub fn config_probe(config_path: PathBuf) -> Probe {
let display = config_path.display().to_string();
match check(config_path) {
Ok(CheckOutcome::Valid) => probe(
CONFIG_LABEL,
ProbeOutcome::Ok,
format!("{display} {VALID_SUFFIX}"),
),
Ok(CheckOutcome::Invalid(report)) => probe(CONFIG_LABEL, ProbeOutcome::Fail, report),
Err(error) => probe(CONFIG_LABEL, ProbeOutcome::Fail, error_chain(&error)),
}
}
pub fn registry_probe(registry: &dyn ProjectRegistry) -> Probe {
match registry.projects() {
Ok(projects) => {
let dangling: Vec<String> = projects
.iter()
.filter(|project| {
!fs::metadata(absolutize(project.config()))
.map(|meta| meta.is_file())
.unwrap_or(false)
})
.map(|project| project.name().as_ref().to_string())
.collect();
if dangling.is_empty() {
probe(
REGISTRY_LABEL,
ProbeOutcome::Ok,
format!("{} registered", projects.len()),
)
} else {
probe(
REGISTRY_LABEL,
ProbeOutcome::Fail,
format!("missing config for: {}", dangling.join(", ")),
)
}
},
Err(error) => probe(REGISTRY_LABEL, ProbeOutcome::Fail, error_chain(&error)),
}
}
pub fn sessions_probe(store: &dyn AgentSessionStore) -> Probe {
match store.sessions() {
Ok(sessions) => probe(
SESSIONS_LABEL,
ProbeOutcome::Ok,
format!("{} stored", sessions.len()),
),
Err(error) => probe(SESSIONS_LABEL, ProbeOutcome::Fail, error_chain(&error)),
}
}
pub fn hooks_probe(statuses: &[HookStatus]) -> Probe {
let broken: Vec<String> = statuses
.iter()
.filter(|status| status.state() != HookState::Installed)
.map(|status| format!("{} ({})", status.provider(), status.state()))
.collect();
if broken.is_empty() {
probe(
HOOKS_LABEL,
ProbeOutcome::Ok,
format!("{} providers installed", statuses.len()),
)
} else {
probe(
HOOKS_LABEL,
ProbeOutcome::Fail,
format!("{}; {HOOKS_HINT}", broken.join(", ")),
)
}
}
pub fn hooks_probe_error(error: &dyn Error) -> Probe {
probe(HOOKS_LABEL, ProbeOutcome::Fail, error_chain(error))
}
pub fn clipboard_probe() -> Probe {
let tool = clipboard::preferred_tool();
let detail = match (clipboard::prefers_osc52(), &tool) {
(true, _) => "remote session; OSC 52 via the terminal".to_string(),
(false, Some(tool_name)) => format!("native tool: {tool_name}"),
(false, None) => "no native tool; OSC 52 via the terminal".to_string(),
};
let outcome = if tool.is_some() || clipboard::prefers_osc52() {
ProbeOutcome::Ok
} else {
ProbeOutcome::Warn
};
probe(CLIPBOARD_LABEL, outcome, detail)
}
pub fn completions_probe(shell_path: Option<&str>, home: &Path, xdg_config: &Path) -> Probe {
let Some(shell) = detect_shell(shell_path) else {
return probe(
COMPLETIONS_LABEL,
ProbeOutcome::Warn,
"unknown shell; see `muster completions --help`".to_string(),
);
};
let matched = rc_paths(shell, home, xdg_config).into_iter().find(|path| {
fs::read_to_string(path).is_ok_and(|content| registers_completions(&content, shell))
});
if let Some(matched) = matched {
probe(
COMPLETIONS_LABEL,
ProbeOutcome::Ok,
format!("registered in {}", matched.display()),
)
} else {
probe(
COMPLETIONS_LABEL,
ProbeOutcome::Warn,
format!("not registered; add: {}", registration_line(shell)),
)
}
}
fn registers_completions(content: &str, shell: CompletionShell) -> bool {
content.lines().any(|line| {
let active = line.trim_start();
!active.starts_with(COMMENT_PREFIX) && active.contains(registration_line(shell))
})
}
fn detect_shell(shell_env: Option<&str>) -> Option<CompletionShell> {
let named = shell_env.and_then(shell_from_path);
if named.is_some() {
return named;
}
if cfg!(windows) {
Some(CompletionShell::Powershell)
} else {
None
}
}
fn shell_from_path(shell_path: &str) -> Option<CompletionShell> {
let name = Path::new(shell_path).file_name()?.to_str()?;
match name {
"bash" => Some(CompletionShell::Bash),
"zsh" => Some(CompletionShell::Zsh),
"fish" => Some(CompletionShell::Fish),
"elvish" => Some(CompletionShell::Elvish),
"pwsh" | "powershell" => Some(CompletionShell::Powershell),
_ => None,
}
}
fn rc_paths(shell: CompletionShell, home: &Path, xdg_config: &Path) -> Vec<PathBuf> {
match shell {
CompletionShell::Bash => vec![home.join(BASH_RC)],
CompletionShell::Zsh => vec![home.join(ZSH_RC)],
CompletionShell::Fish => vec![xdg_config.join(FISH_RC), xdg_config.join(FISH_COMPLETIONS)],
CompletionShell::Elvish => vec![home.join(ELVISH_RC), xdg_config.join(ELVISH_XDG_RC)],
#[cfg(not(windows))]
CompletionShell::Powershell => vec![xdg_config.join(POWERSHELL_RC)],
#[cfg(windows)]
CompletionShell::Powershell => {
vec![home.join(POWERSHELL_RC), home.join(WINDOWS_POWERSHELL_RC)]
},
}
}
pub fn doctor_report(probes: &[Probe]) -> Report {
let rows = probes.iter().map(probe_row).collect();
let ok = outcome_count(probes, ProbeOutcome::Ok);
let hints = outcome_count(probes, ProbeOutcome::Warn);
let failures = outcome_count(probes, ProbeOutcome::Fail);
let mut parts = vec![format!("{ok} {SUMMARY_OK}")];
if hints > 0 {
parts.push(format!(
"{hints} {}",
plural(hints, SUMMARY_HINT, SUMMARY_HINTS)
));
}
if failures > 0 {
parts.push(format!(
"{failures} {}",
plural(failures, SUMMARY_FAILURE, SUMMARY_FAILURES)
));
}
Report::new(DOCTOR_TITLE, rows).with_summary(summary_line(&parts))
}
fn probe_row(probe: &Probe) -> Row {
let kind = match probe.outcome() {
ProbeOutcome::Ok => RowKind::Ok,
ProbeOutcome::Warn => RowKind::Hint,
ProbeOutcome::Fail => RowKind::Fail,
};
Row::labeled(kind, probe.label().clone(), probe.detail().clone())
}
fn outcome_count(probes: &[Probe], outcome: ProbeOutcome) -> usize {
probes
.iter()
.filter(|probe| probe.outcome() == outcome)
.count()
}
fn plural<'a>(count: usize, singular: &'a str, many: &'a str) -> &'a str {
if count == 1 { singular } else { many }
}
pub fn any_failed(probes: &[Probe]) -> bool {
probes
.iter()
.any(|probe| probe.outcome() == ProbeOutcome::Fail)
}
#[cfg(test)]
mod tests {
use std::{cell::RefCell, path::Path};
use super::*;
use crate::domain::{
config::ConfigError, process::AgentTool, project::Project, value::ProjectName,
};
#[derive(Default)]
struct RecordingRegistry {
projects: Vec<Project>,
saved_projects: RefCell<Option<Vec<Project>>>,
}
impl crate::domain::port::ProjectRegistry for RecordingRegistry {
fn projects(&self) -> Result<Vec<Project>, ConfigError> {
Ok(self.projects.clone())
}
fn workspace(
&self,
_config_path: &Path,
) -> Result<crate::domain::config::WorkspaceConfig, ConfigError> {
unreachable!("doctor never loads a workspace")
}
fn workspace_exists(&self, _config_path: &Path) -> bool {
false
}
fn save(&self, projects: &[Project]) -> Result<(), ConfigError> {
*self.saved_projects.borrow_mut() = Some(projects.to_vec());
Ok(())
}
fn save_workspace(
&self,
_config_path: &Path,
_config: &crate::domain::config::WorkspaceConfig,
) -> Result<(), ConfigError> {
unreachable!("doctor never saves a workspace")
}
}
fn project(name: &str, config: &str) -> Project {
Project::builder()
.name(ProjectName::try_new(name).unwrap())
.config(PathBuf::from(config))
.build()
}
fn hook_status(provider: AgentTool, state: HookState) -> HookStatus {
HookStatus::builder()
.provider(provider)
.path(PathBuf::from("/dummy/path"))
.state(state)
.build()
}
#[test]
fn config_probe_fails_on_a_missing_file() {
let probe = config_probe(std::path::PathBuf::from("/definitely/missing/muster.yml"));
assert_eq!(probe.outcome(), ProbeOutcome::Fail);
}
#[test]
fn registry_probe_flags_dangling_projects() {
let registry = RecordingRegistry {
projects: vec![project("gone", "/definitely/missing/muster.yml")],
..RecordingRegistry::default()
};
let probe = registry_probe(®istry);
assert_eq!(probe.outcome(), ProbeOutcome::Fail);
assert!(probe.detail().contains("gone"));
}
#[cfg(unix)]
#[test]
fn registry_probe_flags_dangling_symlink_project() {
use std::os::unix::fs::symlink;
let dir =
std::env::temp_dir().join(format!("muster-probe-dangling-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let config_path = dir.join("muster.yml");
symlink(dir.join("nonexistent.yml"), &config_path).unwrap();
let registry = RecordingRegistry {
projects: vec![project("dangling", config_path.to_str().unwrap())],
..RecordingRegistry::default()
};
let probe = registry_probe(®istry);
assert_eq!(probe.outcome(), ProbeOutcome::Fail);
assert!(
probe.detail().contains("dangling"),
"dangling project named in detail"
);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn hooks_probe_fails_when_any_provider_is_missing() {
let statuses = vec![
hook_status(AgentTool::Claude, HookState::Installed),
hook_status(AgentTool::Codex, HookState::Missing),
];
let probe = hooks_probe(&statuses);
assert_eq!(probe.outcome(), ProbeOutcome::Fail);
assert!(probe.detail().contains("Codex"));
}
#[test]
fn hooks_probe_passes_when_everything_is_installed() {
let statuses = vec![hook_status(AgentTool::Claude, HookState::Installed)];
assert_eq!(hooks_probe(&statuses).outcome(), ProbeOutcome::Ok);
}
#[test]
fn clipboard_probe_is_informational() {
let probe = clipboard_probe();
assert_ne!(probe.outcome(), ProbeOutcome::Fail);
}
#[test]
fn completions_probe_warns_with_the_hook_line() {
let dir = std::env::temp_dir().join(format!("muster-doc-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(".zshrc"), "# nothing here\n").unwrap();
let probe = completions_probe(Some("/bin/zsh"), &dir, &dir.join(".config"));
assert_eq!(probe.outcome(), ProbeOutcome::Warn);
assert!(probe.detail().contains("COMPLETE=zsh"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn completions_probe_ignores_unrelated_complete_mention() {
let dir = std::env::temp_dir().join(format!("muster-doc-needle-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(".zshrc"), "# COMPLETE list for muster tasks\n").unwrap();
let probe = completions_probe(Some("/bin/zsh"), &dir, &dir.join(".config"));
assert_eq!(probe.outcome(), ProbeOutcome::Warn);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn completions_probe_detects_fish_completions_file() {
let dir = std::env::temp_dir().join(format!("muster-doc-fish-{}", uuid::Uuid::new_v4()));
let completions_path = dir.join(".config").join(FISH_COMPLETIONS);
std::fs::create_dir_all(completions_path.parent().unwrap()).unwrap();
std::fs::write(&completions_path, registration_line(CompletionShell::Fish)).unwrap();
let probe = completions_probe(Some("/usr/bin/fish"), &dir, &dir.join(".config"));
assert_eq!(probe.outcome(), ProbeOutcome::Ok);
assert!(probe.detail().contains("completions/muster.fish"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn completions_probe_detects_powershell_registration() {
let dir = std::env::temp_dir().join(format!("muster-pwsh-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let rc_path = dir.join(".config").join(POWERSHELL_RC);
std::fs::create_dir_all(rc_path.parent().unwrap()).unwrap();
std::fs::write(&rc_path, registration_line(CompletionShell::Powershell)).unwrap();
let probe = completions_probe(Some("/usr/bin/pwsh"), &dir, &dir.join(".config"));
assert_eq!(probe.outcome(), ProbeOutcome::Ok);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn shell_from_path_recognizes_powershell() {
assert_eq!(
shell_from_path("/usr/bin/pwsh"),
Some(CompletionShell::Powershell)
);
}
#[test]
fn completions_probe_ignores_commented_registrations() {
let dir = std::env::temp_dir().join(format!("muster-doc-comment-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(".zshrc"),
format!("# {}\n", registration_line(CompletionShell::Zsh)),
)
.unwrap();
let probe = completions_probe(Some("/bin/zsh"), &dir, &dir.join(".config"));
assert_eq!(probe.outcome(), ProbeOutcome::Warn);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn detect_shell_prefers_the_environment_then_the_platform() {
assert_eq!(detect_shell(Some("/bin/zsh")), Some(CompletionShell::Zsh));
#[cfg(windows)]
assert_eq!(detect_shell(None), Some(CompletionShell::Powershell));
#[cfg(not(windows))]
assert_eq!(detect_shell(None), None);
}
#[test]
fn doctor_report_maps_probes_and_summarizes() {
let probes = vec![
Probe::builder()
.label("config".to_string())
.outcome(ProbeOutcome::Ok)
.detail("fine".to_string())
.build(),
Probe::builder()
.label("completions".to_string())
.outcome(ProbeOutcome::Warn)
.detail("not registered".to_string())
.build(),
];
let report = doctor_report(&probes);
assert_eq!(report.rows().len(), 2);
assert_eq!(report.rows()[0].kind(), RowKind::Ok);
assert_eq!(report.rows()[0].label().as_deref(), Some("config"));
assert_eq!(report.rows()[1].kind(), RowKind::Hint);
assert_eq!(report.summary().as_deref(), Some("1 ok ยท 1 hint"));
}
}