Skip to main content

contract_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,
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    match Command::new("typst").arg("--version").output() {
32        Ok(o) if o.status.success() => {
33            let v = String::from_utf8_lossy(&o.stdout).trim().to_string();
34            checks.push(Check {
35                name: "typst".into(),
36                status: "pass",
37                message: v,
38            });
39        }
40        _ => checks.push(Check {
41            name: "typst".into(),
42            status: "fail",
43            message: "typst not on PATH. Install: brew install typst".into(),
44        }),
45    }
46
47    let cfg_path = config::config_path()?;
48    checks.push(Check {
49        name: "config".into(),
50        status: "pass",
51        message: format!("{} (exists: {})", cfg_path.display(), cfg_path.exists()),
52    });
53
54    let state = config::state_path()?;
55    checks.push(Check {
56        name: "state-dir".into(),
57        status: "pass",
58        message: format!("{} (exists: {})", state.display(), state.exists()),
59    });
60
61    typst_assets::ensure_extracted()?;
62    let templates = typst_assets::list_templates()?;
63    checks.push(Check {
64        name: "templates".into(),
65        status: if templates.is_empty() { "fail" } else { "pass" },
66        message: format!("{} available: {}", templates.len(), templates.join(", ")),
67    });
68
69    let packs = crate::clauses::list_packs();
70    checks.push(Check {
71        name: "packs".into(),
72        status: if packs.is_empty() { "fail" } else { "pass" },
73        message: format!(
74            "{} packs: {}",
75            packs.len(),
76            packs
77                .iter()
78                .map(|(k, p)| format!("{k}/{p}"))
79                .collect::<Vec<_>>()
80                .join(", ")
81        ),
82    });
83
84    match crate::db::open() {
85        Ok(conn) => {
86            checks.push(Check {
87                name: "database".into(),
88                status: "pass",
89                message: format!("{} ok", config::db_path()?.display()),
90            });
91            match crate::db::issuer_list(&conn) {
92                Ok(list) if list.is_empty() => checks.push(Check {
93                    name: "issuers".into(),
94                    status: "warn",
95                    message: "no issuers configured (shared with invoice-cli). First run: contract issuer add <slug> --name X --address ...".into(),
96                }),
97                Ok(list) => {
98                    let slugs: Vec<&str> = list.iter().map(|i| i.slug.as_str()).collect();
99                    checks.push(Check {
100                        name: "issuers".into(),
101                        status: "pass",
102                        message: format!("{} shared with invoice-cli: {}", list.len(), slugs.join(", ")),
103                    });
104                }
105                Err(e) => checks.push(Check {
106                    name: "issuers".into(),
107                    status: "fail",
108                    message: format!("{e}"),
109                }),
110            }
111            match crate::db::client_list(&conn) {
112                Ok(list) if list.is_empty() => checks.push(Check {
113                    name: "clients".into(),
114                    status: "warn",
115                    message: "no clients configured (shared with invoice-cli). Add via: contract clients add <slug> ...".into(),
116                }),
117                Ok(list) => {
118                    let slugs: Vec<&str> = list.iter().map(|c| c.slug.as_str()).collect();
119                    let with_legal = list.iter().filter(|c| c.legal_name.is_some()).count();
120                    let msg = if with_legal == list.len() {
121                        format!("{} shared with invoice-cli, all with legal-name: {}", list.len(), slugs.join(", "))
122                    } else {
123                        format!("{} shared with invoice-cli ({} missing legal-name — fix via `contract clients edit <slug> --legal-name X --company-no Y --jurisdiction Z`): {}", list.len(), list.len() - with_legal, slugs.join(", "))
124                    };
125                    checks.push(Check {
126                        name: "clients".into(),
127                        status: if with_legal == list.len() { "pass" } else { "warn" },
128                        message: msg,
129                    });
130                }
131                Err(e) => checks.push(Check {
132                    name: "clients".into(),
133                    status: "fail",
134                    message: format!("{e}"),
135                }),
136            }
137        }
138        Err(e) => checks.push(Check {
139            name: "database".into(),
140            status: "fail",
141            message: format!("{e}"),
142        }),
143    }
144
145    let summary = Summary {
146        pass: checks.iter().filter(|c| c.status == "pass").count(),
147        warn: checks.iter().filter(|c| c.status == "warn").count(),
148        fail: checks.iter().filter(|c| c.status == "fail").count(),
149    };
150    let has_fail = summary.fail > 0;
151    let report = DoctorReport { checks, summary };
152    print_success(ctx, &report, |r| {
153        for c in &r.checks {
154            let icon = match c.status {
155                "pass" => "✓",
156                "warn" => "!",
157                "fail" => "✗",
158                _ => "?",
159            };
160            eprintln!("  {} {:<14} {}", icon, c.name, c.message);
161        }
162        eprintln!(
163            "\n{} passing, {} warnings, {} failing",
164            r.summary.pass, r.summary.warn, r.summary.fail
165        );
166    });
167    if has_fail {
168        return Err(AppError::Config("doctor found issues".into()));
169    }
170    Ok(())
171}