Skip to main content

invoice_cli/commands/
doctor.rs

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, // "pass" | "warn" | "fail"
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    // typst binary
32    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    // config dir
49    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    // state dir
57    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    // templates extracted
65    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    // db + suite-level invariants
74    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}