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
74    match crate::db::open() {
75        Ok(_) => checks.push(Check {
76            name: "database".into(),
77            status: "pass",
78            message: format!("{} ok", config::db_path()?.display()),
79        }),
80        Err(e) => checks.push(Check {
81            name: "database".into(),
82            status: "fail",
83            message: format!("{e}"),
84        }),
85    }
86
87    let summary = Summary {
88        pass: checks.iter().filter(|c| c.status == "pass").count(),
89        warn: checks.iter().filter(|c| c.status == "warn").count(),
90        fail: checks.iter().filter(|c| c.status == "fail").count(),
91    };
92    let has_fail = summary.fail > 0;
93    let report = DoctorReport { checks, summary };
94
95    print_success(ctx, &report, |r| {
96        for c in &r.checks {
97            let icon = match c.status {
98                "pass" => "✓",
99                "warn" => "!",
100                "fail" => "✗",
101                _ => "?",
102            };
103            eprintln!("  {} {:<16}  {}", icon, c.name, c.message);
104        }
105        eprintln!(
106            "\n{} passing, {} warnings, {} failing",
107            r.summary.pass, r.summary.warn, r.summary.fail
108        );
109    });
110
111    if has_fail {
112        return Err(AppError::Config("doctor found issues".into()));
113    }
114    Ok(())
115}