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
63fn humanize_slug(slug: &str) -> String {
64    slug.split(['_', '-'])
65        .filter(|w| !w.is_empty())
66        .enumerate()
67        .map(|(i, w)| {
68            if i == 0 {
69                let mut c = w.chars();
70                c.next()
71                    .map(|f| f.to_uppercase().collect::<String>() + c.as_str())
72                    .unwrap_or_default()
73            } else {
74                w.to_string()
75            }
76        })
77        .collect::<Vec<_>>()
78        .join(" ")
79}
80
81pub fn list_packs() -> Vec<(String, String)> {
82    PackAssets::iter()
83        .filter_map(|p| {
84            let p = p.as_ref();
85            let (kind, file) = p.split_once('/')?;
86            let pack_slug = file.strip_suffix(".toml")?;
87            Some((kind.to_string(), pack_slug.to_string()))
88        })
89        .collect()
90}
91
92/// Resolve a body template against a flat variable map. `{{name}}` tokens are
93/// replaced; unknown tokens are left in place (so previews don't blow up).
94/// Single-pass scan: substituted values are never themselves re-expanded, so
95/// a value containing `{{...}}` text renders literally instead of recursing.
96pub fn expand(body: &str, vars: &BTreeMap<String, String>) -> String {
97    let mut out = String::with_capacity(body.len());
98    let mut rest = body;
99    while let Some(start) = rest.find("{{") {
100        out.push_str(&rest[..start]);
101        let after = &rest[start + 2..];
102        match after.find("}}") {
103            Some(end) => {
104                let key = &after[..end];
105                match vars.get(key) {
106                    Some(v) => out.push_str(v),
107                    None => {
108                        out.push_str("{{");
109                        out.push_str(key);
110                        out.push_str("}}");
111                    }
112                }
113                rest = &after[end + 2..];
114            }
115            None => {
116                out.push_str("{{");
117                rest = after;
118            }
119        }
120    }
121    out.push_str(rest);
122    out
123}
124
125/// Apply a pack + overrides to produce the final ordered clause list.
126///
127/// `included` is the contract's chosen clause order (typically pack defaults
128/// modulo --include/--exclude). `overrides` maps slug -> (heading?, body?)
129/// for clauses the user has customised via `contract clauses edit`.
130pub fn resolve(
131    pack: &Pack,
132    included: &[String],
133    overrides: &BTreeMap<String, (Option<String>, Option<String>)>,
134    vars: &BTreeMap<String, String>,
135) -> Result<Vec<ResolvedClause>> {
136    let mut out = Vec::with_capacity(included.len());
137    for (i, slug) in included.iter().enumerate() {
138        let def = pack.clauses.get(slug);
139        let (head_override, body_override) = overrides
140            .get(slug)
141            .cloned()
142            .unwrap_or((None, None));
143        // Custom clauses (added with --body / --from-file) don't exist in the
144        // pack — their heading/body live entirely in the override.
145        let heading = match (head_override, def) {
146            (Some(h), _) => h,
147            (None, Some(d)) => d.heading.clone(),
148            (None, None) => humanize_slug(slug),
149        };
150        let body_template = match (body_override, def) {
151            (Some(b), _) => b,
152            (None, Some(d)) => d.body.clone(),
153            (None, None) => {
154                return Err(AppError::NotFound(format!(
155                    "clause '{slug}' is not in the pack and has no custom body. \
156                     Set one with: contract clauses edit <number> {slug} --body \"…\""
157                )))
158            }
159        };
160        out.push(ResolvedClause {
161            position: i as i64,
162            slug: slug.clone(),
163            heading: expand(&heading, vars),
164            body: expand(&body_template, vars),
165        });
166    }
167    Ok(out)
168}