contract-cli 0.2.1

Beautiful contracts from the CLI — NDA, NCNDA, consulting, MSA, SOW, service, loan. Plain English, 1-3 pages, agent-friendly.
Documentation
use std::process::Command;

use crate::config;
use crate::error::{AppError, Result};
use crate::output::{print_success, Ctx};
use crate::typst_assets;

#[derive(serde::Serialize)]
pub struct DoctorReport {
    checks: Vec<Check>,
    summary: Summary,
}

#[derive(serde::Serialize)]
pub struct Check {
    name: String,
    status: &'static str,
    message: String,
}

#[derive(serde::Serialize)]
pub struct Summary {
    pass: usize,
    warn: usize,
    fail: usize,
}

pub fn run(ctx: Ctx) -> Result<()> {
    let mut checks = Vec::new();

    match Command::new("typst").arg("--version").output() {
        Ok(o) if o.status.success() => {
            let v = String::from_utf8_lossy(&o.stdout).trim().to_string();
            checks.push(Check {
                name: "typst".into(),
                status: "pass",
                message: v,
            });
        }
        _ => checks.push(Check {
            name: "typst".into(),
            status: "fail",
            message: "typst not on PATH. Install: brew install typst".into(),
        }),
    }

    let cfg_path = config::config_path()?;
    checks.push(Check {
        name: "config".into(),
        status: "pass",
        message: format!("{} (exists: {})", cfg_path.display(), cfg_path.exists()),
    });

    let state = config::state_path()?;
    checks.push(Check {
        name: "state-dir".into(),
        status: "pass",
        message: format!("{} (exists: {})", state.display(), state.exists()),
    });

    typst_assets::ensure_extracted()?;
    let templates = typst_assets::list_templates()?;
    checks.push(Check {
        name: "templates".into(),
        status: if templates.is_empty() { "fail" } else { "pass" },
        message: format!("{} available: {}", templates.len(), templates.join(", ")),
    });

    let packs = crate::clauses::list_packs();
    checks.push(Check {
        name: "packs".into(),
        status: if packs.is_empty() { "fail" } else { "pass" },
        message: format!(
            "{} packs: {}",
            packs.len(),
            packs
                .iter()
                .map(|(k, p)| format!("{k}/{p}"))
                .collect::<Vec<_>>()
                .join(", ")
        ),
    });

    match crate::db::open() {
        Ok(conn) => {
            checks.push(Check {
                name: "database".into(),
                status: "pass",
                message: format!("{} ok", config::db_path()?.display()),
            });
            match crate::db::issuer_list(&conn) {
                Ok(list) if list.is_empty() => checks.push(Check {
                    name: "issuers".into(),
                    status: "warn",
                    message: "no issuers configured (shared with invoice-cli). First run: contract issuer add <slug> --name X --address ...".into(),
                }),
                Ok(list) => {
                    let slugs: Vec<&str> = list.iter().map(|i| i.slug.as_str()).collect();
                    checks.push(Check {
                        name: "issuers".into(),
                        status: "pass",
                        message: format!("{} shared with invoice-cli: {}", list.len(), slugs.join(", ")),
                    });
                }
                Err(e) => checks.push(Check {
                    name: "issuers".into(),
                    status: "fail",
                    message: format!("{e}"),
                }),
            }
            match crate::db::client_list(&conn) {
                Ok(list) if list.is_empty() => checks.push(Check {
                    name: "clients".into(),
                    status: "warn",
                    message: "no clients configured (shared with invoice-cli). Add via: contract clients add <slug> ...".into(),
                }),
                Ok(list) => {
                    let slugs: Vec<&str> = list.iter().map(|c| c.slug.as_str()).collect();
                    let with_legal = list.iter().filter(|c| c.legal_name.is_some()).count();
                    let msg = if with_legal == list.len() {
                        format!("{} shared with invoice-cli, all with legal-name: {}", list.len(), slugs.join(", "))
                    } else {
                        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(", "))
                    };
                    checks.push(Check {
                        name: "clients".into(),
                        status: if with_legal == list.len() { "pass" } else { "warn" },
                        message: msg,
                    });
                }
                Err(e) => checks.push(Check {
                    name: "clients".into(),
                    status: "fail",
                    message: format!("{e}"),
                }),
            }
        }
        Err(e) => checks.push(Check {
            name: "database".into(),
            status: "fail",
            message: format!("{e}"),
        }),
    }

    let summary = Summary {
        pass: checks.iter().filter(|c| c.status == "pass").count(),
        warn: checks.iter().filter(|c| c.status == "warn").count(),
        fail: checks.iter().filter(|c| c.status == "fail").count(),
    };
    let has_fail = summary.fail > 0;
    let report = DoctorReport { checks, summary };
    print_success(ctx, &report, |r| {
        for c in &r.checks {
            let icon = match c.status {
                "pass" => "",
                "warn" => "!",
                "fail" => "",
                _ => "?",
            };
            eprintln!("  {} {:<14} {}", icon, c.name, c.message);
        }
        eprintln!(
            "\n{} passing, {} warnings, {} failing",
            r.summary.pass, r.summary.warn, r.summary.fail
        );
    });
    if has_fail {
        return Err(AppError::Config("doctor found issues".into()));
    }
    Ok(())
}