Skip to main content

muster/adapter/cli/
check.rs

1use std::path::PathBuf;
2
3use super::error::CliError;
4use crate::{
5    adapter::config::YamlConfigSource,
6    domain::{config::ConfigError, port::ConfigSource},
7};
8
9/// Separator between links of a reported error chain.
10const CHAIN_SEPARATOR: &str = ": ";
11/// Suffix shared between `muster check` output and the doctor config probe.
12pub const VALID_SUFFIX: &str = "is valid";
13
14/// Result of validating a workspace config.
15#[derive(Debug)]
16pub enum CheckOutcome {
17    /// The config loaded and validated without errors.
18    Valid,
19    /// The config failed to load; the report is the full error chain.
20    Invalid(String),
21}
22
23/// Validates the workspace config at `config_path`. A config that fails to
24/// parse or validate is a finding; a file that cannot be read at all is an
25/// operational failure of the command itself.
26///
27/// # Errors
28/// Returns [`CliError`] when the config cannot be read.
29pub fn check(config_path: PathBuf) -> Result<CheckOutcome, CliError> {
30    let source = YamlConfigSource::builder().path(config_path).build();
31    match source.load() {
32        Ok(_) => Ok(CheckOutcome::Valid),
33        Err(error @ ConfigError::Read { .. }) => Err(CliError::Config(error)),
34        Err(error) => Ok(CheckOutcome::Invalid(error_chain(&error))),
35    }
36}
37
38/// Formats an error and its sources as one line, outermost first, separated by
39/// `": "`. Reused by `muster doctor` to report multiple findings uniformly.
40pub fn error_chain(error: &dyn std::error::Error) -> String {
41    let mut report = error.to_string();
42    let mut source = error.source();
43    while let Some(cause) = source {
44        let text = cause.to_string();
45        // Many error displays already embed their source text; appending
46        // those again would repeat every diagnostic verbatim.
47        if !report.contains(&text) {
48            report.push_str(CHAIN_SEPARATOR);
49            report.push_str(&text);
50        }
51        source = cause.source();
52    }
53    report
54}
55
56#[cfg(test)]
57mod tests {
58    use std::{fs, path::PathBuf};
59
60    use super::*;
61
62    fn temp_config(tag: &str, content: &str) -> PathBuf {
63        let dir = std::env::temp_dir().join(format!("muster-check-{tag}-{}", uuid::Uuid::new_v4()));
64        fs::create_dir_all(&dir).unwrap();
65        let path = dir.join("muster.yml");
66        fs::write(&path, content).unwrap();
67        path
68    }
69
70    /// A valid workspace reports Valid.
71    #[test]
72    fn a_valid_config_passes() {
73        let path = temp_config("ok", "agents: []\nterminals: []\ncommands: []\n");
74        assert!(matches!(check(path), Ok(CheckOutcome::Valid)));
75    }
76
77    /// Broken YAML reports the parse failure.
78    #[test]
79    fn broken_yaml_reports_the_error() {
80        let path = temp_config("bad", "agents: [unclosed\n");
81        match check(path) {
82            Ok(CheckOutcome::Invalid(report)) => assert!(!report.is_empty()),
83            other => panic!("must be a finding: {other:?}"),
84        }
85    }
86
87    /// A missing file is an operational failure, not a validation finding.
88    #[test]
89    fn a_missing_file_is_a_command_failure() {
90        let path = std::env::temp_dir().join("muster-check-missing/muster.yml");
91        assert!(matches!(check(path), Err(CliError::Config(_))));
92    }
93
94    /// An error whose display already embeds its source text.
95    #[derive(Debug)]
96    struct EmbeddingError(WrappedError);
97
98    impl std::fmt::Display for EmbeddingError {
99        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100            write!(f, "could not parse: {}", self.0)
101        }
102    }
103
104    impl std::error::Error for EmbeddingError {
105        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
106            Some(&self.0)
107        }
108    }
109
110    /// An error whose display is context-only, not embedding its source.
111    #[derive(Debug)]
112    struct ContextError(WrappedError);
113
114    impl std::fmt::Display for ContextError {
115        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116            write!(f, "loading the config failed")
117        }
118    }
119
120    impl std::error::Error for ContextError {
121        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
122            Some(&self.0)
123        }
124    }
125
126    #[derive(Debug)]
127    struct WrappedError;
128
129    impl std::fmt::Display for WrappedError {
130        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131            write!(f, "unexpected token at line 3")
132        }
133    }
134
135    impl std::error::Error for WrappedError {}
136
137    /// Sources already rendered by the outer display are not appended again;
138    /// sources adding new text still are.
139    #[test]
140    fn error_chain_skips_already_rendered_sources() {
141        assert_eq!(
142            error_chain(&EmbeddingError(WrappedError)),
143            "could not parse: unexpected token at line 3"
144        );
145        assert_eq!(
146            error_chain(&ContextError(WrappedError)),
147            "loading the config failed: unexpected token at line 3"
148        );
149    }
150}