Skip to main content

runex_core/
doctor.rs

1use std::path::Path;
2
3use crate::model::Config;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum CheckStatus {
7    Ok,
8    Warn,
9    Error,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Check {
14    pub name: String,
15    pub status: CheckStatus,
16    pub detail: String,
17}
18
19#[derive(Debug, Clone)]
20pub struct DiagResult {
21    pub checks: Vec<Check>,
22}
23
24impl DiagResult {
25    pub fn is_healthy(&self) -> bool {
26        self.checks
27            .iter()
28            .all(|c| c.status == CheckStatus::Ok || c.status == CheckStatus::Warn)
29    }
30}
31
32/// Run environment diagnostics.
33///
34/// `config` is `None` when config loading failed (parse error, etc.).
35/// `command_exists` is injected for testability.
36pub fn diagnose<F>(config_path: &Path, config: Option<&Config>, command_exists: F) -> DiagResult
37where
38    F: Fn(&str) -> bool,
39{
40    let mut checks = Vec::new();
41
42    // 1. Config file exists
43    let config_exists = config_path.exists();
44    checks.push(Check {
45        name: "config_file".into(),
46        status: if config_exists {
47            CheckStatus::Ok
48        } else {
49            CheckStatus::Error
50        },
51        detail: if config_exists {
52            format!("found: {}", config_path.display())
53        } else {
54            format!("not found: {}", config_path.display())
55        },
56    });
57
58    // 2. Config parse
59    checks.push(Check {
60        name: "config_parse".into(),
61        status: if config.is_some() {
62            CheckStatus::Ok
63        } else {
64            CheckStatus::Error
65        },
66        detail: if config.is_some() {
67            "config loaded successfully".into()
68        } else {
69            "failed to load config".into()
70        },
71    });
72
73    // 3. Check when_command_exists commands
74    if let Some(cfg) = config {
75        for abbr in &cfg.abbr {
76            if let Some(cmds) = &abbr.when_command_exists {
77                for cmd in cmds {
78                    let exists = command_exists(cmd);
79                    checks.push(Check {
80                        name: format!("command:{cmd}"),
81                        status: if exists {
82                            CheckStatus::Ok
83                        } else {
84                            CheckStatus::Warn
85                        },
86                        detail: if exists {
87                            format!("'{cmd}' found (required by '{}')", abbr.key)
88                        } else {
89                            format!("'{cmd}' not found (required by '{}')", abbr.key)
90                        },
91                    });
92                }
93            }
94        }
95    }
96
97    DiagResult { checks }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::model::{Abbr, Config};
104    use std::io::Write;
105
106    fn test_config(abbrs: Vec<Abbr>) -> Config {
107        Config {
108            version: 1,
109            keybind: crate::model::KeybindConfig::default(),
110            abbr: abbrs,
111        }
112    }
113
114    fn abbr_when(key: &str, exp: &str, cmds: Vec<&str>) -> Abbr {
115        Abbr {
116            key: key.into(),
117            expand: exp.into(),
118            when_command_exists: Some(cmds.into_iter().map(String::from).collect()),
119        }
120    }
121
122    #[test]
123    fn all_healthy() {
124        let dir = tempfile::tempdir().unwrap();
125        let path = dir.path().join("config.toml");
126        let mut f = std::fs::File::create(&path).unwrap();
127        writeln!(f, "version = 1").unwrap();
128
129        let cfg = test_config(vec![abbr_when("ls", "lsd", vec!["lsd"])]);
130        let result = diagnose(&path, Some(&cfg), |_| true);
131
132        assert!(result.is_healthy());
133        assert_eq!(result.checks[0].status, CheckStatus::Ok); // file exists
134        assert_eq!(result.checks[1].status, CheckStatus::Ok); // config parsed
135        assert_eq!(result.checks[2].status, CheckStatus::Ok); // command found
136    }
137
138    #[test]
139    fn config_file_missing() {
140        let path = std::path::PathBuf::from("/nonexistent/config.toml");
141        let result = diagnose(&path, None, |_| true);
142
143        assert!(!result.is_healthy());
144        assert_eq!(result.checks[0].status, CheckStatus::Error);
145        assert_eq!(result.checks[1].status, CheckStatus::Error);
146    }
147
148    #[test]
149    fn command_not_found_is_warn() {
150        let dir = tempfile::tempdir().unwrap();
151        let path = dir.path().join("config.toml");
152        std::fs::write(&path, "version = 1").unwrap();
153
154        let cfg = test_config(vec![abbr_when("ls", "lsd", vec!["lsd"])]);
155        let result = diagnose(&path, Some(&cfg), |_| false);
156
157        // is_healthy returns true for Warn
158        assert!(result.is_healthy());
159        assert_eq!(result.checks[2].status, CheckStatus::Warn);
160        assert!(result.checks[2].detail.contains("not found"));
161    }
162
163    #[test]
164    fn diag_result_is_healthy_with_error() {
165        let result = DiagResult {
166            checks: vec![Check {
167                name: "test".into(),
168                status: CheckStatus::Error,
169                detail: "bad".into(),
170            }],
171        };
172        assert!(!result.is_healthy());
173    }
174}