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}