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 deprecated_state_removed_count = self.remove_deprecated_message_state_sidecars()?;
90 let cache = self.rebuild_message_cache_from_eml()?;
91 for path in message_json_paths(&self.root)? {
92 if let Ok(message) = read_message(&path) {
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 "deprecated_state_removed_count": deprecated_state_removed_count,
106 "render": rendered,
107 "remaining_issues": after,
108 }))
109 }
110
111 fn doctor_issues(&self) -> Result<Vec<DoctorIssue>> {
112 let mut issues = Vec::new();
113 self.check_transactions(&mut issues)?;
114 self.check_messages(&mut issues)?;
115 self.check_case_refs(&mut issues)?;
116 self.check_archive_refs(&mut issues)?;
117 self.check_push_overlay(&mut issues)?;
118 self.check_templates(&mut issues)?;
119 Ok(issues)
120 }
121
122 fn check_transactions(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
123 for transaction in self.incomplete_transactions()? {
124 issues.push(DoctorIssue::error(
125 "transaction_incomplete",
126 format!(
127 "incomplete local transaction {} ({})",
128 transaction.transaction_id, transaction.kind
129 ),
130 Some(format!(
131 ".afmail/transactions/{}.json",
132 transaction.transaction_id
133 )),
134 ));
135 }
136 Ok(())
137 }
138
139 fn check_messages(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
140 let mut ids = BTreeSet::new();
141 for path in message_json_paths(&self.root)? {
142 let rel = rel_path(&self.root, &path);
143 let message = match read_message(&path) {
144 Ok(message) => message,
145 Err(err) => {
146 issues.push(DoctorIssue::error(
147 "message_cache_invalid",
148 err.message,
149 Some(rel),
150 ));
151 continue;
152 }
153 };
154 ids.insert(message.message_id.clone());
155 let eml = self
156 .root
157 .join(format!(".afmail/messages/{}.eml", message.message_id));
158 if !eml.is_file() {
159 issues.push(DoctorIssue::error(
160 "message_eml_missing",
161 format!("missing raw .eml for {}", message.message_id),
162 Some(rel_path(&self.root, &eml)),
163 ));
164 }
165 if message
166 .remote
167 .as_ref()
168 .is_some_and(|remote| !remote.locations.is_empty())
169 {
170 let remote = self.root.join(format!(
171 ".afmail/messages/{}.remote.json",
172 message.message_id
173 ));
174 if !remote.is_file() {
175 issues.push(DoctorIssue::warning(
176 "message_remote_missing",
177 format!("missing remote sidecar for {}", message.message_id),
178 Some(rel_path(&self.root, &remote)),
179 true,
180 ));
181 }
182 }
183 }
184 for entry in read_optional_dir(&self.root.join(".afmail/messages"), "read message state")? {
185 let path = entry.path();
186 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
187 continue;
188 };
189 if name.ends_with(".state.json") {
190 issues.push(DoctorIssue::warning(
191 "message_state_deprecated",
192 format!("deprecated message state sidecar: {name}"),
193 Some(rel_path(&self.root, &path)),
194 true,
195 ));
196 continue;
197 }
198 if let Some(message_id) = name.strip_suffix(".remote.json") {
199 if !ids.contains(message_id) && !self.message_path(message_id).is_file() {
200 issues.push(DoctorIssue::warning(
201 "message_sidecar_orphaned",
202 format!("sidecar has no materialized message cache: {message_id}"),
203 Some(rel_path(&self.root, &path)),
204 true,
205 ));
206 }
207 }
208 }
209 Ok(())
210 }
211
212 fn remove_deprecated_message_state_sidecars(&self) -> Result<usize> {
213 let mut removed = 0usize;
214 for entry in read_optional_dir(&self.root.join(".afmail/messages"), "read message state")? {
215 let path = entry.path();
216 if path
217 .file_name()
218 .and_then(|name| name.to_str())
219 .is_some_and(|name| name.ends_with(".state.json"))
220 {
221 remove_file(&path)?;
222 removed += 1;
223 }
224 }
225 Ok(removed)
226 }
227
228 fn check_case_refs(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
229 for (case_uid, case_path) in self.all_case_entries()? {
230 let messages = read_case_messages(&case_path, &case_uid)?;
231 for message_id in messages.message_ids() {
232 if !self.message_path(&message_id).is_file() {
233 let mut issue = DoctorIssue::error(
234 "case_message_ref_broken",
235 format!("case {case_uid} references missing message {message_id}"),
236 Some(rel_path(&self.root, &case_json_path(&case_path))),
237 );
238 issue.refs = vec![case_uid.clone(), message_id];
239 issues.push(issue);
240 }
241 }
242 }
243 Ok(())
244 }
245
246 fn check_archive_refs(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
247 for archive_uid in self.archive_message_category_ids()? {
248 let archive = self.read_archive_messages(&archive_uid)?;
249 for item in archive.items {
250 if !self.message_path(&item.message_id).is_file() {
251 let mut issue = DoctorIssue::error(
252 "archive_message_ref_broken",
253 format!(
254 "archive {archive_uid} references missing message {}",
255 item.message_id
256 ),
257 Some(rel_path(
258 &self.root,
259 &self.archive_message_json_path(&archive_uid),
260 )),
261 );
262 issue.refs = vec![archive_uid.clone(), item.message_id];
263 issues.push(issue);
264 }
265 }
266 }
267 Ok(())
268 }
269
270 fn check_push_overlay(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
271 let items = crate::push_queue::pending_items(&self.root)?;
272 let mut pending_by_message: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
273 for item in items {
274 for message_id in item.message_ids() {
275 pending_by_message
276 .entry(message_id.clone())
277 .or_default()
278 .insert(item.push_id.clone());
279 if !self.message_path(message_id).is_file() {
280 issues.push(DoctorIssue::error(
281 "push_message_ref_broken",
282 format!(
283 "push {} references missing message {message_id}",
284 item.push_id
285 ),
286 Some(format!(".afmail/push/{}.json", item.push_id)),
287 ));
288 }
289 }
290 }
291 for path in message_json_paths(&self.root)? {
292 let Ok(message) = read_message(&path) else {
293 continue;
294 };
295 let expected = pending_by_message
296 .remove(&message.message_id)
297 .unwrap_or_default();
298 let actual = message
299 .workspace
300 .push
301 .as_ref()
302 .map(|push| {
303 push.pending
304 .iter()
305 .map(|pending| pending.push_id.clone())
306 .collect::<BTreeSet<_>>()
307 })
308 .unwrap_or_default();
309 if expected != actual && (!expected.is_empty() || !actual.is_empty()) {
310 issues.push(DoctorIssue::warning(
311 "push_overlay_drift",
312 format!(
313 "message {} push overlay differs from queue",
314 message.message_id
315 ),
316 Some(rel_path(&self.root, &path)),
317 true,
318 ));
319 }
320 }
321 Ok(())
322 }
323
324 fn check_templates(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
325 let legacy_templates = self.root.join(".afmail/templates");
326 if legacy_templates.exists() {
327 issues.push(DoctorIssue::warning(
328 "legacy_template_dir",
329 ".afmail/templates is obsolete; workspace templates now live under templates/",
330 Some(".afmail/templates".to_string()),
331 false,
332 ));
333 }
334 let language = self.template_language()?;
335 let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
336 for key in TemplateKey::ALL {
337 if let Err(err) = renderer.render(key, &minimal_template_context(language)) {
338 issues.push(DoctorIssue::error(
339 "template_render_failed",
340 err.message,
341 Some(key.as_str().to_string()),
342 ));
343 }
344 }
345 Ok(())
346 }
347}
348
349fn read_optional_dir(path: &Path, context: &str) -> Result<Vec<fs::DirEntry>> {
350 if !path.exists() {
351 return Ok(Vec::new());
352 }
353 read_dir(path, context)
354}
355
356fn minimal_template_context(language: TemplateLanguage) -> Value {
357 json!({
358 "items": [],
359 "messages": [],
360 "workspaces": ["."],
361 "frontmatter": {
362 "kind": "doctor",
363 "message_id": "message_doctor",
364 "message_ids": ["message_doctor"],
365 "case_uid": "c20260609001",
366 "case_name": "doctor",
367 "archive_uid": "a20260609001",
368 "archive_name": "doctor",
369 "status": "active",
370 "message_count": 0,
371 "attachment_count": 0,
372 "generated_rfc3339": "2026-06-09T00:00:00Z",
373 "added_rfc3339": "2026-06-09T00:00:00Z",
374 "suggested_case_uids": [],
375 "suggested_reason": "",
376 "suggested_reason_yaml": "",
377 },
378 "message": {
379 "schema_name": "message",
380 "schema_version": 1,
381 "message_id": "message_doctor",
382 "from": "",
383 "subject": "",
384 "to": [],
385 "cc": [],
386 "bcc": [],
387 "attachments": [],
388 "workspace": {"status": "triage"},
389 },
390 "view": {
391 "language": language.as_str(),
392 "title": "doctor",
393 "status": "active",
394 "status_label": "active",
395 "message_count": 0,
396 "attachment_count": 0,
397 "generated_rfc3339": "2026-06-09T00:00:00Z",
398 "added_rfc3339": "2026-06-09T00:00:00Z",
399 "summary": "doctor",
400 "conversation": "",
401 "related_messages": [],
402 "suggested_case_uids": [],
403 "suggested_reason": "",
404 "body_text_visible_block": "\n",
405 "body_text_fence": "```",
406 "display_heading": "doctor",
407 "security": {
408 "authentication": {
409 "check": false,
410 "has_results": false,
411 "spf": "missing",
412 "dkim": "missing",
413 "dmarc": "missing",
414 "dmarc_policy": null,
415 "authenticated_domain": null,
416 "from_domain": null,
417 "alignment": "unknown",
418 },
419 "possible_bcc": false,
420 "reply_to_differs": false,
421 "reply_to_recipients": "",
422 "sender_differs": false,
423 "sender": "",
424 "mailing_list": "",
425 "mailing_list_headers": "",
426 },
427 "hints": [],
428 "attachments": [],
429 },
430 "config": {
431 "archive": {
432 "message_index": {
433 "item_fields": [],
434 },
435 },
436 },
437 "archive": {
438 "archive_uid": "a20260609001",
439 "archive_name": "doctor",
440 },
441 "case": {
442 "case_uid": "c20260609001",
443 "case_name": "doctor",
444 "collection_uid": "c20260609001",
445 "collection_name": "doctor",
446 "status": "active",
447 },
448 "item": {},
449 })
450}