invoice_cli/commands/
doctor.rs1use 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(_) => 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}