1use super::*;
2
3#[derive(Clone, Debug, Serialize)]
4struct DoctorIssue {
5 code: String,
6 severity: &'static str,
7 message: String,
8 #[serde(skip_serializing_if = "Option::is_none")]
9 path: Option<String>,
10 #[serde(default, skip_serializing_if = "Vec::is_empty")]
11 refs: Vec<String>,
12 repairable: bool,
13}
14
15impl DoctorIssue {
16 fn error(code: &str, message: impl Into<String>, path: Option<String>) -> Self {
17 Self {
18 code: code.to_string(),
19 severity: "error",
20 message: message.into(),
21 path,
22 refs: Vec::new(),
23 repairable: false,
24 }
25 }
26
27 fn warning(
28 code: &str,
29 message: impl Into<String>,
30 path: Option<String>,
31 repairable: bool,
32 ) -> Self {
33 Self {
34 code: code.to_string(),
35 severity: "warning",
36 message: message.into(),
37 path,
38 refs: Vec::new(),
39 repairable,
40 }
41 }
42}
43
44impl Workspace {
45 pub fn doctor(&self) -> Result<Value> {
46 self.require_workspace()?;
47 let issues = self.doctor_issues()?;
48 let repairable_count = issues.iter().filter(|issue| issue.repairable).count();
49 let error_count = issues
50 .iter()
51 .filter(|issue| issue.severity == "error")
52 .count();
53 Ok(json!({
54 "code": "doctor",
55 "ok": issues.is_empty(),
56 "issue_count": issues.len(),
57 "error_count": error_count,
58 "repairable_count": repairable_count,
59 "checks": {
60 "git_checked": false,
61 "messages": true,
62 "cases": true,
63 "archives": true,
64 "push_queue": true,
65 "templates": true,
66 "transactions": true,
67 },
68 "issues": issues,
69 }))
70 }
71
72 pub fn doctor_repair(&self, confirm: bool) -> Result<Value> {
73 self.require_workspace()?;
74 if !confirm {
75 return Err(AppError::new(
76 "confirm_required",
77 "doctor repair requires --confirm",
78 )
79 .with_hint("Inspect with `afmail doctor`; apply repairs with `afmail doctor repair --confirm`.")
80 .with_details(json!({
81 "suggested_commands": [
82 "afmail doctor",
83 "afmail doctor repair --confirm"
84 ]
85 })));
86 }
87 self.ensure_no_incomplete_transactions()?;
88 let before = self.doctor_issues()?;
89 let cache = self.rebuild_message_cache_from_eml()?;
90 for path in message_json_paths(&self.root)? {
91 if let Ok(message) = read_message(&path) {
92 self.persist_message_state(&message)?;
93 self.persist_message_remote(&message)?;
94 }
95 }
96 let rendered = self.render_refresh()?;
97 let after = self.doctor_issues()?;
98 Ok(json!({
99 "code": "doctor_repair",
100 "confirmed": true,
101 "before_issue_count": before.len(),
102 "after_issue_count": after.len(),
103 "message_cache_rebuilt_count": cache.rebuilt_count,
104 "text_cache_removed_count": cache.removed_text_cache_count,
105 "render": rendered,
106 "remaining_issues": after,
107 }))
108 }
109
110 fn doctor_issues(&self) -> Result<Vec<DoctorIssue>> {
111 let mut issues = Vec::new();
112 self.check_transactions(&mut issues)?;
113 self.check_messages(&mut issues)?;
114 self.check_case_refs(&mut issues)?;
115 self.check_archive_refs(&mut issues)?;
116 self.check_push_overlay(&mut issues)?;
117 self.check_templates(&mut issues)?;
118 Ok(issues)
119 }
120
121 fn check_transactions(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
122 for transaction in self.incomplete_transactions()? {
123 issues.push(DoctorIssue::error(
124 "transaction_incomplete",
125 format!(
126 "incomplete local transaction {} ({})",
127 transaction.transaction_id, transaction.kind
128 ),
129 Some(format!(
130 ".afmail/transactions/{}.json",
131 transaction.transaction_id
132 )),
133 ));
134 }
135 Ok(())
136 }
137
138 fn check_messages(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
139 let mut ids = BTreeSet::new();
140 for path in message_json_paths(&self.root)? {
141 let rel = rel_path(&self.root, &path);
142 let message = match read_message(&path) {
143 Ok(message) => message,
144 Err(err) => {
145 issues.push(DoctorIssue::error(
146 "message_cache_invalid",
147 err.message,
148 Some(rel),
149 ));
150 continue;
151 }
152 };
153 ids.insert(message.message_id.clone());
154 let eml = self
155 .root
156 .join(format!(".afmail/messages/{}.eml", message.message_id));
157 if !eml.is_file() {
158 issues.push(DoctorIssue::error(
159 "message_eml_missing",
160 format!("missing raw .eml for {}", message.message_id),
161 Some(rel_path(&self.root, &eml)),
162 ));
163 }
164 let state = self.root.join(format!(
165 ".afmail/messages/{}.state.json",
166 message.message_id
167 ));
168 if !state.is_file() {
169 issues.push(DoctorIssue::warning(
170 "message_state_missing",
171 format!("missing state sidecar for {}", message.message_id),
172 Some(rel_path(&self.root, &state)),
173 true,
174 ));
175 }
176 if message
177 .remote
178 .as_ref()
179 .is_some_and(|remote| !remote.locations.is_empty())
180 {
181 let remote = self.root.join(format!(
182 ".afmail/messages/{}.remote.json",
183 message.message_id
184 ));
185 if !remote.is_file() {
186 issues.push(DoctorIssue::warning(
187 "message_remote_missing",
188 format!("missing remote sidecar for {}", message.message_id),
189 Some(rel_path(&self.root, &remote)),
190 true,
191 ));
192 }
193 }
194 }
195 for entry in read_optional_dir(&self.root.join(".afmail/messages"), "read message state")? {
196 let path = entry.path();
197 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
198 continue;
199 };
200 let message_id = name
201 .strip_suffix(".state.json")
202 .or_else(|| name.strip_suffix(".remote.json"));
203 if let Some(message_id) = message_id {
204 if !ids.contains(message_id) && !self.message_path(message_id).is_file() {
205 issues.push(DoctorIssue::warning(
206 "message_sidecar_orphaned",
207 format!("sidecar has no materialized message cache: {message_id}"),
208 Some(rel_path(&self.root, &path)),
209 true,
210 ));
211 }
212 }
213 }
214 Ok(())
215 }
216
217 fn check_case_refs(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
218 for (case_uid, case_path) in self.all_case_entries()? {
219 let messages = read_case_messages(&case_messages_json_path(&case_path), &case_uid)?;
220 for message_id in messages.message_ids {
221 if !self.message_path(&message_id).is_file() {
222 let mut issue = DoctorIssue::error(
223 "case_message_ref_broken",
224 format!("case {case_uid} references missing message {message_id}"),
225 Some(rel_path(&self.root, &case_messages_json_path(&case_path))),
226 );
227 issue.refs = vec![case_uid.clone(), message_id];
228 issues.push(issue);
229 }
230 }
231 }
232 Ok(())
233 }
234
235 fn check_archive_refs(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
236 for archive_uid in self.archive_message_category_ids()? {
237 let archive = self.read_archive_messages(&archive_uid)?;
238 for item in archive.items {
239 if !self.message_path(&item.message_id).is_file() {
240 let mut issue = DoctorIssue::error(
241 "archive_message_ref_broken",
242 format!(
243 "archive {archive_uid} references missing message {}",
244 item.message_id
245 ),
246 Some(rel_path(
247 &self.root,
248 &self.archive_message_json_path(&archive_uid),
249 )),
250 );
251 issue.refs = vec![archive_uid.clone(), item.message_id];
252 issues.push(issue);
253 }
254 }
255 }
256 Ok(())
257 }
258
259 fn check_push_overlay(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
260 let items = crate::push_queue::pending_items(&self.root)?;
261 let mut pending_by_message: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
262 for item in items {
263 for message_id in item.message_ids() {
264 pending_by_message
265 .entry(message_id.clone())
266 .or_default()
267 .insert(item.push_id.clone());
268 if !self.message_path(message_id).is_file() {
269 issues.push(DoctorIssue::error(
270 "push_message_ref_broken",
271 format!(
272 "push {} references missing message {message_id}",
273 item.push_id
274 ),
275 Some(format!(".afmail/push/{}.json", item.push_id)),
276 ));
277 }
278 }
279 }
280 for path in message_json_paths(&self.root)? {
281 let Ok(message) = read_message(&path) else {
282 continue;
283 };
284 let expected = pending_by_message
285 .remove(&message.message_id)
286 .unwrap_or_default();
287 let actual = message
288 .workspace
289 .push
290 .as_ref()
291 .map(|push| {
292 push.pending
293 .iter()
294 .map(|pending| pending.push_id.clone())
295 .collect::<BTreeSet<_>>()
296 })
297 .unwrap_or_default();
298 if expected != actual && (!expected.is_empty() || !actual.is_empty()) {
299 issues.push(DoctorIssue::warning(
300 "push_overlay_drift",
301 format!(
302 "message {} push overlay differs from queue",
303 message.message_id
304 ),
305 Some(rel_path(&self.root, &path)),
306 true,
307 ));
308 }
309 }
310 Ok(())
311 }
312
313 fn check_templates(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
314 let language = self.template_language()?;
315 let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
316 for key in TemplateKey::ALL {
317 if let Err(err) = renderer.render(key, &minimal_template_context(language)) {
318 issues.push(DoctorIssue::error(
319 "template_render_failed",
320 err.message,
321 Some(key.as_str().to_string()),
322 ));
323 }
324 }
325 Ok(())
326 }
327}
328
329fn read_optional_dir(path: &Path, context: &str) -> Result<Vec<fs::DirEntry>> {
330 if !path.exists() {
331 return Ok(Vec::new());
332 }
333 read_dir(path, context)
334}
335
336fn minimal_template_context(language: TemplateLanguage) -> Value {
337 json!({
338 "language": language.as_str(),
339 "frontmatter": {},
340 "message_id": "message_doctor",
341 "case_uid": "c20260609001",
342 "case_name": "doctor",
343 "archive_uid": "a20260609001",
344 "archive_name": "doctor",
345 "title": "doctor",
346 "status": "active",
347 "message_count": 0,
348 "attachment_count": 0,
349 "generated_rfc3339": "2026-06-09T00:00:00Z",
350 "archived_rfc3339": "2026-06-09T00:00:00Z",
351 "summary": "doctor",
352 "conversation": "",
353 "items": [],
354 "messages": [],
355 "related_messages": [],
356 "suggested_case_uids": [],
357 "suggested_reason": "",
358 "suggested_reason_yaml": "",
359 "body_text_visible_block": "\n",
360 "body_text_fence": "```",
361 "display_heading": "doctor",
362 "from": "",
363 "subject": "",
364 "to": [],
365 "cc": [],
366 "bcc": [],
367 "security": {
368 "authentication": {
369 "check": false,
370 "has_results": false,
371 "spf": "missing",
372 "dkim": "missing",
373 "dmarc": "missing",
374 "dmarc_policy": null,
375 "authenticated_domain": null,
376 "from_domain": null,
377 "alignment": "unknown",
378 },
379 "possible_bcc": false,
380 "reply_to_differs": false,
381 "reply_to_recipients": "",
382 "sender_differs": false,
383 "sender": "",
384 "mailing_list": "",
385 "mailing_list_headers": "",
386 },
387 "hints": [],
388 "attachments": [],
389 "sender": "",
390 "quoted": "",
391 "config": {
392 "archive": {
393 "message_index": {
394 "item_fields": [],
395 },
396 },
397 },
398 "message": {
399 "schema_name": "message",
400 "schema_version": 1,
401 "message_id": "message_doctor",
402 "workspace": {"status": "triage"},
403 },
404 "case": {},
405 })
406}