1use std::process::Command;
2
3use crate::config;
4use crate::error::{AppError, Result};
5use crate::output::{print_success, Ctx};
6use crate::typst_assets;
7
8#[derive(serde::Serialize)]
9pub struct DoctorReport {
10 checks: Vec<Check>,
11 summary: Summary,
12}
13
14#[derive(serde::Serialize)]
15pub struct Check {
16 name: String,
17 status: &'static str,
18 message: String,
19}
20
21#[derive(serde::Serialize)]
22pub struct Summary {
23 pass: usize,
24 warn: usize,
25 fail: usize,
26}
27
28pub fn run(ctx: Ctx) -> Result<()> {
29 let mut checks = Vec::new();
30
31 match Command::new("typst").arg("--version").output() {
32 Ok(o) if o.status.success() => {
33 let v = String::from_utf8_lossy(&o.stdout).trim().to_string();
34 checks.push(Check {
35 name: "typst".into(),
36 status: "pass",
37 message: v,
38 });
39 }
40 _ => checks.push(Check {
41 name: "typst".into(),
42 status: "fail",
43 message: "typst not on PATH. Install: brew install typst".into(),
44 }),
45 }
46
47 let cfg_path = config::config_path()?;
48 checks.push(Check {
49 name: "config".into(),
50 status: "pass",
51 message: format!("{} (exists: {})", cfg_path.display(), cfg_path.exists()),
52 });
53
54 let state = config::state_path()?;
55 checks.push(Check {
56 name: "state-dir".into(),
57 status: "pass",
58 message: format!("{} (exists: {})", state.display(), state.exists()),
59 });
60
61 typst_assets::ensure_extracted()?;
62 let templates = typst_assets::list_templates()?;
63 checks.push(Check {
64 name: "templates".into(),
65 status: if templates.is_empty() { "fail" } else { "pass" },
66 message: format!("{} available: {}", templates.len(), templates.join(", ")),
67 });
68
69 let packs = crate::clauses::list_packs();
70 checks.push(Check {
71 name: "packs".into(),
72 status: if packs.is_empty() { "fail" } else { "pass" },
73 message: format!(
74 "{} packs: {}",
75 packs.len(),
76 packs
77 .iter()
78 .map(|(k, p)| format!("{k}/{p}"))
79 .collect::<Vec<_>>()
80 .join(", ")
81 ),
82 });
83
84 match crate::db::open() {
85 Ok(conn) => {
86 checks.push(Check {
87 name: "database".into(),
88 status: "pass",
89 message: format!("{} ok", config::db_path()?.display()),
90 });
91 match crate::db::issuer_list(&conn) {
92 Ok(list) if list.is_empty() => checks.push(Check {
93 name: "issuers".into(),
94 status: "warn",
95 message: "no issuers configured (shared with invoice-cli). First run: contract issuer add <slug> --name X --address ...".into(),
96 }),
97 Ok(list) => {
98 let slugs: Vec<&str> = list.iter().map(|i| i.slug.as_str()).collect();
99 checks.push(Check {
100 name: "issuers".into(),
101 status: "pass",
102 message: format!("{} shared with invoice-cli: {}", list.len(), slugs.join(", ")),
103 });
104 }
105 Err(e) => checks.push(Check {
106 name: "issuers".into(),
107 status: "fail",
108 message: format!("{e}"),
109 }),
110 }
111 match crate::db::client_list(&conn) {
112 Ok(list) if list.is_empty() => checks.push(Check {
113 name: "clients".into(),
114 status: "warn",
115 message: "no clients configured (shared with invoice-cli). Add via: contract clients add <slug> ...".into(),
116 }),
117 Ok(list) => {
118 let slugs: Vec<&str> = list.iter().map(|c| c.slug.as_str()).collect();
119 let with_legal = list.iter().filter(|c| c.legal_name.is_some()).count();
120 let msg = if with_legal == list.len() {
121 format!("{} shared with invoice-cli, all with legal-name: {}", list.len(), slugs.join(", "))
122 } else {
123 format!("{} shared with invoice-cli ({} missing legal-name — fix via `contract clients edit <slug> --legal-name X --company-no Y --jurisdiction Z`): {}", list.len(), list.len() - with_legal, slugs.join(", "))
124 };
125 checks.push(Check {
126 name: "clients".into(),
127 status: if with_legal == list.len() { "pass" } else { "warn" },
128 message: msg,
129 });
130 }
131 Err(e) => checks.push(Check {
132 name: "clients".into(),
133 status: "fail",
134 message: format!("{e}"),
135 }),
136 }
137 }
138 Err(e) => checks.push(Check {
139 name: "database".into(),
140 status: "fail",
141 message: format!("{e}"),
142 }),
143 }
144
145 let summary = Summary {
146 pass: checks.iter().filter(|c| c.status == "pass").count(),
147 warn: checks.iter().filter(|c| c.status == "warn").count(),
148 fail: checks.iter().filter(|c| c.status == "fail").count(),
149 };
150 let has_fail = summary.fail > 0;
151 let report = DoctorReport { checks, summary };
152 print_success(ctx, &report, |r| {
153 for c in &r.checks {
154 let icon = match c.status {
155 "pass" => "✓",
156 "warn" => "!",
157 "fail" => "✗",
158 _ => "?",
159 };
160 eprintln!(" {} {:<14} {}", icon, c.name, c.message);
161 }
162 eprintln!(
163 "\n{} passing, {} warnings, {} failing",
164 r.summary.pass, r.summary.warn, r.summary.fail
165 );
166 });
167 if has_fail {
168 return Err(AppError::Config("doctor found issues".into()));
169 }
170 Ok(())
171}