1use std::collections::BTreeSet;
2use std::path::PathBuf;
3use std::process::Command;
4
5use chrono::{Datelike, NaiveDate, Utc};
6use serde_json::{json, Value};
7
8use crate::cli::{ContractCmd, ContractListArgs, ContractNewArgs, ContractRenderArgs, SignArgs};
9use crate::clauses;
10use crate::db::{self, Contract, ContractClauseRow};
11use crate::error::{AppError, Result};
12use crate::output::{print_success, Ctx};
13use crate::render;
14
15pub fn run(cmd: ContractCmd, ctx: Ctx) -> Result<()> {
16 match cmd {
17 ContractCmd::New(args) => cmd_new(args, ctx),
18 ContractCmd::List(args) => cmd_list(args, ctx),
19 ContractCmd::Show { number } => cmd_show(&number, ctx),
20 ContractCmd::Edit {
21 number,
22 client,
23 title,
24 effective,
25 end,
26 term_months,
27 governing_law,
28 venue,
29 fee,
30 fee_schedule,
31 notes,
32 template,
33 } => cmd_edit(
34 number,
35 client,
36 title,
37 effective,
38 end,
39 term_months,
40 governing_law,
41 venue,
42 fee,
43 fee_schedule,
44 notes,
45 template,
46 ctx,
47 ),
48 ContractCmd::Render(args) => cmd_render(args, ctx),
49 ContractCmd::Mark { number, status } => cmd_mark(&number, &status, ctx),
50 ContractCmd::Sign(args) => cmd_sign(args, ctx),
51 ContractCmd::Clauses(cmd) => super::clauses::run(cmd, ctx),
52 ContractCmd::Duplicate { number, client, r#as } => cmd_duplicate(&number, client, r#as, ctx),
53 ContractCmd::Delete { number, force } => cmd_delete(&number, force, ctx),
54 }
55}
56
57const KINDS: &[&str] = &["consulting", "nda", "msa", "sow", "service"];
60
61fn cmd_new(args: ContractNewArgs, ctx: Ctx) -> Result<()> {
62 if !KINDS.contains(&args.kind.as_str()) {
63 return Err(AppError::InvalidInput(format!(
64 "unknown kind '{}'. Expected one of: {}",
65 args.kind,
66 KINDS.join(", ")
67 )));
68 }
69 let mut conn = db::open()?;
70
71 let client = db::client_by_slug(&conn, &args.client)?;
73 let issuer_slug = args
75 .r#as
76 .or(client.default_issuer_slug.clone())
77 .or_else(|| crate::config::load().ok().and_then(|c| c.default_issuer))
78 .ok_or_else(|| {
79 AppError::InvalidInput(
80 "no issuer — pass --as <slug>, pin a default on the client, or set config.default_issuer".into(),
81 )
82 })?;
83 let issuer = db::issuer_by_slug(&conn, &issuer_slug)?;
84
85 let effective_iso = match args.effective {
86 Some(s) => parse_date(&s)?,
87 None => Utc::now().date_naive().format("%Y-%m-%d").to_string(),
88 };
89 let end_iso = match args.end {
90 Some(s) => Some(parse_date(&s)?),
91 None => None,
92 };
93 let term_months = match (args.term_months, args.term_years) {
94 (Some(_), Some(_)) => {
95 return Err(AppError::InvalidInput(
96 "pass at most one of --term-months / --term-years".into(),
97 ))
98 }
99 (Some(m), None) => Some(m),
100 (None, Some(y)) => Some(y * 12),
101 (None, None) => None,
102 };
103 if end_iso.is_some() && term_months.is_some() {
104 return Err(AppError::InvalidInput(
105 "--end and --term-months/--term-years are mutually exclusive".into(),
106 ));
107 }
108
109 let governing_law = args
110 .governing_law
111 .unwrap_or_else(|| issuer.jurisdiction.profile().country.to_string());
112 let venue = args.venue;
113
114 let title = args
115 .title
116 .unwrap_or_else(|| default_title(&args.kind, &issuer.name, &client.name));
117
118 let (fee_type, fee_amount_minor, fee_currency) = match args.fee.as_deref() {
120 None => (None, None, None),
121 Some(spec) => {
122 let (t, a, c) = parse_fee(spec)?;
123 (Some(t), Some(a), Some(c))
124 }
125 };
126 let fee_schedule = args.fee_schedule;
127
128 let mut terms_obj = serde_json::Map::new();
130 if let Some(p) = &args.purpose {
131 terms_obj.insert("purpose".into(), Value::String(p.clone()));
132 }
133 if let Some(n) = args.termination_notice_days {
134 terms_obj.insert("termination_notice_days".into(), json!(n));
135 }
136 if args.kind == "nda" {
137 terms_obj.insert(
138 "mutuality".into(),
139 Value::String(args.mutuality.clone().unwrap_or_else(|| "mutual".into())),
140 );
141 terms_obj.insert(
142 "disclosing_side".into(),
143 Value::String(args.disclosing_side.clone().unwrap_or_else(|| {
144 if args.mutuality.as_deref() == Some("unilateral") {
145 "us".into()
146 } else {
147 "both".into()
148 }
149 })),
150 );
151 terms_obj.insert("confidentiality_years".into(), json!(3));
152 } else if matches!(args.kind.as_str(), "consulting" | "msa" | "sow" | "service") {
153 if !args.deliverables.is_empty() {
154 terms_obj.insert(
155 "deliverables".into(),
156 Value::Array(args.deliverables.iter().cloned().map(Value::String).collect()),
157 );
158 }
159 if let Some(ip) = &args.ip_assignment {
160 terms_obj.insert("ip_assignment".into(), Value::String(ip.clone()));
161 }
162 terms_obj
163 .entry("confidentiality_years".to_string())
164 .or_insert(json!(3));
165 }
166 let terms_json = Value::Object(terms_obj).to_string();
167
168 let pack_slug = args.pack.clone().unwrap_or_else(|| "standard".to_string());
170 let pack = clauses::load_pack(&args.kind, &pack_slug)?;
171
172 let mut included: Vec<String> = pack.pack.default_clauses.clone();
174 let mut seen: BTreeSet<String> = included.iter().cloned().collect();
175 for slug in &args.include {
176 if !pack.clauses.contains_key(slug) {
177 return Err(AppError::NotFound(format!(
178 "clause '{slug}' is not defined in pack '{}/{pack_slug}'",
179 args.kind
180 )));
181 }
182 if seen.insert(slug.clone()) {
183 included.push(slug.clone());
184 }
185 }
186 for slug in &args.exclude {
187 included.retain(|s| s != slug);
188 }
189 let clause_rows: Vec<ContractClauseRow> = included
190 .iter()
191 .enumerate()
192 .map(|(i, slug)| ContractClauseRow {
193 id: 0,
194 contract_id: 0,
195 position: i as i64,
196 slug: slug.clone(),
197 heading: None,
198 body: None,
199 })
200 .collect();
201
202 let year = NaiveDate::parse_from_str(&effective_iso, "%Y-%m-%d")
204 .map(|d| d.year())
205 .unwrap_or_else(|_| Utc::now().year());
206 let number = db::next_contract_number(&conn, &issuer, year, &args.kind)?;
207
208 let contract = Contract {
209 id: 0,
210 number,
211 kind: args.kind.clone(),
212 issuer_id: issuer.id,
213 client_id: client.id,
214 title,
215 effective_date: effective_iso,
216 end_date: end_iso,
217 term_months,
218 governing_law,
219 venue,
220 status: "draft".into(),
221 sent_at: None,
222 signed_at: None,
223 terminated_at: None,
224 notes: args.notes,
225 fee_type,
226 fee_amount_minor,
227 fee_currency,
228 fee_schedule,
229 terms_json,
230 clause_pack: pack_slug,
231 clause_pack_version: pack.pack.version.clone(),
232 default_template: args.template,
233 signed_by_us_name: None,
234 signed_by_us_title: None,
235 signed_by_us_at: None,
236 signed_by_them_name: None,
237 signed_by_them_title: None,
238 signed_by_them_at: None,
239 created_at: String::new(),
240 updated_at: String::new(),
241 };
242
243 let _id = db::contract_create(&mut conn, &contract, &clause_rows)?;
244 let saved = db::contract_get(&conn, &contract.number)?;
245 print_success(ctx, &saved, |c| {
246 println!(
247 "created {} contract '{}' for {} ({} clauses)",
248 c.kind, c.number, c.title, clause_rows.len()
249 );
250 });
251 Ok(())
252}
253
254fn default_title(kind: &str, issuer_name: &str, client_name: &str) -> String {
255 match kind {
256 "nda" => format!("NDA — {issuer_name} & {client_name}"),
257 "consulting" => format!("Consulting Agreement — {issuer_name} × {client_name}"),
258 "msa" => format!("Master Services Agreement — {issuer_name} & {client_name}"),
259 "sow" => format!("Statement of Work — {issuer_name} × {client_name}"),
260 "service" => format!("Service Agreement — {issuer_name} for {client_name}"),
261 _ => format!("Agreement — {issuer_name} & {client_name}"),
262 }
263}
264
265fn parse_date(s: &str) -> Result<String> {
266 NaiveDate::parse_from_str(s, "%Y-%m-%d")
267 .map(|d| d.format("%Y-%m-%d").to_string())
268 .map_err(|_| AppError::InvalidInput(format!("invalid date '{s}' — expected YYYY-MM-DD")))
269}
270
271fn parse_fee(spec: &str) -> Result<(String, i64, String)> {
272 let parts: Vec<&str> = spec.split(':').collect();
273 if parts.len() < 3 {
274 return Err(AppError::InvalidInput(format!(
275 "fee spec '{spec}' — expected type:amount:currency (e.g. fixed:8400:SGD)"
276 )));
277 }
278 let kind = parts[0].to_lowercase();
279 if !matches!(kind.as_str(), "fixed" | "hourly" | "daily" | "retainer") {
280 return Err(AppError::InvalidInput(format!(
281 "unknown fee type '{kind}' (expected fixed | hourly | daily | retainer)"
282 )));
283 }
284 let amount_major: f64 = parts[1].parse().map_err(|_| {
285 AppError::InvalidInput(format!("invalid fee amount '{}': must be a number", parts[1]))
286 })?;
287 let currency = parts[2].to_uppercase();
288 let amount_minor = (amount_major * 100.0).round() as i64;
290 Ok((kind, amount_minor, currency))
291}
292
293fn cmd_list(args: ContractListArgs, ctx: Ctx) -> Result<()> {
296 let conn = db::open()?;
297 let rows = db::contract_list(
298 &conn,
299 args.kind.as_deref(),
300 args.status.as_deref(),
301 args.issuer.as_deref(),
302 )?;
303 print_success(ctx, &rows, |rs| {
304 if rs.is_empty() {
305 println!("(no contracts — create one with: contract new --kind nda --client X)");
306 } else {
307 for c in rs {
308 println!(
309 " {:<28} {:<10} {:<8} {}",
310 c.number, c.kind, c.status, c.title
311 );
312 }
313 }
314 });
315 Ok(())
316}
317
318#[derive(serde::Serialize)]
321struct ContractView<'a> {
322 #[serde(flatten)]
323 contract: &'a Contract,
324 clauses: Vec<ContractClauseRow>,
325}
326
327fn cmd_show(number: &str, ctx: Ctx) -> Result<()> {
328 let conn = db::open()?;
329 let c = db::contract_get_or_404(&conn, number)?;
330 let clauses = db::clauses_for(&conn, c.id)?;
331 let view = ContractView { contract: &c, clauses: clauses.clone() };
332 print_success(ctx, &view, |v| {
333 println!("Contract: {}", v.contract.number);
334 println!(" Kind: {}", v.contract.kind);
335 println!(" Title: {}", v.contract.title);
336 println!(" Status: {}", v.contract.status);
337 println!(" Effective: {}", v.contract.effective_date);
338 if let Some(e) = &v.contract.end_date {
339 println!(" End: {}", e);
340 }
341 if let Some(t) = v.contract.term_months {
342 println!(" Term: {} months", t);
343 }
344 println!(" Governing law: {}", v.contract.governing_law);
345 if let Some(fee) = &v.contract.fee_type {
346 println!(
347 " Fee: {} {} {}",
348 fee,
349 v.contract.fee_amount_minor.map(|m| (m as f64) / 100.0).unwrap_or(0.0),
350 v.contract.fee_currency.clone().unwrap_or_default()
351 );
352 }
353 println!(" Pack: {} v{}", v.contract.clause_pack, v.contract.clause_pack_version);
354 println!(" Clauses ({}):", v.clauses.len());
355 for cl in &v.clauses {
356 println!(" {:>2}. {}", cl.position + 1, cl.slug);
357 }
358 });
359 Ok(())
360}
361
362#[allow(clippy::too_many_arguments)]
365fn cmd_edit(
366 number: String,
367 client: Option<String>,
368 title: Option<String>,
369 effective: Option<String>,
370 end: Option<String>,
371 term_months: Option<i64>,
372 governing_law: Option<String>,
373 venue: Option<String>,
374 fee: Option<String>,
375 fee_schedule: Option<String>,
376 notes: Option<String>,
377 template: Option<String>,
378 ctx: Ctx,
379) -> Result<()> {
380 let conn = db::open()?;
381 let mut c = db::contract_get_or_404(&conn, &number)?;
382 if let Some(slug) = client {
383 c.client_id = db::client_by_slug(&conn, &slug)?.id;
384 }
385 if let Some(v) = title { c.title = v; }
386 if let Some(v) = effective { c.effective_date = parse_date(&v)?; }
387 if let Some(v) = end {
388 c.end_date = Some(parse_date(&v)?);
389 c.term_months = None;
390 }
391 if let Some(v) = term_months {
392 c.term_months = Some(v);
393 c.end_date = None;
394 }
395 if let Some(v) = governing_law { c.governing_law = v; }
396 if let Some(v) = venue { c.venue = Some(v); }
397 if let Some(v) = fee {
398 let (t, a, cur) = parse_fee(&v)?;
399 c.fee_type = Some(t);
400 c.fee_amount_minor = Some(a);
401 c.fee_currency = Some(cur);
402 }
403 if let Some(v) = fee_schedule { c.fee_schedule = Some(v); }
404 if let Some(v) = notes { c.notes = Some(v); }
405 if let Some(v) = template { c.default_template = Some(v); }
406 db::contract_update_draft(&conn, &c)?;
407 let saved = db::contract_get(&conn, &number)?;
408 print_success(ctx, &saved, |s| println!("updated draft '{}'", s.number));
409 Ok(())
410}
411
412fn cmd_render(args: ContractRenderArgs, ctx: Ctx) -> Result<()> {
415 let conn = db::open()?;
416 let c = db::contract_get_or_404(&conn, &args.number)?;
417 let issuer = db::issuer_list(&conn)?
418 .into_iter()
419 .find(|i| i.id == c.issuer_id)
420 .ok_or_else(|| AppError::NotFound(format!("issuer #{}", c.issuer_id)))?;
421 let client = db::client_list(&conn)?
422 .into_iter()
423 .find(|x| x.id == c.client_id)
424 .ok_or_else(|| AppError::NotFound(format!("client #{}", c.client_id)))?;
425 let clause_rows = db::clauses_for(&conn, c.id)?;
426 let pack = clauses::load_pack(&c.kind, &c.clause_pack)?;
427
428 let template = args
429 .template
430 .or_else(|| c.default_template.clone())
431 .unwrap_or_else(|| "helvetica-nera".to_string());
432
433 let out_path: PathBuf = match args.out {
434 Some(p) => PathBuf::from(p),
435 None => {
436 let dir = issuer
437 .default_output_dir
438 .clone()
439 .map(|s| PathBuf::from(render::expand_tilde(&s)))
440 .unwrap_or_else(render::default_output_dir);
441 std::fs::create_dir_all(&dir)?;
442 dir.join(format!("{}.pdf", c.number))
443 }
444 };
445
446 let mut data = render::build_render_data(
447 &c, &issuer, &client, &clause_rows, &pack, args.draft, args.final_render,
448 )?;
449 render::render_to_pdf(&template, &mut data, &issuer, &out_path)?;
450
451 if args.open {
452 let opener = if cfg!(target_os = "macos") {
453 "open"
454 } else {
455 "xdg-open"
456 };
457 let _ = Command::new(opener).arg(&out_path).status();
458 }
459
460 #[derive(serde::Serialize)]
461 struct Out {
462 number: String,
463 template: String,
464 out: String,
465 draft_watermark: bool,
466 }
467 let report = Out {
468 number: c.number.clone(),
469 template,
470 out: out_path.display().to_string(),
471 draft_watermark: data.draft_watermark,
472 };
473 print_success(ctx, &report, |r| {
474 let mark = if r.draft_watermark { " (DRAFT)" } else { "" };
475 println!("rendered → {}{}", r.out, mark);
476 });
477 Ok(())
478}
479
480fn cmd_mark(number: &str, status: &str, ctx: Ctx) -> Result<()> {
483 let conn = db::open()?;
484 db::contract_set_status(&conn, number, status)?;
485 let c = db::contract_get(&conn, number)?;
486 print_success(ctx, &c, |c| println!("'{}' → {}", c.number, c.status));
487 Ok(())
488}
489
490fn cmd_sign(args: SignArgs, ctx: Ctx) -> Result<()> {
491 let conn = db::open()?;
492 let date_iso = match args.date {
493 Some(s) => parse_date(&s)?,
494 None => Utc::now().date_naive().format("%Y-%m-%d").to_string(),
495 };
496 let c = db::contract_record_signature(
497 &conn,
498 &args.number,
499 &args.side,
500 &args.name,
501 args.title.as_deref(),
502 &date_iso,
503 )?;
504 print_success(ctx, &c, |c| {
505 println!(
506 "recorded {} signature on '{}'. Status: {}.",
507 args.side, c.number, c.status
508 );
509 });
510 Ok(())
511}
512
513fn cmd_duplicate(
516 number: &str,
517 client: Option<String>,
518 r#as: Option<String>,
519 ctx: Ctx,
520) -> Result<()> {
521 let mut conn = db::open()?;
522 let src = db::contract_get_or_404(&conn, number)?;
523 let src_clauses = db::clauses_for(&conn, src.id)?;
524
525 let issuer = match r#as {
526 Some(slug) => db::issuer_by_slug(&conn, &slug)?,
527 None => db::issuer_list(&conn)?
528 .into_iter()
529 .find(|i| i.id == src.issuer_id)
530 .ok_or_else(|| AppError::NotFound(format!("issuer #{}", src.issuer_id)))?,
531 };
532 let client_id = match client {
533 Some(slug) => db::client_by_slug(&conn, &slug)?.id,
534 None => src.client_id,
535 };
536 let year = Utc::now().year();
537 let new_number = db::next_contract_number(&conn, &issuer, year, &src.kind)?;
538 let today = Utc::now().date_naive().format("%Y-%m-%d").to_string();
539 let new_contract = Contract {
540 id: 0,
541 number: new_number.clone(),
542 kind: src.kind.clone(),
543 issuer_id: issuer.id,
544 client_id,
545 title: src.title.clone(),
546 effective_date: today,
547 end_date: src.end_date.clone(),
548 term_months: src.term_months,
549 governing_law: src.governing_law.clone(),
550 venue: src.venue.clone(),
551 status: "draft".into(),
552 sent_at: None,
553 signed_at: None,
554 terminated_at: None,
555 notes: src.notes.clone(),
556 fee_type: src.fee_type.clone(),
557 fee_amount_minor: src.fee_amount_minor,
558 fee_currency: src.fee_currency.clone(),
559 fee_schedule: src.fee_schedule.clone(),
560 terms_json: src.terms_json.clone(),
561 clause_pack: src.clause_pack.clone(),
562 clause_pack_version: src.clause_pack_version.clone(),
563 default_template: src.default_template.clone(),
564 signed_by_us_name: None,
565 signed_by_us_title: None,
566 signed_by_us_at: None,
567 signed_by_them_name: None,
568 signed_by_them_title: None,
569 signed_by_them_at: None,
570 created_at: String::new(),
571 updated_at: String::new(),
572 };
573 let fresh_clauses: Vec<ContractClauseRow> = src_clauses
574 .into_iter()
575 .map(|c| ContractClauseRow {
576 id: 0,
577 contract_id: 0,
578 position: c.position,
579 slug: c.slug,
580 heading: c.heading,
581 body: c.body,
582 })
583 .collect();
584 db::contract_create(&mut conn, &new_contract, &fresh_clauses)?;
585 let saved = db::contract_get(&conn, &new_number)?;
586 print_success(ctx, &saved, |c| {
587 println!("duplicated '{}' → '{}'", number, c.number);
588 });
589 Ok(())
590}
591
592fn cmd_delete(number: &str, force: bool, ctx: Ctx) -> Result<()> {
593 let conn = db::open()?;
594 db::contract_delete(&conn, number, force)?;
595 print_success(ctx, &number, |n| println!("deleted contract '{n}'"));
596 Ok(())
597}