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!(
54 "{} (exists: {})",
55 cfg_path.display(),
56 cfg_path.exists()
57 ),
58 });
59
60 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 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 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}