Skip to main content

contract_cli/commands/
contracts.rs

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
57// ─── new ─────────────────────────────────────────────────────────────────
58
59const 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    // Resolve client first — needed for default issuer
72    let client = db::client_by_slug(&conn, &args.client)?;
73    // Resolve issuer: --as > client.default_issuer > config.default_issuer
74    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    // Parse fee
119    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    // Build terms_json
129    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    // Pick clause pack (default: "standard")
169    let pack_slug = args.pack.clone().unwrap_or_else(|| "standard".to_string());
170    let pack = clauses::load_pack(&args.kind, &pack_slug)?;
171
172    // Build the included clause list with --include / --exclude
173    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    // Generate number
203    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    // allow trailing "/month" etc — ignore for now
289    let amount_minor = (amount_major * 100.0).round() as i64;
290    Ok((kind, amount_minor, currency))
291}
292
293// ─── list ─────────────────────────────────────────────────────────────────
294
295fn 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// ─── show ─────────────────────────────────────────────────────────────────
319
320#[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// ─── edit ─────────────────────────────────────────────────────────────────
363
364#[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
412// ─── render ───────────────────────────────────────────────────────────────
413
414fn 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
480// ─── mark / sign ──────────────────────────────────────────────────────────
481
482fn 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
513// ─── duplicate / delete ───────────────────────────────────────────────────
514
515fn 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}