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!(
54            "{} (exists: {})",
55            cfg_path.display(),
56            cfg_path.exists()
57        ),
58    });
59
60    // state dir
61    let state = config::state_path()?;
62    checks.push(Check {
63        name: "state-dir".into(),
64        status: "pass",
65        message: format!("{} (exists: {})", state.display(), state.exists()),
66    });
67
68    // templates extracted
69    typst_assets::ensure_extracted()?;
70    let templates = typst_assets::list_templates()?;
71    checks.push(Check {
72        name: "templates".into(),
73        status: if templates.is_empty() { "fail" } else { "pass" },
74        message: format!("{} available: {}", templates.len(), templates.join(", ")),
75    });
76
77    // db
78    match crate::db::open() {
79        Ok(_) => checks.push(Check {
80            name: "database".into(),
81            status: "pass",
82            message: format!("{} ok", config::db_path()?.display()),
83        }),
84        Err(e) => checks.push(Check {
85            name: "database".into(),
86            status: "fail",
87            message: format!("{e}"),
88        }),
89    }
90
91    let summary = Summary {
92        pass: checks.iter().filter(|c| c.status == "pass").count(),
93        warn: checks.iter().filter(|c| c.status == "warn").count(),
94        fail: checks.iter().filter(|c| c.status == "fail").count(),
95    };
96    let has_fail = summary.fail > 0;
97    let report = DoctorReport { checks, summary };
98
99    print_success(ctx, &report, |r| {
100        for c in &r.checks {
101            let icon = match c.status {
102                "pass" => "✓",
103                "warn" => "!",
104                "fail" => "✗",
105                _ => "?",
106            };
107            eprintln!("  {} {:<16}  {}", icon, c.name, c.message);
108        }
109        eprintln!(
110            "\n{} passing, {} warnings, {} failing",
111            r.summary.pass, r.summary.warn, r.summary.fail
112        );
113    });
114
115    if has_fail {
116        return Err(AppError::Config("doctor found issues".into()));
117    }
118    Ok(())
119}