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
24const DOCTOR_TITLE: &str = "muster doctor";
26const SUMMARY_OK: &str = "ok";
28const SUMMARY_HINT: &str = "hint";
30const SUMMARY_HINTS: &str = "hints";
31const SUMMARY_FAILURE: &str = "failure";
33const SUMMARY_FAILURES: &str = "failures";
34const 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";
41const COMMENT_PREFIX: &str = "#";
43const HOOKS_HINT: &str = "run `muster hooks setup`";
45const BASH_RC: &str = ".bashrc";
47const ZSH_RC: &str = ".zshrc";
49const FISH_RC: &str = "fish/config.fish";
51const FISH_COMPLETIONS: &str = "fish/completions/muster.fish";
53const ELVISH_RC: &str = ".elvish/rc.elv";
55const ELVISH_XDG_RC: &str = "elvish/rc.elv";
57#[cfg(not(windows))]
59const POWERSHELL_RC: &str = "powershell/Microsoft.PowerShell_profile.ps1";
60#[cfg(windows)]
62const POWERSHELL_RC: &str = "Documents/PowerShell/Microsoft.PowerShell_profile.ps1";
63#[cfg(windows)]
65const WINDOWS_POWERSHELL_RC: &str = "Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1";
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum ProbeOutcome {
70 Ok,
72 Warn,
74 Fail,
76}
77
78#[derive(Debug, Getters, CopyGetters, TypedBuilder)]
80pub struct Probe {
81 #[getset(get = "pub")]
83 label: String,
84 #[getset(get_copy = "pub")]
86 outcome: ProbeOutcome,
87 #[getset(get = "pub")]
89 detail: String,
90}
91
92fn 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
101pub 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 Err(error) => probe(CONFIG_LABEL, ProbeOutcome::Fail, error_chain(&error)),
114 }
115}
116
117pub 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
149pub 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
161pub 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
183pub fn hooks_probe_error(error: &dyn Error) -> Probe {
185 probe(HOOKS_LABEL, ProbeOutcome::Fail, error_chain(error))
186}
187
188pub 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
204pub 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
232fn 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
241fn 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
256fn 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
269fn 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 #[cfg(windows)]
283 CompletionShell::Powershell => {
284 vec![home.join(POWERSHELL_RC), home.join(WINDOWS_POWERSHELL_RC)]
285 },
286 }
287}
288
289pub 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
312fn 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
322fn outcome_count(probes: &[Probe], outcome: ProbeOutcome) -> usize {
324 probes
325 .iter()
326 .filter(|probe| probe.outcome() == outcome)
327 .count()
328}
329
330fn plural<'a>(count: usize, singular: &'a str, many: &'a str) -> &'a str {
332 if count == 1 { singular } else { many }
333}
334
335pub 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 #[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 #[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 #[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(®istry);
418 assert_eq!(probe.outcome(), ProbeOutcome::Fail);
419 assert!(probe.detail().contains("gone"));
420 }
421
422 #[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(®istry);
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 #[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 #[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 #[test]
470 fn clipboard_probe_is_informational() {
471 let probe = clipboard_probe();
472 assert_ne!(probe.outcome(), ProbeOutcome::Fail);
473 }
474
475 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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}