contract-cli 0.1.0

Beautiful contracts from the CLI — NDAs, consulting agreements, MSAs, SOWs. Plain English, 1–3 pages, agent-friendly.
Documentation
use chrono::Utc;
use std::path::PathBuf;

use crate::cli::TemplateCmd;
use crate::clauses;
use crate::db::{Client, Contract, ContractClauseRow, Issuer};
use crate::error::Result;
use crate::output::{print_success, Ctx};
use crate::render;
use crate::tax::Jurisdiction;
use crate::typst_assets;

pub fn run(cmd: TemplateCmd, ctx: Ctx) -> Result<()> {
    match cmd {
        TemplateCmd::List => {
            let names = typst_assets::list_templates()?;
            print_success(ctx, &names, |ns| {
                if ns.is_empty() {
                    println!("(no templates)");
                } else {
                    for n in ns {
                        println!("  {n}");
                    }
                }
            });
            Ok(())
        }
        TemplateCmd::Preview { name, kind, out } => {
            let issuer = sample_issuer();
            let client = sample_client();
            let contract = sample_contract(&kind);
            let pack = clauses::load_pack(&kind, &contract.clause_pack)?;
            let clause_rows: Vec<ContractClauseRow> = pack
                .pack
                .default_clauses
                .iter()
                .enumerate()
                .map(|(i, slug)| ContractClauseRow {
                    id: 0,
                    contract_id: 0,
                    position: i as i64,
                    slug: slug.clone(),
                    heading: None,
                    body: None,
                })
                .collect();
            let mut data =
                render::build_render_data(&contract, &issuer, &client, &clause_rows, &pack, false, true)?;
            let out_path =
                PathBuf::from(out.unwrap_or_else(|| format!("preview-{name}-{kind}.pdf")));
            render::render_to_pdf(&name, &mut data, &issuer, &out_path)?;
            print_success(ctx, &out_path.display().to_string(), |p| {
                println!("preview → {p}");
            });
            Ok(())
        }
    }
}

fn sample_issuer() -> Issuer {
    Issuer {
        id: 0,
        slug: "acme".into(),
        name: "Acme Studio".into(),
        legal_name: Some("Acme Studio Pte. Ltd.".into()),
        jurisdiction: Jurisdiction::Sg,
        tax_registered: false,
        tax_id: None,
        company_no: Some("202312345A".into()),
        tagline: None,
        address: vec![
            "1 Marina Bay".into(),
            "Singapore 018989".into(),
        ],
        email: Some("hello@acme.example".into()),
        phone: None,
        bank_details: None,
        default_template: "helvetica-nera".into(),
        currency: None,
        symbol: None,
        number_format: "{issuer}-{year}-{seq:04}".into(),
        logo_path: None,
        default_output_dir: None,
        default_notes: None,
    }
}

fn sample_client() -> Client {
    Client {
        id: 0,
        slug: "meridian".into(),
        name: "Meridian & Co.".into(),
        legal_name: Some("Meridian & Co. Pty Ltd".into()),
        company_no: Some("ACN 600 123 456".into()),
        legal_jurisdiction: Some("Victoria, Australia".into()),
        attn: Some("Sophie Lin, Head of Marketing".into()),
        country: Some("AU".into()),
        tax_id: None,
        address: vec![
            "401 Collins Street".into(),
            "Melbourne VIC 3000".into(),
            "Australia".into(),
        ],
        email: Some("sophie@meridian.example".into()),
        notes: None,
        default_issuer_slug: None,
        default_template: None,
    }
}

fn sample_contract(kind: &str) -> Contract {
    let today = Utc::now().date_naive().format("%Y-%m-%d").to_string();
    let terms = match kind {
        "nda" => serde_json::json!({
            "mutuality": "mutual",
            "disclosing_side": "both",
            "purpose": "a potential collaboration on a longevity research project",
            "confidentiality_years": 3,
        }),
        "consulting" => serde_json::json!({
            "purpose": "the design and delivery of a customer-facing dashboard for the Client's flagship product",
            "deliverables": [
                "Discovery interviews and a one-page strategy memo",
                "Three rounds of high-fidelity Figma designs",
                "Production-ready front-end implementation in React",
                "One handover session with the Client's engineering team",
            ],
            "ip_assignment": "client",
            "confidentiality_years": 3,
            "termination_notice_days": 30,
        }),
        "msa" => serde_json::json!({
            "ip_assignment": "client",
            "confidentiality_years": 3,
            "termination_notice_days": 30,
        }),
        "sow" => serde_json::json!({
            "purpose": "Implementation of the customer dashboard described in the kick-off memo dated 2026-04-01.",
            "deliverables": [
                "Functional prototype by week 4",
                "Production deployment by week 10",
            ],
        }),
        "service" => serde_json::json!({
            "purpose": "managed hosting and monthly performance reviews for the Customer's web application",
            "deliverables": [
                "24/7 monitoring of production endpoints",
                "Monthly performance review and recommendations",
            ],
            "termination_notice_days": 60,
        }),
        _ => serde_json::json!({}),
    };
    let (fee_type, fee_minor, fee_cur, fee_sched) = match kind {
        "consulting" => (Some("fixed".into()), Some(84_000_00i64), Some("SGD".into()), Some("on-completion".into())),
        "msa" => (None, None, None, None),
        "sow" => (Some("fixed".into()), Some(34_000_00i64), Some("SGD".into()), Some("on-milestone".into())),
        "service" => (Some("retainer".into()), Some(5_000_00i64), Some("SGD".into()), Some("monthly".into())),
        _ => (None, None, None, None),
    };
    let title = match kind {
        "nda" => "Mutual NDA — Acme × Meridian".into(),
        "consulting" => "Customer Dashboard — Consulting Engagement".into(),
        "msa" => "Master Services Agreement — Acme × Meridian".into(),
        "sow" => "SOW #1 — Customer Dashboard Implementation".into(),
        "service" => "Managed Hosting & Performance Reviews".into(),
        _ => format!("Sample {kind} agreement"),
    };
    Contract {
        id: 0,
        number: format!("{}-acme-2026-0001", prefix_for(kind)),
        kind: kind.to_string(),
        issuer_id: 0,
        client_id: 0,
        title,
        effective_date: today,
        end_date: None,
        term_months: Some(12),
        governing_law: "Singapore".into(),
        venue: None,
        status: "draft".into(),
        sent_at: None,
        signed_at: None,
        terminated_at: None,
        notes: None,
        fee_type,
        fee_amount_minor: fee_minor,
        fee_currency: fee_cur,
        fee_schedule: fee_sched,
        terms_json: terms.to_string(),
        clause_pack: "standard".into(),
        clause_pack_version: "1.0".into(),
        default_template: None,
        signed_by_us_name: None,
        signed_by_us_title: None,
        signed_by_us_at: None,
        signed_by_them_name: None,
        signed_by_them_title: None,
        signed_by_them_at: None,
        created_at: String::new(),
        updated_at: String::new(),
    }
}

fn prefix_for(kind: &str) -> &'static str {
    match kind {
        "consulting" => "CTR",
        "nda" => "NDA",
        "msa" => "MSA",
        "sow" => "SOW",
        "service" => "SVC",
        _ => "DOC",
    }
}