Skip to main content

contract_cli/
clauses.rs

1// ═══════════════════════════════════════════════════════════════════════════
2// Clauses — embedded clause packs (per kind × pack slug) + variable expansion.
3//
4// Packs live in `clauses/<kind>/<pack>.toml` and are baked into the binary
5// via rust-embed. Each pack lists the default clause set (slug + order) plus
6// the heading and Markdown body for every available clause. Body templates
7// substitute `{{var}}` tokens at render time using a small ContractVars dict
8// the Rust side computes from the DB row + terms JSON.
9// ═══════════════════════════════════════════════════════════════════════════
10
11use std::collections::BTreeMap;
12
13use rust_embed::RustEmbed;
14use serde::{Deserialize, Serialize};
15
16use crate::error::{AppError, Result};
17
18#[derive(RustEmbed)]
19#[folder = "clauses/"]
20pub struct PackAssets;
21
22#[derive(Debug, Clone, Deserialize)]
23pub struct Pack {
24    pub pack: PackMeta,
25    /// slug -> ClauseDef
26    pub clauses: BTreeMap<String, ClauseDef>,
27}
28
29#[derive(Debug, Clone, Deserialize)]
30pub struct PackMeta {
31    pub slug: String,
32    pub name: String,
33    pub version: String,
34    pub kind: String,
35    /// Slugs of clauses that should be included by default (in order).
36    pub default_clauses: Vec<String>,
37}
38
39#[derive(Debug, Clone, Deserialize, Serialize)]
40pub struct ClauseDef {
41    pub heading: String,
42    pub body: String,
43}
44
45#[derive(Debug, Clone, Serialize)]
46pub struct ResolvedClause {
47    pub position: i64,
48    pub slug: String,
49    pub heading: String,
50    pub body: String,
51}
52
53pub fn load_pack(kind: &str, pack_slug: &str) -> Result<Pack> {
54    let path = format!("{kind}/{pack_slug}.toml");
55    let file = PackAssets::get(&path)
56        .ok_or_else(|| AppError::NotFound(format!("clause pack '{kind}/{pack_slug}'")))?;
57    let text = std::str::from_utf8(&file.data)
58        .map_err(|e| AppError::Other(format!("non-utf8 pack {path}: {e}")))?;
59    toml::from_str::<Pack>(text)
60        .map_err(|e| AppError::Other(format!("invalid pack {path}: {e}")))
61}
62
63pub fn list_packs() -> Vec<(String, String)> {
64    PackAssets::iter()
65        .filter_map(|p| {
66            let p = p.as_ref();
67            let (kind, file) = p.split_once('/')?;
68            let pack_slug = file.strip_suffix(".toml")?;
69            Some((kind.to_string(), pack_slug.to_string()))
70        })
71        .collect()
72}
73
74/// Resolve a body template against a flat variable map. `{{name}}` tokens are
75/// replaced; unknown tokens are left in place (so previews don't blow up).
76pub fn expand(body: &str, vars: &BTreeMap<String, String>) -> String {
77    let mut out = body.to_string();
78    for (k, v) in vars {
79        let token = format!("{{{{{k}}}}}");
80        out = out.replace(&token, v);
81    }
82    out
83}
84
85/// Apply a pack + overrides to produce the final ordered clause list.
86///
87/// `included` is the contract's chosen clause order (typically pack defaults
88/// modulo --include/--exclude). `overrides` maps slug -> (heading?, body?)
89/// for clauses the user has customised via `contract clauses edit`.
90pub fn resolve(
91    pack: &Pack,
92    included: &[String],
93    overrides: &BTreeMap<String, (Option<String>, Option<String>)>,
94    vars: &BTreeMap<String, String>,
95) -> Result<Vec<ResolvedClause>> {
96    let mut out = Vec::with_capacity(included.len());
97    for (i, slug) in included.iter().enumerate() {
98        let def = pack
99            .clauses
100            .get(slug)
101            .ok_or_else(|| AppError::NotFound(format!("clause '{slug}' not in pack")))?;
102        let (head_override, body_override) = overrides
103            .get(slug)
104            .cloned()
105            .unwrap_or((None, None));
106        let heading = head_override.unwrap_or_else(|| def.heading.clone());
107        let body_template = body_override.unwrap_or_else(|| def.body.clone());
108        out.push(ResolvedClause {
109            position: i as i64,
110            slug: slug.clone(),
111            heading: expand(&heading, vars),
112            body: expand(&body_template, vars),
113        });
114    }
115    Ok(out)
116}