Skip to main content

contract_cli/
render.rs

1// ═══════════════════════════════════════════════════════════════════════════
2// Render — build a ContractRenderData JSON sidecar, write it next to the
3// templates in a temp directory, and shell out to `typst compile`.
4//
5// Designed to be pure: callers pre-load the Contract row + clauses + parties
6// and pass them in. No DB access here.
7// ═══════════════════════════════════════════════════════════════════════════
8
9use std::collections::BTreeMap;
10use std::path::Path;
11use std::process::Command;
12
13use chrono::NaiveDate;
14use serde::Serialize;
15
16use crate::clauses::{self, Pack};
17use crate::db::{Client, Contract, ContractClauseRow, Issuer};
18use crate::error::{AppError, Result};
19use crate::typst_assets;
20
21// ─── Wire format ─────────────────────────────────────────────────────────
22
23#[derive(Debug, Serialize)]
24#[serde(rename_all = "kebab-case")]
25pub struct ContractRenderData {
26    pub kind: String,
27    /// Formal document type — the title of the page (e.g. "Consulting
28    /// Services Agreement", "Mutual Non-Disclosure Agreement"). This is
29    /// what real contracts put at the top, not the kind tag from the CLI.
30    pub kind_label: String,
31    /// Internal reference code (CTR-..., NDA-...). Rendered only in the
32    /// page footer at small size for filing purposes — never on the body.
33    pub number: String,
34    /// User-supplied contract title (e.g. project name). Rendered as a
35    /// subtitle UNDER the formal kind-label *only* when the user gave a
36    /// meaningful title (i.e. not the auto-generated "Kind — A × B").
37    /// `None` suppresses the subtitle entirely.
38    pub subtitle: Option<String>,
39    /// Effective date in display format ("24 May 2026"). Used in the
40    /// "DATED" line above the parties section.
41    pub effective_date_display: String,
42    pub end_date_display: Option<String>,
43    pub term_text: String,
44    /// Compact term phrase for the key-terms strip ("2 months", "until 1 Jul 2026").
45    pub term_short: String,
46    pub governing_law: String,
47    pub jurisdiction_phrase: String,
48    pub venue: Option<String>,
49    pub status: String,
50    pub draft_watermark: bool,
51    pub fee_text: Option<String>,
52    /// Compact fee phrase for the key-terms strip ("£1,500 fixed").
53    pub fee_short: Option<String>,
54    pub our_party: PartyData,
55    pub their_party: PartyData,
56    /// Parties as numbered prose lines, the traditional UK contract format:
57    ///   "**BORIS DJORDJEVIC** of 199 Gloucester Terrace, London W2 6LD,
58    ///    United Kingdom (the \"Consultant\")"
59    /// Templates render them as a numbered list "(1) … ; and  (2) ….".
60    pub parties_prose: Vec<String>,
61    pub clauses: Vec<ClauseRenderData>,
62    pub signature: SignatureBlock,
63    pub logo: Option<String>,
64    pub internal_notes: Option<String>,
65}
66
67#[derive(Debug, Serialize)]
68#[serde(rename_all = "kebab-case")]
69pub struct PartyData {
70    pub role_label: String,
71    pub display_name: String,
72    pub legal_name: Option<String>,
73    pub company_no: Option<String>,
74    pub address: Vec<String>,
75    pub jurisdiction: Option<String>,
76    pub email: Option<String>,
77    pub attn: Option<String>,
78}
79
80#[derive(Debug, Serialize)]
81#[serde(rename_all = "kebab-case")]
82pub struct ClauseRenderData {
83    pub number: i64,
84    pub slug: String,
85    pub heading: String,
86    pub body: String,
87}
88
89#[derive(Debug, Serialize)]
90#[serde(rename_all = "kebab-case")]
91pub struct SignatureBlock {
92    pub our_label: String,
93    pub our_name: String,
94    pub our_signer_name: Option<String>,
95    pub our_signer_title: Option<String>,
96    pub our_signer_date: Option<String>,
97    pub their_label: String,
98    pub their_name: String,
99    pub their_signer_name: Option<String>,
100    pub their_signer_title: Option<String>,
101    pub their_signer_date: Option<String>,
102}
103
104// ─── Helpers ─────────────────────────────────────────────────────────────
105
106fn fmt_date(iso: &str) -> String {
107    NaiveDate::parse_from_str(iso, "%Y-%m-%d")
108        .map(|d| d.format("%-d %B %Y").to_string())
109        .unwrap_or_else(|_| iso.to_string())
110}
111
112fn kind_label(kind: &str, terms: &serde_json::Value) -> String {
113    match kind {
114        "consulting" => "Consulting Services Agreement".into(),
115        "nda" => {
116            if terms.get("mutuality").and_then(|v| v.as_str()) == Some("unilateral") {
117                "Non-Disclosure Agreement".into()
118            } else {
119                "Mutual Non-Disclosure Agreement".into()
120            }
121        }
122        "ncnda" => "Non-Circumvention & Non-Disclosure Agreement".into(),
123        "loan" => "Loan Agreement".into(),
124        "msa" => "Master Services Agreement".into(),
125        "sow" => "Statement of Work".into(),
126        "service" => "Service Agreement".into(),
127        other => format!("{} Agreement", capitalize(other)),
128    }
129}
130
131fn capitalize(s: &str) -> String {
132    let mut chars = s.chars();
133    chars.next().map(|c| c.to_uppercase().collect::<String>() + chars.as_str())
134        .unwrap_or_default()
135}
136
137fn party_role_labels(kind: &str, terms: &serde_json::Value) -> (String, String) {
138    match kind {
139        "nda" => {
140            let mutuality = terms.get("mutuality").and_then(|v| v.as_str()).unwrap_or("mutual");
141            if mutuality == "mutual" {
142                ("Party A".into(), "Party B".into())
143            } else {
144                let disclosing = terms.get("disclosing_side").and_then(|v| v.as_str()).unwrap_or("us");
145                if disclosing == "us" {
146                    ("Disclosing Party".into(), "Receiving Party".into())
147                } else {
148                    ("Receiving Party".into(), "Disclosing Party".into())
149                }
150            }
151        }
152        "ncnda" => ("Party A".into(), "Party B".into()),
153        _ => crate::kinds::get(kind)
154            .map(|k| (k.roles.0.to_string(), k.roles.1.to_string()))
155            .unwrap_or_else(|| ("Party A".into(), "Party B".into())),
156    }
157}
158
159fn jurisdiction_phrase(law: &str) -> String {
160    // Best-effort heuristic. Falls back to "the courts of <law>".
161    let l = law.trim();
162    let lower = l.to_lowercase();
163    if lower.contains("singapore") {
164        "the courts of Singapore".into()
165    } else if lower.contains("england") || lower.contains("wales") || lower.contains("united kingdom") || lower == "uk" {
166        "the courts of England and Wales".into()
167    } else if lower.contains("delaware") {
168        "the state and federal courts located in Delaware".into()
169    } else if lower.contains("new york") {
170        "the state and federal courts of New York".into()
171    } else if lower.contains("hong kong") {
172        "the courts of the Hong Kong Special Administrative Region".into()
173    } else if lower.contains("germany") {
174        "the courts of Frankfurt am Main, Germany".into()
175    } else if lower.contains("france") {
176        "the courts of Paris, France".into()
177    } else {
178        format!("the courts of {}", l)
179    }
180}
181
182fn term_text(c: &Contract) -> String {
183    if let Some(end) = &c.end_date {
184        format!("until {}", fmt_date(end))
185    } else if let Some(m) = c.term_months {
186        if m % 12 == 0 {
187            let years = m / 12;
188            if years == 1 {
189                "for one year from the Effective Date".into()
190            } else {
191                format!("for {} years from the Effective Date", years)
192            }
193        } else if m == 1 {
194            "for one month from the Effective Date".into()
195        } else {
196            format!("for {} months from the Effective Date", m)
197        }
198    } else {
199        "indefinitely until terminated as provided below".into()
200    }
201}
202
203fn term_short(c: &Contract) -> String {
204    if let Some(end) = &c.end_date {
205        // Compact date — use "%-d %b %Y" so "1 Jul 2026" not "1 July 2026"
206        NaiveDate::parse_from_str(end, "%Y-%m-%d")
207            .map(|d| format!("until {}", d.format("%-d %b %Y")))
208            .unwrap_or_else(|_| format!("until {end}"))
209    } else if let Some(m) = c.term_months {
210        if m % 12 == 0 {
211            let y = m / 12;
212            if y == 1 { "1 year".into() } else { format!("{y} years") }
213        } else if m == 1 {
214            "1 month".into()
215        } else {
216            format!("{m} months")
217        }
218    } else {
219        "Indefinite".into()
220    }
221}
222
223fn fee_short(c: &Contract) -> Option<String> {
224    let kind = c.fee_type.as_deref()?;
225    let amt = c.fee_amount_minor?;
226    let cur = c.fee_currency.clone().unwrap_or_default();
227    let symbol = finance_core::money::currency_symbol(&cur);
228    let amt_str = finance_core::money::MinorUnits(amt).format_number();
229    let amt_display = if symbol.is_empty() {
230        format!("{} {}", amt_str, cur)
231    } else {
232        format!("{}{}", symbol, amt_str)
233    };
234    Some(match kind {
235        "fixed" => format!("{amt_display} fixed"),
236        "hourly" => format!("{amt_display}/hr"),
237        "daily" => format!("{amt_display}/day"),
238        "retainer" => format!("{amt_display}/month"),
239        _ => amt_display,
240    })
241}
242
243fn ip_assignment_text(terms: &serde_json::Value) -> String {
244    let mode = terms.get("ip_assignment").and_then(|v| v.as_str()).unwrap_or("client");
245    match mode {
246        "client" => "All deliverables produced specifically for the Client under this engagement (the “Deliverables”) belong to the Client. The Consultant assigns to the Client, on payment of the relevant fees, all right, title, and interest in the Deliverables.".into(),
247        "consultant" | "provider" => "The Consultant retains ownership of all deliverables. The Client receives a non-exclusive, perpetual, worldwide, royalty-free licence to use them for its internal business purposes.".into(),
248        "shared" => "The parties jointly own the deliverables. Each party may use them for any purpose without accounting to the other.".into(),
249        _ => "All deliverables produced specifically for the Client under this engagement belong to the Client, on payment of the relevant fees.".into(),
250    }
251}
252
253fn fee_text(c: &Contract) -> Option<String> {
254    let kind = c.fee_type.as_deref()?;
255    let amt = c.fee_amount_minor?;
256    let cur = c.fee_currency.clone().unwrap_or_default();
257    let symbol = finance_core::money::currency_symbol(&cur);
258    let amt_str = finance_core::money::MinorUnits(amt).format_number();
259    let amt_display = if symbol.is_empty() {
260        format!("{} {}", amt_str, cur)
261    } else {
262        format!("{}{}", symbol, amt_str)
263    };
264    let sched_phrase = match c.fee_schedule.as_deref() {
265        Some("on-completion") => ", payable on completion of the Services",
266        Some("on-milestone") => ", payable on completion of each milestone",
267        Some("monthly") => ", invoiced monthly in arrears",
268        Some("upon-invoice") => ", invoiced as work is performed",
269        _ => "",
270    };
271    Some(match kind {
272        "fixed" => format!("a fixed fee of {amt_display}{sched_phrase}"),
273        "hourly" => format!("an hourly rate of {amt_display}/hour{sched_phrase}"),
274        "daily" => format!("a daily rate of {amt_display}/day{sched_phrase}"),
275        "retainer" => format!("a monthly retainer of {amt_display}{sched_phrase}"),
276        _ => format!("{amt_display}{sched_phrase}"),
277    })
278}
279
280fn deliverables_block(terms: &serde_json::Value) -> String {
281    let items: Vec<String> = terms
282        .get("deliverables")
283        .and_then(|v| v.as_array())
284        .map(|arr| {
285            arr.iter()
286                .filter_map(|x| x.as_str().map(str::to_string))
287                .collect()
288        })
289        .unwrap_or_default();
290    if items.is_empty() {
291        "To be agreed in writing by the parties.".into()
292    } else {
293        items
294            .into_iter()
295            .map(|s| format!("- {s}"))
296            .collect::<Vec<_>>()
297            .join("\n")
298    }
299}
300
301/// Escape user-controlled text for a Typst markup context. The parties prose
302/// is eval'd as markup so the bold `*NAME*` markers work; every character a
303/// counterparty name/address could use to smuggle markup in must be escaped.
304fn esc_markup(s: &str) -> String {
305    let mut out = String::with_capacity(s.len());
306    for c in s.chars() {
307        // Line separators would let a field start a fresh markup line where
308        // `=` (heading), `+`/`-` (lists) become structural — flatten them.
309        if matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') {
310            out.push(' ');
311            continue;
312        }
313        if matches!(
314            c,
315            '\\' | '#' | '*' | '_' | '`' | '$' | '<' | '>' | '@' | '[' | ']' | '~' | '/' | '-'
316                | '=' | '+'
317        ) {
318            out.push('\\');
319        }
320        out.push(c);
321    }
322    out
323}
324
325/// Generate the traditional "(N) <NAME> of <address>, <qualifier> (the "Role")"
326/// prose used in UK/Commonwealth contracts. Companies get an incorporation
327/// qualifier ("a company incorporated in England and Wales, Co. No. 12345678");
328/// individuals just have name + address. The role label is the contract-side
329/// label (Consultant / Client / Provider / Party A / etc).
330fn party_intro_prose(p: &PartyData) -> String {
331    let legal = p.legal_name.clone().unwrap_or_else(|| p.display_name.clone());
332    let name_upper = esc_markup(&legal.to_uppercase());
333    let addr = esc_markup(&p.address.join(", "));
334    let looks_like_company = looks_like_company_name(&legal);
335    let mut qualifier = String::new();
336    if let Some(j) = &p.jurisdiction {
337        if looks_like_company {
338            qualifier.push_str(&format!(", a company incorporated in {}", esc_markup(j)));
339            if let Some(co) = &p.company_no {
340                qualifier.push_str(&format!(" (Co. No. {})", esc_markup(co)));
341            }
342        }
343    } else if let Some(co) = &p.company_no {
344        // Jurisdiction missing but company number known.
345        qualifier.push_str(&format!(", company no. {}", esc_markup(co)));
346    }
347    // Use Typst's single-asterisk bold syntax so the name reads as bold.
348    format!(
349        "*{name_upper}* of {addr}{qualifier} (the \"{role}\")",
350        role = esc_markup(&p.role_label)
351    )
352}
353
354fn looks_like_company_name(s: &str) -> bool {
355    let lower = s.to_lowercase();
356    [
357        " ltd", " limited", " inc", " incorporated", " corp", " corporation",
358        " pte", " llc", " llp", " plc", " gmbh", " ag", " sa", " s.a.",
359        " sas", " bv", " nv", " pty",
360    ]
361    .iter()
362    .any(|s| lower.contains(s))
363}
364
365/// Mirror of cmd_new's default_title — used to decide whether the user
366/// provided a meaningful project title (subtitle) or just accepted the
367/// auto-generated "Kind — A × B" string (no subtitle).
368fn auto_default_title(kind: &str, issuer_name: &str, client_name: &str) -> String {
369    match kind {
370        "nda" => format!("NDA — {issuer_name} & {client_name}"),
371        "ncnda" => format!("NCNDA — {issuer_name} & {client_name}"),
372        "consulting" => format!("Consulting Agreement — {issuer_name} × {client_name}"),
373        "msa" => format!("Master Services Agreement — {issuer_name} & {client_name}"),
374        "sow" => format!("Statement of Work — {issuer_name} × {client_name}"),
375        "service" => format!("Service Agreement — {issuer_name} for {client_name}"),
376        "loan" => format!("Loan Agreement — {issuer_name} & {client_name}"),
377        _ => format!("Agreement — {issuer_name} & {client_name}"),
378    }
379}
380
381fn party_display(issuer: &Issuer, role_label: &str) -> PartyData {
382    PartyData {
383        role_label: role_label.to_string(),
384        display_name: issuer.name.clone(),
385        legal_name: issuer.legal_name.clone(),
386        company_no: issuer.company_no.clone(),
387        address: issuer.address.clone(),
388        jurisdiction: Some(issuer.jurisdiction.profile().country.to_string()),
389        email: issuer.email.clone(),
390        attn: None,
391    }
392}
393
394fn client_display(client: &Client, role_label: &str) -> PartyData {
395    PartyData {
396        role_label: role_label.to_string(),
397        display_name: client.name.clone(),
398        legal_name: client.legal_name.clone(),
399        company_no: client.company_no.clone(),
400        address: client.address.clone(),
401        jurisdiction: client.legal_jurisdiction.clone(),
402        email: client.email.clone(),
403        attn: client.attn.clone(),
404    }
405}
406
407fn vars_from(
408    contract: &Contract,
409    issuer: &Issuer,
410    client: &Client,
411    terms: &serde_json::Value,
412    our_role: &str,
413    their_role: &str,
414) -> BTreeMap<String, String> {
415    let mut v = BTreeMap::new();
416    let our_legal = issuer.legal_name.clone().unwrap_or_else(|| issuer.name.clone());
417    let their_legal = client.legal_name.clone().unwrap_or_else(|| client.name.clone());
418    v.insert("our_name".into(), issuer.name.clone());
419    v.insert("our_legal_name".into(), our_legal);
420    v.insert("our_role".into(), our_role.to_string());
421    v.insert("their_name".into(), client.name.clone());
422    v.insert("their_legal_name".into(), their_legal);
423    v.insert("their_role".into(), their_role.to_string());
424    v.insert("title".into(), contract.title.clone());
425    v.insert("number".into(), contract.number.clone());
426    v.insert("effective_date".into(), fmt_date(&contract.effective_date));
427    v.insert(
428        "end_date".into(),
429        contract.end_date.as_deref().map(fmt_date).unwrap_or_default(),
430    );
431    v.insert("term_text".into(), term_text(contract));
432    v.insert("governing_law".into(), contract.governing_law.clone());
433    v.insert(
434        "jurisdiction_phrase".into(),
435        jurisdiction_phrase(&contract.governing_law),
436    );
437    v.insert(
438        "venue".into(),
439        contract.venue.clone().unwrap_or_default(),
440    );
441    // NDA specifics
442    v.insert(
443        "purpose".into(),
444        terms
445            .get("purpose")
446            .and_then(|x| x.as_str())
447            .unwrap_or("the matters described in the title")
448            .to_string(),
449    );
450    v.insert(
451        "mutuality".into(),
452        terms
453            .get("mutuality")
454            .and_then(|x| x.as_str())
455            .unwrap_or("mutual")
456            .to_string(),
457    );
458    v.insert(
459        "confidentiality_years".into(),
460        terms
461            .get("confidentiality_years")
462            .and_then(|x| x.as_i64())
463            .map(|n| n.to_string())
464            .unwrap_or_else(|| "3".into()),
465    );
466    v.insert(
467        "termination_notice_days".into(),
468        terms
469            .get("termination_notice_days")
470            .and_then(|x| x.as_i64())
471            .or_else(|| {
472                contract
473                    .terms_json
474                    .parse::<serde_json::Value>()
475                    .ok()
476                    .and_then(|t| t.get("termination_notice_days").and_then(|x| x.as_i64()))
477            })
478            .map(|n| n.to_string())
479            .unwrap_or_else(|| "30".into()),
480    );
481    // Consulting / SOW
482    v.insert(
483        "deliverables_block".into(),
484        deliverables_block(terms),
485    );
486    v.insert("ip_assignment_text".into(), ip_assignment_text(terms));
487    v.insert(
488        "fee_text".into(),
489        fee_text(contract).unwrap_or_else(|| "as separately agreed in writing".into()),
490    );
491    // Any other scalar term becomes a {{var}} directly — this is what lets
492    // new kinds (loan: principal_text, repayment_date, interest_text; ncnda:
493    // non_circumvention_months, commission_text) work with zero Rust changes.
494    if let Some(obj) = terms.as_object() {
495        for (key, val) in obj {
496            if v.contains_key(key) {
497                continue;
498            }
499            let s = match val {
500                serde_json::Value::String(s) => s.clone(),
501                serde_json::Value::Number(n) => n.to_string(),
502                serde_json::Value::Bool(b) => b.to_string(),
503                _ => continue,
504            };
505            v.insert(key.clone(), s);
506        }
507    }
508    v
509}
510
511// ─── Pipeline ────────────────────────────────────────────────────────────
512
513pub fn build_render_data(
514    contract: &Contract,
515    issuer: &Issuer,
516    client: &Client,
517    clause_rows: &[ContractClauseRow],
518    pack: &Pack,
519    force_draft: bool,
520    force_final: bool,
521) -> Result<ContractRenderData> {
522    let terms: serde_json::Value = serde_json::from_str(&contract.terms_json)
523        .map_err(|e| AppError::Other(format!("invalid terms_json: {e}")))?;
524    let (our_role, their_role) = party_role_labels(&contract.kind, &terms);
525
526    let vars = vars_from(contract, issuer, client, &terms, &our_role, &their_role);
527
528    // Resolve clauses through pack with optional overrides on each row.
529    let included: Vec<String> = clause_rows.iter().map(|r| r.slug.clone()).collect();
530    let mut overrides: BTreeMap<String, (Option<String>, Option<String>)> = BTreeMap::new();
531    for r in clause_rows {
532        if r.heading.is_some() || r.body.is_some() {
533            overrides.insert(r.slug.clone(), (r.heading.clone(), r.body.clone()));
534        }
535    }
536    let resolved = clauses::resolve(pack, &included, &overrides, &vars)?;
537    let render_clauses: Vec<ClauseRenderData> = resolved
538        .into_iter()
539        .enumerate()
540        .map(|(i, c)| ClauseRenderData {
541            number: (i + 1) as i64,
542            slug: c.slug,
543            heading: c.heading,
544            body: c.body,
545        })
546        .collect();
547
548    let our_legal = issuer
549        .legal_name
550        .clone()
551        .unwrap_or_else(|| issuer.name.clone());
552    let their_legal = client
553        .legal_name
554        .clone()
555        .unwrap_or_else(|| client.name.clone());
556
557    let signature = SignatureBlock {
558        our_label: our_role.clone(),
559        our_name: our_legal,
560        our_signer_name: contract.signed_by_us_name.clone(),
561        our_signer_title: contract.signed_by_us_title.clone(),
562        our_signer_date: contract.signed_by_us_at.as_deref().map(fmt_date),
563        their_label: their_role.clone(),
564        their_name: their_legal,
565        their_signer_name: contract.signed_by_them_name.clone(),
566        their_signer_title: contract.signed_by_them_title.clone(),
567        their_signer_date: contract.signed_by_them_at.as_deref().map(fmt_date),
568    };
569
570    // DRAFT watermark logic: implicit unless the contract has been executed
571    // (signed/active, or has since expired / been terminated), but the user
572    // can force either direction.
573    let is_executed = matches!(
574        contract.status.as_str(),
575        "signed" | "active" | "expired" | "terminated"
576    );
577    let draft = if force_draft {
578        true
579    } else if force_final {
580        false
581    } else {
582        !is_executed
583    };
584
585    let our_party = party_display(issuer, &our_role);
586    let their_party = client_display(client, &their_role);
587    let parties_prose = vec![party_intro_prose(&our_party), party_intro_prose(&their_party)];
588    // Compute subtitle: suppress when contract.title is just the auto-default.
589    let auto = auto_default_title(&contract.kind, &issuer.name, &client.name);
590    let subtitle = if contract.title.trim() == auto.trim() {
591        None
592    } else {
593        Some(contract.title.clone())
594    };
595
596    Ok(ContractRenderData {
597        kind: contract.kind.clone(),
598        kind_label: kind_label(&contract.kind, &terms),
599        number: contract.number.clone(),
600        subtitle,
601        effective_date_display: fmt_date(&contract.effective_date),
602        end_date_display: contract.end_date.as_deref().map(fmt_date),
603        term_text: term_text(contract),
604        term_short: term_short(contract),
605        governing_law: contract.governing_law.clone(),
606        jurisdiction_phrase: jurisdiction_phrase(&contract.governing_law),
607        venue: contract.venue.clone(),
608        status: contract.status.clone(),
609        draft_watermark: draft,
610        fee_text: fee_text(contract),
611        fee_short: fee_short(contract),
612        our_party,
613        their_party,
614        parties_prose,
615        clauses: render_clauses,
616        signature,
617        logo: None, // populated by render_to_pdf if issuer has a logo
618        internal_notes: contract.notes.clone(),
619    })
620}
621
622pub fn render_to_pdf(
623    template: &str,
624    data: &mut ContractRenderData,
625    issuer: &Issuer,
626    out_path: &Path,
627) -> Result<()> {
628    typst_assets::ensure_extracted()?;
629    if !typst_assets::has_template(template)? {
630        return Err(AppError::InvalidInput(format!(
631            "template '{template}' not found. Run: contract template list"
632        )));
633    }
634
635    let tmp = tempfile::Builder::new()
636        .prefix("contract-cli-render-")
637        .tempdir()?;
638    let root = tmp.path();
639    copy_dir_contents(&typst_assets::project_root()?, root)?;
640
641    // Copy logo (if any) into shared/ and set the json path.
642    data.logo = stage_logo(root, issuer)?;
643
644    let json_path = root.join("shared").join("contract.json");
645    std::fs::write(&json_path, serde_json::to_vec_pretty(&data)?)?;
646
647    let template_path = root.join("templates").join(format!("{template}.typ"));
648    let mut cmd = Command::new("typst");
649    cmd.arg("compile").arg("--root").arg(root);
650    // Embedded OFL fonts (typst/fonts/**) travel with the assets; point typst
651    // at them so templates render identically on machines without the faces.
652    let fonts_dir = root.join("fonts");
653    if fonts_dir.is_dir() {
654        cmd.arg("--font-path").arg(&fonts_dir);
655    }
656    let status = cmd
657        .arg(&template_path)
658        .arg(out_path)
659        .status()
660        .map_err(|e| AppError::Render(format!("typst binary not found: {e}")))?;
661    if !status.success() {
662        return Err(AppError::Render(format!(
663            "typst compile exited with {}",
664            status.code().unwrap_or(-1)
665        )));
666    }
667    Ok(())
668}
669
670fn copy_dir_contents(src: &Path, dst: &Path) -> Result<()> {
671    std::fs::create_dir_all(dst)?;
672    for entry in std::fs::read_dir(src)? {
673        let entry = entry?;
674        let src_path = entry.path();
675        let dst_path = dst.join(entry.file_name());
676        if src_path.is_dir() {
677            copy_dir_contents(&src_path, &dst_path)?;
678        } else {
679            std::fs::copy(&src_path, &dst_path)?;
680        }
681    }
682    Ok(())
683}
684
685fn stage_logo(root: &Path, issuer: &Issuer) -> Result<Option<String>> {
686    let Some(src_raw) = &issuer.logo_path else {
687        return Ok(None);
688    };
689    let src_expanded = expand_tilde(src_raw);
690    let src = Path::new(&src_expanded);
691    if !src.exists() {
692        eprintln!(
693            "warning: logo '{}' not found for issuer '{}' — rendering without",
694            src.display(),
695            issuer.slug
696        );
697        return Ok(None);
698    }
699    let ext = src
700        .extension()
701        .and_then(|e| e.to_str())
702        .unwrap_or("png")
703        .to_lowercase();
704    let rel = format!("shared/logo-{}.{ext}", issuer.slug);
705    let dst = root.join(&rel);
706    if let Some(parent) = dst.parent() {
707        std::fs::create_dir_all(parent)?;
708    }
709    std::fs::copy(src, &dst)?;
710    Ok(Some(format!("/{rel}")))
711}
712
713pub fn expand_tilde(s: &str) -> String {
714    if let Some(rest) = s.strip_prefix("~/") {
715        if let Ok(home) = std::env::var("HOME") {
716            return format!("{home}/{rest}");
717        }
718    }
719    s.to_string()
720}
721
722pub fn default_output_dir() -> std::path::PathBuf {
723    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
724    std::path::PathBuf::from(home).join("Documents").join("Contracts")
725}