1use 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 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 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
92pub 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
125pub 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 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}