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, 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() {
33 Ok(o) if o.status.success() => {
34 let v = String::from_utf8_lossy(&o.stdout).trim().to_string();
35 checks.push(Check {
36 name: "typst".into(),
37 status: "pass",
38 message: v,
39 });
40 }
41 _ => checks.push(Check {
42 name: "typst".into(),
43 status: "fail",
44 message: "typst binary not found on PATH. Install: brew install typst".into(),
45 }),
46 }
47
48 let cfg_path = config::config_path()?;
50 checks.push(Check {
51 name: "config".into(),
52 status: "pass",
53 message: format!("{} (exists: {})", cfg_path.display(), cfg_path.exists()),
54 });
55
56 let state = config::state_path()?;
58 checks.push(Check {
59 name: "state-dir".into(),
60 status: "pass",
61 message: format!("{} (exists: {})", state.display(), state.exists()),
62 });
63
64 typst_assets::ensure_extracted()?;
66 let templates = typst_assets::list_templates()?;
67 checks.push(Check {
68 name: "templates".into(),
69 status: if templates.is_empty() { "fail" } else { "pass" },
70 message: format!("{} available: {}", templates.len(), templates.join(", ")),
71 });
72
73 match crate::db::open() {
75 Ok(conn) => {
76 checks.push(Check {
77 name: "database".into(),
78 status: "pass",
79 message: format!("{} ok", config::db_path()?.display()),
80 });
81 add_suite_checks(&mut checks, &conn)?;
82 }
83 Err(e) => checks.push(Check {
84 name: "database".into(),
85 status: "fail",
86 message: format!("{e}"),
87 }),
88 }
89
90 let summary = Summary {
91 pass: checks.iter().filter(|c| c.status == "pass").count(),
92 warn: checks.iter().filter(|c| c.status == "warn").count(),
93 fail: checks.iter().filter(|c| c.status == "fail").count(),
94 };
95 let has_fail = summary.fail > 0;
96 let report = DoctorReport { checks, summary };
97
98 print_success(ctx, &report, |r| {
99 for c in &r.checks {
100 let icon = match c.status {
101 "pass" => "✓",
102 "warn" => "!",
103 "fail" => "✗",
104 _ => "?",
105 };
106 eprintln!(" {} {:<16} {}", icon, c.name, c.message);
107 }
108 eprintln!(
109 "\n{} passing, {} warnings, {} failing",
110 r.summary.pass, r.summary.warn, r.summary.fail
111 );
112 });
113
114 if has_fail {
115 return Err(AppError::Config("doctor found issues".into()));
116 }
117 Ok(())
118}
119
120fn add_suite_checks(checks: &mut Vec<Check>, conn: &rusqlite::Connection) -> Result<()> {
121 let issuers = crate::db::issuer_list(conn)?;
122 if issuers.is_empty() {
123 checks.push(Check {
124 name: "issuers".into(),
125 status: "warn",
126 message: "no issuers configured. First run: invoice issuer add <slug> --name ... --address ...".into(),
127 });
128 } else {
129 checks.push(Check {
130 name: "issuers".into(),
131 status: "pass",
132 message: format!("{} configured", issuers.len()),
133 });
134 }
135
136 let cfg = config::load()?;
137 match cfg.default_issuer.as_deref() {
138 Some(slug) => match crate::db::issuer_by_slug(conn, slug) {
139 Ok(_) => checks.push(Check {
140 name: "default-issuer".into(),
141 status: "pass",
142 message: format!("config.default_issuer = {slug}"),
143 }),
144 Err(_) => checks.push(Check {
145 name: "default-issuer".into(),
146 status: "fail",
147 message: format!(
148 "config.default_issuer points to missing issuer '{slug}'. Run: invoice config set default_issuer unset"
149 ),
150 }),
151 },
152 None if issuers.is_empty() => checks.push(Check {
153 name: "default-issuer".into(),
154 status: "warn",
155 message: "not set yet because no issuers exist".into(),
156 }),
157 None => checks.push(Check {
158 name: "default-issuer".into(),
159 status: "warn",
160 message: "not set. Agents must pass --as or use clients with default issuers.".into(),
161 }),
162 }
163
164 if issuers.len() > 1 {
165 use std::collections::BTreeMap;
166 let mut by_format: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
167 for issuer in &issuers {
168 by_format
169 .entry(issuer.number_format.as_str())
170 .or_default()
171 .push(issuer.slug.as_str());
172 }
173 let risky: Vec<String> = by_format
174 .into_iter()
175 .filter(|(format, slugs)| slugs.len() > 1 && !format.contains("{issuer}"))
176 .map(|(format, slugs)| format!("{} share '{}'", slugs.join(", "), format))
177 .collect();
178 if risky.is_empty() {
179 checks.push(Check {
180 name: "numbering".into(),
181 status: "pass",
182 message: "multi-company number formats are distinct or include {issuer}".into(),
183 });
184 } else {
185 checks.push(Check {
186 name: "numbering".into(),
187 status: "warn",
188 message: format!(
189 "{}. Invoice numbers are globally addressable; use --number-format '{{issuer}}-{{year}}-{{seq:04}}' for each issuer.",
190 risky.join("; ")
191 ),
192 });
193 }
194 }
195
196 Ok(())
197}