Skip to main content

contract_cli/commands/
template.rs

1use chrono::Utc;
2use std::path::PathBuf;
3
4use crate::cli::TemplateCmd;
5use crate::clauses;
6use crate::db::{Client, Contract, ContractClauseRow, Issuer};
7use crate::error::Result;
8use crate::output::{print_success, Ctx};
9use crate::render;
10use crate::tax::Jurisdiction;
11use crate::typst_assets;
12
13pub fn run(cmd: TemplateCmd, ctx: Ctx) -> Result<()> {
14    match cmd {
15        TemplateCmd::List => {
16            let metas = typst_assets::list_template_meta()?;
17            print_success(ctx, &metas, |ms| {
18                if ms.is_empty() {
19                    println!("(no templates)");
20                } else {
21                    for m in ms {
22                        println!("  {:<16} {}", m.name, m.description);
23                        if !m.tags.is_empty() {
24                            println!("  {:<16} tags: {}", "", m.tags.join(", "));
25                        }
26                    }
27                }
28            });
29            Ok(())
30        }
31        TemplateCmd::Find { query } => {
32            let query = query.join(" ");
33            let metas = typst_assets::list_template_meta()?;
34            let mut scored: Vec<(f64, &typst_assets::TemplateMeta)> = metas
35                .iter()
36                .map(|m| {
37                    let tags: Vec<&str> = m
38                        .tags
39                        .iter()
40                        .chain(m.mood.iter())
41                        .map(String::as_str)
42                        .collect();
43                    let haystack = format!("{} {} {}", m.name, m.description, m.fonts);
44                    (crate::kinds::score(&query, &haystack, &tags), m)
45                })
46                .filter(|(s, _)| *s > 0.0)
47                .collect();
48            scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
49            if scored.is_empty() {
50                return Err(crate::error::AppError::NotFound(format!(
51                    "no template matches '{query}'. Run: contract template list"
52                )));
53            }
54            #[derive(serde::Serialize)]
55            struct Match<'a> {
56                name: &'a str,
57                score: f64,
58                description: &'a str,
59                tags: &'a [String],
60            }
61            let matches: Vec<Match> = scored
62                .iter()
63                .map(|(s, m)| Match {
64                    name: &m.name,
65                    score: (*s * 100.0).round() / 100.0,
66                    description: &m.description,
67                    tags: &m.tags,
68                })
69                .collect();
70            print_success(ctx, &matches, |ms| {
71                for m in ms {
72                    println!("  {:<16} {:>5.2}  {}", m.name, m.score, m.description);
73                }
74            });
75            Ok(())
76        }
77        TemplateCmd::Preview { name, kind, out } => {
78            let issuer = sample_issuer();
79            let client = sample_client();
80            let contract = sample_contract(&kind);
81            let pack = clauses::load_pack(&kind, &contract.clause_pack)?;
82            let clause_rows: Vec<ContractClauseRow> = pack
83                .pack
84                .default_clauses
85                .iter()
86                .enumerate()
87                .map(|(i, slug)| ContractClauseRow {
88                    id: 0,
89                    contract_id: 0,
90                    position: i as i64,
91                    slug: slug.clone(),
92                    heading: None,
93                    body: None,
94                })
95                .collect();
96            let mut data =
97                render::build_render_data(&contract, &issuer, &client, &clause_rows, &pack, false, true)?;
98            let out_path =
99                PathBuf::from(out.unwrap_or_else(|| format!("preview-{name}-{kind}.pdf")));
100            render::render_to_pdf(&name, &mut data, &issuer, &out_path)?;
101            print_success(ctx, &out_path.display().to_string(), |p| {
102                println!("preview → {p}");
103            });
104            Ok(())
105        }
106    }
107}
108
109fn sample_issuer() -> Issuer {
110    Issuer {
111        id: 0,
112        slug: "acme".into(),
113        name: "Acme Studio".into(),
114        legal_name: Some("Acme Studio Pte. Ltd.".into()),
115        jurisdiction: Jurisdiction::Sg,
116        tax_registered: false,
117        tax_id: None,
118        company_no: Some("202312345A".into()),
119        tagline: None,
120        address: vec![
121            "1 Marina Bay".into(),
122            "Singapore 018989".into(),
123        ],
124        email: Some("hello@acme.example".into()),
125        phone: None,
126        bank_details: None,
127        default_template: "helvetica-nera".into(),
128        currency: None,
129        symbol: None,
130        number_format: "{issuer}-{year}-{seq:04}".into(),
131        logo_path: None,
132        default_output_dir: None,
133        default_notes: None,
134    }
135}
136
137fn sample_client() -> Client {
138    Client {
139        id: 0,
140        slug: "meridian".into(),
141        name: "Meridian & Co.".into(),
142        legal_name: Some("Meridian & Co. Pty Ltd".into()),
143        company_no: Some("ACN 600 123 456".into()),
144        legal_jurisdiction: Some("Victoria, Australia".into()),
145        attn: Some("Sophie Lin, Head of Marketing".into()),
146        country: Some("AU".into()),
147        tax_id: None,
148        address: vec![
149            "401 Collins Street".into(),
150            "Melbourne VIC 3000".into(),
151            "Australia".into(),
152        ],
153        email: Some("sophie@meridian.example".into()),
154        notes: None,
155        default_issuer_slug: None,
156        default_template: None,
157    }
158}
159
160fn sample_contract(kind: &str) -> Contract {
161    let today = Utc::now().date_naive().format("%Y-%m-%d").to_string();
162    let terms = match kind {
163        "nda" => serde_json::json!({
164            "mutuality": "mutual",
165            "disclosing_side": "both",
166            "purpose": "a potential collaboration on a longevity research project",
167            "confidentiality_years": 3,
168        }),
169        "ncnda" => serde_json::json!({
170            "mutuality": "mutual",
171            "disclosing_side": "both",
172            "purpose": "the introduction of prospective lenders and buyers in connection with a proposed asset financing",
173            "confidentiality_years": 3,
174            "non_circumvention_months": "24",
175            "commission_text": "a commission as separately agreed in writing between the parties",
176        }),
177        "loan" => serde_json::json!({
178            "purpose": "a personal loan between the parties",
179            "principal_text": "S$10,000 (ten thousand Singapore dollars)",
180            "interest_text": "interest-free",
181            "repayment_date": "1 December 2026",
182        }),
183        "consulting" => serde_json::json!({
184            "purpose": "the design and delivery of a customer-facing dashboard for the Client's flagship product",
185            "deliverables": [
186                "Discovery interviews and a one-page strategy memo",
187                "Three rounds of high-fidelity Figma designs",
188                "Production-ready front-end implementation in React",
189                "One handover session with the Client's engineering team",
190            ],
191            "ip_assignment": "client",
192            "confidentiality_years": 3,
193            "termination_notice_days": 30,
194        }),
195        "msa" => serde_json::json!({
196            "ip_assignment": "client",
197            "confidentiality_years": 3,
198            "termination_notice_days": 30,
199        }),
200        "sow" => serde_json::json!({
201            "purpose": "Implementation of the customer dashboard described in the kick-off memo dated 2026-04-01.",
202            "deliverables": [
203                "Functional prototype by week 4",
204                "Production deployment by week 10",
205            ],
206        }),
207        "service" => serde_json::json!({
208            "purpose": "managed hosting and monthly performance reviews for the Customer's web application",
209            "deliverables": [
210                "24/7 monitoring of production endpoints",
211                "Monthly performance review and recommendations",
212            ],
213            "termination_notice_days": 60,
214        }),
215        _ => serde_json::json!({}),
216    };
217    let (fee_type, fee_minor, fee_cur, fee_sched) = match kind {
218        "consulting" => (Some("fixed".into()), Some(84_000_00i64), Some("SGD".into()), Some("on-completion".into())),
219        "msa" => (None, None, None, None),
220        "sow" => (Some("fixed".into()), Some(34_000_00i64), Some("SGD".into()), Some("on-milestone".into())),
221        "service" => (Some("retainer".into()), Some(5_000_00i64), Some("SGD".into()), Some("monthly".into())),
222        _ => (None, None, None, None),
223    };
224    let title = match kind {
225        "nda" => "Mutual NDA — Acme × Meridian".into(),
226        "ncnda" => "NCNDA — Acme × Meridian".into(),
227        "consulting" => "Customer Dashboard — Consulting Engagement".into(),
228        "msa" => "Master Services Agreement — Acme × Meridian".into(),
229        "sow" => "SOW #1 — Customer Dashboard Implementation".into(),
230        "service" => "Managed Hosting & Performance Reviews".into(),
231        "loan" => "Loan Agreement — Acme × Meridian".into(),
232        _ => format!("Sample {kind} agreement"),
233    };
234    Contract {
235        id: 0,
236        number: format!("{}-acme-2026-0001", prefix_for(kind)),
237        kind: kind.to_string(),
238        issuer_id: 0,
239        client_id: 0,
240        title,
241        effective_date: today,
242        end_date: None,
243        term_months: Some(12),
244        governing_law: "Singapore".into(),
245        venue: None,
246        status: "draft".into(),
247        sent_at: None,
248        signed_at: None,
249        terminated_at: None,
250        notes: None,
251        fee_type,
252        fee_amount_minor: fee_minor,
253        fee_currency: fee_cur,
254        fee_schedule: fee_sched,
255        terms_json: terms.to_string(),
256        clause_pack: "standard".into(),
257        clause_pack_version: "1.0".into(),
258        default_template: None,
259        signed_by_us_name: None,
260        signed_by_us_title: None,
261        signed_by_us_at: None,
262        signed_by_them_name: None,
263        signed_by_them_title: None,
264        signed_by_them_at: None,
265        created_at: String::new(),
266        updated_at: String::new(),
267    }
268}
269
270fn prefix_for(kind: &str) -> &'static str {
271    crate::kinds::prefix_for(kind)
272}