Skip to main content

ecr_core/
doctor.rs

1use crate::account::Account;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum CheckStatus {
9    Ok,
10    Warn,
11    Fail,
12}
13
14impl CheckStatus {
15    pub fn symbol(&self) -> &'static str {
16        match self {
17            CheckStatus::Ok => "ok",
18            CheckStatus::Warn => "warn",
19            CheckStatus::Fail => "FAIL",
20        }
21    }
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct Check {
26    pub name: String,
27    pub status: CheckStatus,
28    pub detail: String,
29    pub hint: Option<String>,
30}
31
32impl Check {
33    pub fn ok(name: impl Into<String>, detail: impl Into<String>) -> Self {
34        Self {
35            name: name.into(),
36            status: CheckStatus::Ok,
37            detail: detail.into(),
38            hint: None,
39        }
40    }
41
42    pub fn warn(name: impl Into<String>, detail: impl Into<String>) -> Self {
43        Self {
44            name: name.into(),
45            status: CheckStatus::Warn,
46            detail: detail.into(),
47            hint: None,
48        }
49    }
50
51    pub fn fail(name: impl Into<String>, detail: impl Into<String>) -> Self {
52        Self {
53            name: name.into(),
54            status: CheckStatus::Fail,
55            detail: detail.into(),
56            hint: None,
57        }
58    }
59
60    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
61        self.hint = Some(hint.into());
62        self
63    }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum ConfigKind {
69    Notmuch,
70    Mbsync,
71    Msmtp,
72}
73
74impl fmt::Display for ConfigKind {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.write_str(match self {
77            ConfigKind::Notmuch => "notmuch",
78            ConfigKind::Mbsync => "mbsync",
79            ConfigKind::Msmtp => "msmtp",
80        })
81    }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case", tag = "source", content = "via")]
86pub enum ConfigSource {
87    ServerToml,
88    EnvVar(String),
89    Xdg,
90    LegacyDotfile,
91    NotFound,
92}
93
94impl fmt::Display for ConfigSource {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            ConfigSource::ServerToml => f.write_str("ecr server.toml"),
98            ConfigSource::EnvVar(name) => write!(f, "${name}"),
99            ConfigSource::Xdg => f.write_str("XDG"),
100            ConfigSource::LegacyDotfile => f.write_str("legacy dotfile"),
101            ConfigSource::NotFound => f.write_str("not found"),
102        }
103    }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct ResolvedConfig {
108    pub kind: ConfigKind,
109    pub path: Option<PathBuf>,
110    pub source: ConfigSource,
111    pub shadowed: Vec<PathBuf>,
112}
113
114impl ResolvedConfig {
115    pub fn missing(kind: ConfigKind) -> Self {
116        Self {
117            kind,
118            path: None,
119            source: ConfigSource::NotFound,
120            shadowed: Vec::new(),
121        }
122    }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct ToolInfo {
127    pub name: String,
128    pub path: Option<PathBuf>,
129    pub version: Option<String>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct Doctor {
134    pub tools: Vec<ToolInfo>,
135    pub configs: Vec<ResolvedConfig>,
136    pub maildir_root: Option<PathBuf>,
137    pub database_path: Option<PathBuf>,
138    pub post_new_hook: Option<PathBuf>,
139    pub accounts: Vec<Account>,
140    pub checks: Vec<Check>,
141}
142
143impl Doctor {
144    pub fn status(&self) -> CheckStatus {
145        self.checks
146            .iter()
147            .map(|c| c.status)
148            .max()
149            .unwrap_or(CheckStatus::Ok)
150    }
151
152    pub fn is_healthy(&self) -> bool {
153        self.status() != CheckStatus::Fail
154    }
155
156    pub fn failures(&self) -> impl Iterator<Item = &Check> {
157        self.checks.iter().filter(|c| c.status == CheckStatus::Fail)
158    }
159
160    pub fn config(&self, kind: ConfigKind) -> Option<&ResolvedConfig> {
161        self.configs.iter().find(|c| c.kind == kind)
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn doctor_with(checks: Vec<Check>) -> Doctor {
170        Doctor {
171            tools: Vec::new(),
172            configs: Vec::new(),
173            maildir_root: None,
174            database_path: None,
175            post_new_hook: None,
176            accounts: Vec::new(),
177            checks,
178        }
179    }
180
181    #[test]
182    fn status_is_the_worst_check() {
183        let d = doctor_with(vec![
184            Check::ok("a", ""),
185            Check::warn("b", ""),
186            Check::ok("c", ""),
187        ]);
188        assert_eq!(d.status(), CheckStatus::Warn);
189        assert!(d.is_healthy());
190    }
191
192    #[test]
193    fn any_failure_makes_it_unhealthy() {
194        let d = doctor_with(vec![Check::ok("a", ""), Check::fail("b", "broken")]);
195        assert_eq!(d.status(), CheckStatus::Fail);
196        assert!(!d.is_healthy());
197        assert_eq!(d.failures().count(), 1);
198    }
199
200    #[test]
201    fn no_checks_is_ok() {
202        assert_eq!(doctor_with(Vec::new()).status(), CheckStatus::Ok);
203    }
204}