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        "msa" => "Master Services Agreement".into(),
123        "sow" => "Statement of Work".into(),
124        "service" => "Service Agreement".into(),
125        other => format!("{} Agreement", capitalize(other)),
126    }
127}
128
129fn capitalize(s: &str) -> String {
130    let mut chars = s.chars();
131    chars.next().map(|c| c.to_uppercase().collect::<String>() + chars.as_str())
132        .unwrap_or_default()
133}
134
135fn party_role_labels(kind: &str, terms: &serde_json::Value) -> (String, String) {
136    match kind {
137        "consulting" => ("Consultant".into(), "Client".into()),
138        "msa" | "sow" => ("Provider".into(), "Client".into()),
139        "service" => ("Provider".into(), "Customer".into()),
140        "nda" => {
141            let mutuality = terms.get("mutuality").and_then(|v| v.as_str()).unwrap_or("mutual");
142            if mutuality == "mutual" {
143                ("Party A".into(), "Party B".into())
144            } else {
145                let disclosing = terms.get("disclosing_side").and_then(|v| v.as_str()).unwrap_or("us");
146                if disclosing == "us" {
147                    ("Disclosing Party".into(), "Receiving Party".into())
148                } else {
149                    ("Receiving Party".into(), "Disclosing Party".into())
150                }
151            }
152        }
153        _ => ("Party A".into(), "Party B".into()),
154    }
155}
156
157fn jurisdiction_phrase(law: &str) -> String {
158    // Best-effort heuristic. Falls back to "the courts of <law>".
159    let l = law.trim();
160    let lower = l.to_lowercase();
161    if lower.contains("singapore") {
162        "the courts of Singapore".into()
163    } else if lower.contains("england") || lower.contains("wales") || lower.contains("united kingdom") || lower == "uk" {
164        "the courts of England and Wales".into()
165    } else if lower.contains("delaware") {
166        "the state and federal courts located in Delaware".into()
167    } else if lower.contains("new york") {
168        "the state and federal courts of New York".into()
169    } else if lower.contains("hong kong") {
170        "the courts of the Hong Kong Special Administrative Region".into()
171    } else if lower.contains("germany") {
172        "the courts of Frankfurt am Main, Germany".into()
173    } else if lower.contains("france") {
174        "the courts of Paris, France".into()
175    } else {
176        format!("the courts of {}", l)
177    }
178}
179
180fn term_text(c: &Contract) -> String {
181    if let Some(end) = &c.end_date {
182        format!("until {}", fmt_date(end))
183    } else if let Some(m) = c.term_months {
184        if m % 12 == 0 {
185            let years = m / 12;
186            if years == 1 {
187                "for one year from the Effective Date".into()
188            } else {
189                format!("for {} years from the Effective Date", years)
190            }
191        } else if m == 1 {
192            "for one month from the Effective Date".into()
193        } else {
194            format!("for {} months from the Effective Date", m)
195        }
196    } else {
197        "indefinitely until terminated as provided below".into()
198    }
199}
200
201fn term_short(c: &Contract) -> String {
202    if let Some(end) = &c.end_date {
203        // Compact date — use "%-d %b %Y" so "1 Jul 2026" not "1 July 2026"
204        NaiveDate::parse_from_str(end, "%Y-%m-%d")
205            .map(|d| format!("until {}", d.format("%-d %b %Y")))
206            .unwrap_or_else(|_| format!("until {end}"))
207    } else if let Some(m) = c.term_months {
208        if m % 12 == 0 {
209            let y = m / 12;
210            if y == 1 { "1 year".into() } else { format!("{y} years") }
211        } else if m == 1 {
212            "1 month".into()
213        } else {
214            format!("{m} months")
215        }
216    } else {
217        "Indefinite".into()
218    }
219}
220
221fn fee_short(c: &Contract) -> Option<String> {
222    let kind = c.fee_type.as_deref()?;
223    let amt = c.fee_amount_minor?;
224    let cur = c.fee_currency.clone().unwrap_or_default();
225    let symbol = finance_core::money::currency_symbol(&cur);
226    let amt_str = finance_core::money::MinorUnits(amt).format_number();
227    let amt_display = if symbol.is_empty() {
228        format!("{} {}", amt_str, cur)
229    } else {
230        format!("{}{}", symbol, amt_str)
231    };
232    Some(match kind {
233        "fixed" => format!("{amt_display} fixed"),
234        "hourly" => format!("{amt_display}/hr"),
235        "daily" => format!("{amt_display}/day"),
236        "retainer" => format!("{amt_display}/month"),
237        _ => amt_display,
238    })
239}
240
241fn ip_assignment_text(terms: &serde_json::Value) -> String {
242    let mode = terms.get("ip_assignment").and_then(|v| v.as_str()).unwrap_or("client");
243    match mode {
244        "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(),
245        "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(),
246        "shared" => "The parties jointly own the deliverables. Each party may use them for any purpose without accounting to the other.".into(),
247        _ => "All deliverables produced specifically for the Client under this engagement belong to the Client, on payment of the relevant fees.".into(),
248    }
249}
250
251fn fee_text(c: &Contract) -> Option<String> {
252    let kind = c.fee_type.as_deref()?;
253    let amt = c.fee_amount_minor?;
254    let cur = c.fee_currency.clone().unwrap_or_default();
255    let symbol = finance_core::money::currency_symbol(&cur);
256    let amt_str = finance_core::money::MinorUnits(amt).format_number();
257    let amt_display = if symbol.is_empty() {
258        format!("{} {}", amt_str, cur)
259    } else {
260        format!("{}{}", symbol, amt_str)
261    };
262    let sched_phrase = match c.fee_schedule.as_deref() {
263        Some("on-completion") => ", payable on completion of the Services",
264        Some("on-milestone") => ", payable on completion of each milestone",
265        Some("monthly") => ", invoiced monthly in arrears",
266        Some("upon-invoice") => ", invoiced as work is performed",
267        _ => "",
268    };
269    Some(match kind {
270        "fixed" => format!("a fixed fee of {amt_display}{sched_phrase}"),
271        "hourly" => format!("an hourly rate of {amt_display}/hour{sched_phrase}"),
272        "daily" => format!("a daily rate of {amt_display}/day{sched_phrase}"),
273        "retainer" => format!("a monthly retainer of {amt_display}{sched_phrase}"),
274        _ => format!("{amt_display}{sched_phrase}"),
275    })
276}
277
278fn deliverables_block(terms: &serde_json::Value) -> String {
279    let items: Vec<String> = terms
280        .get("deliverables")
281        .and_then(|v| v.as_array())
282        .map(|arr| {
283            arr.iter()
284                .filter_map(|x| x.as_str().map(str::to_string))
285                .collect()
286        })
287        .unwrap_or_default();
288    if items.is_empty() {
289        "_To be agreed in writing by the parties._".into()
290    } else {
291        items
292            .into_iter()
293            .map(|s| format!("- {s}"))
294            .collect::<Vec<_>>()
295            .join("\n")
296    }
297}
298
299/// Generate the traditional "(N) <NAME> of <address>, <qualifier> (the "Role")"
300/// prose used in UK/Commonwealth contracts. Companies get an incorporation
301/// qualifier ("a company incorporated in England and Wales, Co. No. 12345678");
302/// individuals just have name + address. The role label is the contract-side
303/// label (Consultant / Client / Provider / Party A / etc).
304fn party_intro_prose(p: &PartyData) -> String {
305    let legal = p.legal_name.clone().unwrap_or_else(|| p.display_name.clone());
306    let name_upper = legal.to_uppercase();
307    let addr = p.address.join(", ");
308    let looks_like_company = looks_like_company_name(&legal);
309    let mut qualifier = String::new();
310    if let Some(j) = &p.jurisdiction {
311        if looks_like_company {
312            qualifier.push_str(&format!(", a company incorporated in {j}"));
313            if let Some(co) = &p.company_no {
314                qualifier.push_str(&format!(" (Co. No. {co})"));
315            } else {
316                qualifier.push(')');
317                // remove the trailing ')' we just over-added; only add if
318                // a company-no is missing AND we want no closing paren.
319                qualifier.pop();
320            }
321        }
322    } else if let Some(co) = &p.company_no {
323        // Jurisdiction missing but company number known.
324        qualifier.push_str(&format!(", company no. {co}"));
325    }
326    // Use Typst's single-asterisk bold syntax so the name reads as bold.
327    format!("*{name_upper}* of {addr}{qualifier} (the \"{role}\")", role = p.role_label)
328}
329
330fn looks_like_company_name(s: &str) -> bool {
331    let lower = s.to_lowercase();
332    [
333        " ltd", " limited", " inc", " incorporated", " corp", " corporation",
334        " pte", " llc", " llp", " plc", " gmbh", " ag", " sa", " s.a.",
335        " sas", " bv", " nv", " pty",
336    ]
337    .iter()
338    .any(|s| lower.contains(s))
339}
340
341/// Mirror of cmd_new's default_title — used to decide whether the user
342/// provided a meaningful project title (subtitle) or just accepted the
343/// auto-generated "Kind — A × B" string (no subtitle).
344fn auto_default_title(kind: &str, issuer_name: &str, client_name: &str) -> String {
345    match kind {
346        "nda" => format!("NDA — {issuer_name} & {client_name}"),
347        "consulting" => format!("Consulting Agreement — {issuer_name} × {client_name}"),
348        "msa" => format!("Master Services Agreement — {issuer_name} & {client_name}"),
349        "sow" => format!("Statement of Work — {issuer_name} × {client_name}"),
350        "service" => format!("Service Agreement — {issuer_name} for {client_name}"),
351        _ => format!("Agreement — {issuer_name} & {client_name}"),
352    }
353}
354
355fn party_display(issuer: &Issuer, role_label: &str) -> PartyData {
356    PartyData {
357        role_label: role_label.to_string(),
358        display_name: issuer.name.clone(),
359        legal_name: issuer.legal_name.clone(),
360        company_no: issuer.company_no.clone(),
361        address: issuer.address.clone(),
362        jurisdiction: Some(issuer.jurisdiction.profile().country.to_string()),
363        email: issuer.email.clone(),
364        attn: None,
365    }
366}
367
368fn client_display(client: &Client, role_label: &str) -> PartyData {
369    PartyData {
370        role_label: role_label.to_string(),
371        display_name: client.name.clone(),
372        legal_name: client.legal_name.clone(),
373        company_no: client.company_no.clone(),
374        address: client.address.clone(),
375        jurisdiction: client.legal_jurisdiction.clone(),
376        email: client.email.clone(),
377        attn: client.attn.clone(),
378    }
379}
380
381fn vars_from(
382    contract: &Contract,
383    issuer: &Issuer,
384    client: &Client,
385    terms: &serde_json::Value,
386    our_role: &str,
387    their_role: &str,
388) -> BTreeMap<String, String> {
389    let mut v = BTreeMap::new();
390    let our_legal = issuer.legal_name.clone().unwrap_or_else(|| issuer.name.clone());
391    let their_legal = client.legal_name.clone().unwrap_or_else(|| client.name.clone());
392    v.insert("our_name".into(), issuer.name.clone());
393    v.insert("our_legal_name".into(), our_legal);
394    v.insert("our_role".into(), our_role.to_string());
395    v.insert("their_name".into(), client.name.clone());
396    v.insert("their_legal_name".into(), their_legal);
397    v.insert("their_role".into(), their_role.to_string());
398    v.insert("title".into(), contract.title.clone());
399    v.insert("number".into(), contract.number.clone());
400    v.insert("effective_date".into(), fmt_date(&contract.effective_date));
401    v.insert(
402        "end_date".into(),
403        contract.end_date.as_deref().map(fmt_date).unwrap_or_default(),
404    );
405    v.insert("term_text".into(), term_text(contract));
406    v.insert("governing_law".into(), contract.governing_law.clone());
407    v.insert(
408        "jurisdiction_phrase".into(),
409        jurisdiction_phrase(&contract.governing_law),
410    );
411    v.insert(
412        "venue".into(),
413        contract.venue.clone().unwrap_or_default(),
414    );
415    // NDA specifics
416    v.insert(
417        "purpose".into(),
418        terms
419            .get("purpose")
420            .and_then(|x| x.as_str())
421            .unwrap_or("the matters described in the title")
422            .to_string(),
423    );
424    v.insert(
425        "mutuality".into(),
426        terms
427            .get("mutuality")
428            .and_then(|x| x.as_str())
429            .unwrap_or("mutual")
430            .to_string(),
431    );
432    v.insert(
433        "confidentiality_years".into(),
434        terms
435            .get("confidentiality_years")
436            .and_then(|x| x.as_i64())
437            .map(|n| n.to_string())
438            .unwrap_or_else(|| "3".into()),
439    );
440    v.insert(
441        "termination_notice_days".into(),
442        terms
443            .get("termination_notice_days")
444            .and_then(|x| x.as_i64())
445            .or_else(|| {
446                contract
447                    .terms_json
448                    .parse::<serde_json::Value>()
449                    .ok()
450                    .and_then(|t| t.get("termination_notice_days").and_then(|x| x.as_i64()))
451            })
452            .map(|n| n.to_string())
453            .unwrap_or_else(|| "30".into()),
454    );
455    // Consulting / SOW
456    v.insert(
457        "deliverables_block".into(),
458        deliverables_block(terms),
459    );
460    v.insert("ip_assignment_text".into(), ip_assignment_text(terms));
461    v.insert(
462        "fee_text".into(),
463        fee_text(contract).unwrap_or_else(|| "as separately agreed in writing".into()),
464    );
465    v
466}
467
468// ─── Pipeline ────────────────────────────────────────────────────────────
469
470pub fn build_render_data(
471    contract: &Contract,
472    issuer: &Issuer,
473    client: &Client,
474    clause_rows: &[ContractClauseRow],
475    pack: &Pack,
476    force_draft: bool,
477    force_final: bool,
478) -> Result<ContractRenderData> {
479    let terms: serde_json::Value = serde_json::from_str(&contract.terms_json)
480        .map_err(|e| AppError::Other(format!("invalid terms_json: {e}")))?;
481    let (our_role, their_role) = party_role_labels(&contract.kind, &terms);
482
483    let vars = vars_from(contract, issuer, client, &terms, &our_role, &their_role);
484
485    // Resolve clauses through pack with optional overrides on each row.
486    let included: Vec<String> = clause_rows.iter().map(|r| r.slug.clone()).collect();
487    let mut overrides: BTreeMap<String, (Option<String>, Option<String>)> = BTreeMap::new();
488    for r in clause_rows {
489        if r.heading.is_some() || r.body.is_some() {
490            overrides.insert(r.slug.clone(), (r.heading.clone(), r.body.clone()));
491        }
492    }
493    let resolved = clauses::resolve(pack, &included, &overrides, &vars)?;
494    let render_clauses: Vec<ClauseRenderData> = resolved
495        .into_iter()
496        .enumerate()
497        .map(|(i, c)| ClauseRenderData {
498            number: (i + 1) as i64,
499            slug: c.slug,
500            heading: c.heading,
501            body: c.body,
502        })
503        .collect();
504
505    let our_legal = issuer
506        .legal_name
507        .clone()
508        .unwrap_or_else(|| issuer.name.clone());
509    let their_legal = client
510        .legal_name
511        .clone()
512        .unwrap_or_else(|| client.name.clone());
513
514    let signature = SignatureBlock {
515        our_label: our_role.clone(),
516        our_name: our_legal,
517        our_signer_name: contract.signed_by_us_name.clone(),
518        our_signer_title: contract.signed_by_us_title.clone(),
519        our_signer_date: contract.signed_by_us_at.as_deref().map(fmt_date),
520        their_label: their_role.clone(),
521        their_name: their_legal,
522        their_signer_name: contract.signed_by_them_name.clone(),
523        their_signer_title: contract.signed_by_them_title.clone(),
524        their_signer_date: contract.signed_by_them_at.as_deref().map(fmt_date),
525    };
526
527    // DRAFT watermark logic: implicit unless contract is signed/active, but
528    // user can force either direction.
529    let is_executed = matches!(contract.status.as_str(), "signed" | "active");
530    let draft = if force_draft {
531        true
532    } else if force_final {
533        false
534    } else {
535        !is_executed
536    };
537
538    let our_party = party_display(issuer, &our_role);
539    let their_party = client_display(client, &their_role);
540    let parties_prose = vec![party_intro_prose(&our_party), party_intro_prose(&their_party)];
541    // Compute subtitle: suppress when contract.title is just the auto-default.
542    let auto = auto_default_title(&contract.kind, &issuer.name, &client.name);
543    let subtitle = if contract.title.trim() == auto.trim() {
544        None
545    } else {
546        Some(contract.title.clone())
547    };
548
549    Ok(ContractRenderData {
550        kind: contract.kind.clone(),
551        kind_label: kind_label(&contract.kind, &terms),
552        number: contract.number.clone(),
553        subtitle,
554        effective_date_display: fmt_date(&contract.effective_date),
555        end_date_display: contract.end_date.as_deref().map(fmt_date),
556        term_text: term_text(contract),
557        term_short: term_short(contract),
558        governing_law: contract.governing_law.clone(),
559        jurisdiction_phrase: jurisdiction_phrase(&contract.governing_law),
560        venue: contract.venue.clone(),
561        status: contract.status.clone(),
562        draft_watermark: draft,
563        fee_text: fee_text(contract),
564        fee_short: fee_short(contract),
565        our_party,
566        their_party,
567        parties_prose,
568        clauses: render_clauses,
569        signature,
570        logo: None, // populated by render_to_pdf if issuer has a logo
571        internal_notes: contract.notes.clone(),
572    })
573}
574
575pub fn render_to_pdf(
576    template: &str,
577    data: &mut ContractRenderData,
578    issuer: &Issuer,
579    out_path: &Path,
580) -> Result<()> {
581    typst_assets::ensure_extracted()?;
582    if !typst_assets::has_template(template)? {
583        return Err(AppError::InvalidInput(format!(
584            "template '{template}' not found. Run: contract template list"
585        )));
586    }
587
588    let tmp = tempfile::Builder::new()
589        .prefix("contract-cli-render-")
590        .tempdir()?;
591    let root = tmp.path();
592    copy_dir_contents(&typst_assets::project_root()?, root)?;
593
594    // Copy logo (if any) into shared/ and set the json path.
595    data.logo = stage_logo(root, issuer)?;
596
597    let json_path = root.join("shared").join("contract.json");
598    std::fs::write(&json_path, serde_json::to_vec_pretty(&data)?)?;
599
600    let template_path = root.join("templates").join(format!("{template}.typ"));
601    let status = Command::new("typst")
602        .arg("compile")
603        .arg("--root")
604        .arg(root)
605        .arg(&template_path)
606        .arg(out_path)
607        .status()
608        .map_err(|e| AppError::Render(format!("typst binary not found: {e}")))?;
609    if !status.success() {
610        return Err(AppError::Render(format!(
611            "typst compile exited with {}",
612            status.code().unwrap_or(-1)
613        )));
614    }
615    Ok(())
616}
617
618fn copy_dir_contents(src: &Path, dst: &Path) -> Result<()> {
619    std::fs::create_dir_all(dst)?;
620    for entry in std::fs::read_dir(src)? {
621        let entry = entry?;
622        let src_path = entry.path();
623        let dst_path = dst.join(entry.file_name());
624        if src_path.is_dir() {
625            copy_dir_contents(&src_path, &dst_path)?;
626        } else {
627            std::fs::copy(&src_path, &dst_path)?;
628        }
629    }
630    Ok(())
631}
632
633fn stage_logo(root: &Path, issuer: &Issuer) -> Result<Option<String>> {
634    let Some(src_raw) = &issuer.logo_path else {
635        return Ok(None);
636    };
637    let src_expanded = expand_tilde(src_raw);
638    let src = Path::new(&src_expanded);
639    if !src.exists() {
640        eprintln!(
641            "warning: logo '{}' not found for issuer '{}' — rendering without",
642            src.display(),
643            issuer.slug
644        );
645        return Ok(None);
646    }
647    let ext = src
648        .extension()
649        .and_then(|e| e.to_str())
650        .unwrap_or("png")
651        .to_lowercase();
652    let rel = format!("shared/logo-{}.{ext}", issuer.slug);
653    let dst = root.join(&rel);
654    if let Some(parent) = dst.parent() {
655        std::fs::create_dir_all(parent)?;
656    }
657    std::fs::copy(src, &dst)?;
658    Ok(Some(format!("/{rel}")))
659}
660
661pub fn expand_tilde(s: &str) -> String {
662    if let Some(rest) = s.strip_prefix("~/") {
663        if let Ok(home) = std::env::var("HOME") {
664            return format!("{home}/{rest}");
665        }
666    }
667    s.to_string()
668}
669
670pub fn default_output_dir() -> std::path::PathBuf {
671    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
672    std::path::PathBuf::from(home).join("Documents").join("Contracts")
673}