Skip to main content

runex_core/
doctor.rs

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