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}