mdvault_core/frontmatter/
modifier.rs1use super::types::{
4 Frontmatter, FrontmatterOp, FrontmatterOpType, FrontmatterOps, ParsedDocument,
5};
6use regex::Regex;
7use serde_yaml::Value;
8use std::collections::HashMap;
9use thiserror::Error;
10
11#[derive(Debug, Error)]
13pub enum FrontmatterModifyError {
14 #[error("field '{0}' is not a boolean, cannot toggle")]
15 NotBoolean(String),
16 #[error("field '{0}' is not a number, cannot increment")]
17 NotNumber(String),
18 #[error("field '{0}' is not a list, cannot append")]
19 NotList(String),
20}
21
22pub fn apply_ops(
24 mut doc: ParsedDocument,
25 ops: &FrontmatterOps,
26 render_ctx: &HashMap<String, String>,
27) -> Result<ParsedDocument, FrontmatterModifyError> {
28 if doc.frontmatter.is_none() {
30 doc.frontmatter = Some(Frontmatter::default());
31 }
32 let fm = doc.frontmatter.as_mut().unwrap();
33
34 match ops {
35 FrontmatterOps::Simple(map) => {
36 for (field, value) in map {
37 let rendered_value = render_value(value, render_ctx);
38 fm.fields.insert(field.clone(), rendered_value);
39 }
40 }
41 FrontmatterOps::Operations(op_list) => {
42 for op in op_list {
43 apply_single_op(fm, op, render_ctx)?;
44 }
45 }
46 }
47
48 Ok(doc)
49}
50
51fn apply_single_op(
53 fm: &mut Frontmatter,
54 op: &FrontmatterOp,
55 render_ctx: &HashMap<String, String>,
56) -> Result<(), FrontmatterModifyError> {
57 match &op.op {
58 FrontmatterOpType::Set => {
59 if let Some(value) = &op.value {
60 let rendered = render_value(value, render_ctx);
61 fm.fields.insert(op.field.clone(), rendered);
62 }
63 }
64 FrontmatterOpType::Toggle => {
65 let current = fm.fields.get(&op.field);
66 match current {
67 Some(Value::Bool(b)) => {
68 fm.fields.insert(op.field.clone(), Value::Bool(!b));
69 }
70 None => {
71 fm.fields.insert(op.field.clone(), Value::Bool(true));
73 }
74 _ => return Err(FrontmatterModifyError::NotBoolean(op.field.clone())),
75 }
76 }
77 FrontmatterOpType::Increment => {
78 let current = fm.fields.get(&op.field).cloned();
79 let increment = op.value.as_ref().and_then(|v| v.as_i64()).unwrap_or(1);
80
81 match current {
82 Some(Value::Number(n)) => {
83 let new_val = n.as_i64().unwrap_or(0) + increment;
84 fm.fields.insert(op.field.clone(), Value::Number(new_val.into()));
85 }
86 None => {
87 fm.fields.insert(op.field.clone(), Value::Number(increment.into()));
88 }
89 _ => return Err(FrontmatterModifyError::NotNumber(op.field.clone())),
90 }
91 }
92 FrontmatterOpType::Append => {
93 let current = fm.fields.get(&op.field).cloned();
94 let append_val = op
95 .value
96 .as_ref()
97 .map(|v| render_value(v, render_ctx))
98 .unwrap_or(Value::Null);
99
100 match current {
101 Some(Value::Sequence(mut seq)) => {
102 seq.push(append_val);
103 fm.fields.insert(op.field.clone(), Value::Sequence(seq));
104 }
105 None => {
106 fm.fields.insert(op.field.clone(), Value::Sequence(vec![append_val]));
107 }
108 _ => return Err(FrontmatterModifyError::NotList(op.field.clone())),
109 }
110 }
111 }
112 Ok(())
113}
114
115fn render_value(value: &Value, ctx: &HashMap<String, String>) -> Value {
117 match value {
118 Value::String(s) => {
119 let rendered = render_string(s, ctx);
120 Value::String(rendered)
121 }
122 Value::Mapping(map) => {
124 let rendered_map: serde_yaml::Mapping =
125 map.iter().map(|(k, v)| (k.clone(), render_value(v, ctx))).collect();
126 Value::Mapping(rendered_map)
127 }
128 Value::Sequence(seq) => {
129 Value::Sequence(seq.iter().map(|v| render_value(v, ctx)).collect())
130 }
131 _ => value.clone(),
132 }
133}
134
135fn render_string(template: &str, ctx: &HashMap<String, String>) -> String {
137 let re = Regex::new(r"\{\{([a-zA-Z0-9_]+)\}\}").unwrap();
138 re.replace_all(template, |caps: ®ex::Captures<'_>| {
139 let key = &caps[1];
140 ctx.get(key).cloned().unwrap_or_else(|| caps[0].to_string())
141 })
142 .into_owned()
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use crate::frontmatter::parser::parse;
149
150 fn make_ctx() -> HashMap<String, String> {
151 let mut ctx = HashMap::new();
152 ctx.insert("date".to_string(), "2024-01-15".to_string());
153 ctx.insert("time".to_string(), "14:30".to_string());
154 ctx
155 }
156
157 #[test]
158 fn test_simple_set() {
159 let content = "---\ntitle: Old\n---\n# Content";
160 let doc = parse(content).unwrap();
161
162 let mut ops_map = HashMap::new();
163 ops_map.insert("title".to_string(), Value::String("New".to_string()));
164 ops_map.insert("added".to_string(), Value::Bool(true));
165
166 let ops = FrontmatterOps::Simple(ops_map);
167 let result = apply_ops(doc, &ops, &make_ctx()).unwrap();
168
169 let fm = result.frontmatter.unwrap();
170 assert_eq!(fm.fields.get("title").and_then(|v| v.as_str()), Some("New"));
171 assert_eq!(fm.fields.get("added").and_then(|v| v.as_bool()), Some(true));
172 }
173
174 #[test]
175 fn test_toggle_existing_true() {
176 let content = "---\nflag: true\n---\n# Content";
177 let doc = parse(content).unwrap();
178
179 let ops = FrontmatterOps::Operations(vec![FrontmatterOp {
180 field: "flag".to_string(),
181 op: FrontmatterOpType::Toggle,
182 value: None,
183 }]);
184
185 let result = apply_ops(doc, &ops, &make_ctx()).unwrap();
186 let fm = result.frontmatter.unwrap();
187 assert_eq!(fm.fields.get("flag").and_then(|v| v.as_bool()), Some(false));
188 }
189
190 #[test]
191 fn test_toggle_missing_field() {
192 let content = "---\nother: value\n---\n# Content";
193 let doc = parse(content).unwrap();
194
195 let ops = FrontmatterOps::Operations(vec![FrontmatterOp {
196 field: "flag".to_string(),
197 op: FrontmatterOpType::Toggle,
198 value: None,
199 }]);
200
201 let result = apply_ops(doc, &ops, &make_ctx()).unwrap();
202 let fm = result.frontmatter.unwrap();
203 assert_eq!(fm.fields.get("flag").and_then(|v| v.as_bool()), Some(true));
204 }
205
206 #[test]
207 fn test_increment() {
208 let content = "---\ncount: 5\n---\n# Content";
209 let doc = parse(content).unwrap();
210
211 let ops = FrontmatterOps::Operations(vec![FrontmatterOp {
212 field: "count".to_string(),
213 op: FrontmatterOpType::Increment,
214 value: None, }]);
216
217 let result = apply_ops(doc, &ops, &make_ctx()).unwrap();
218 let fm = result.frontmatter.unwrap();
219 assert_eq!(fm.fields.get("count").and_then(|v| v.as_i64()), Some(6));
220 }
221
222 #[test]
223 fn test_increment_with_value() {
224 let content = "---\ncount: 10\n---\n# Content";
225 let doc = parse(content).unwrap();
226
227 let ops = FrontmatterOps::Operations(vec![FrontmatterOp {
228 field: "count".to_string(),
229 op: FrontmatterOpType::Increment,
230 value: Some(Value::Number(5.into())),
231 }]);
232
233 let result = apply_ops(doc, &ops, &make_ctx()).unwrap();
234 let fm = result.frontmatter.unwrap();
235 assert_eq!(fm.fields.get("count").and_then(|v| v.as_i64()), Some(15));
236 }
237
238 #[test]
239 fn test_append_to_existing_list() {
240 let content = "---\nitems:\n - one\n - two\n---\n# Content";
241 let doc = parse(content).unwrap();
242
243 let ops = FrontmatterOps::Operations(vec![FrontmatterOp {
244 field: "items".to_string(),
245 op: FrontmatterOpType::Append,
246 value: Some(Value::String("three".to_string())),
247 }]);
248
249 let result = apply_ops(doc, &ops, &make_ctx()).unwrap();
250 let fm = result.frontmatter.unwrap();
251 let items = fm.fields.get("items").unwrap().as_sequence().unwrap();
252 assert_eq!(items.len(), 3);
253 assert_eq!(items[2].as_str(), Some("three"));
254 }
255
256 #[test]
257 fn test_append_to_new_list() {
258 let content = "---\nother: value\n---\n# Content";
259 let doc = parse(content).unwrap();
260
261 let ops = FrontmatterOps::Operations(vec![FrontmatterOp {
262 field: "items".to_string(),
263 op: FrontmatterOpType::Append,
264 value: Some(Value::String("first".to_string())),
265 }]);
266
267 let result = apply_ops(doc, &ops, &make_ctx()).unwrap();
268 let fm = result.frontmatter.unwrap();
269 let items = fm.fields.get("items").unwrap().as_sequence().unwrap();
270 assert_eq!(items.len(), 1);
271 assert_eq!(items[0].as_str(), Some("first"));
272 }
273
274 #[test]
275 fn test_variable_substitution() {
276 let content = "---\n---\n# Content";
277 let doc = parse(content).unwrap();
278
279 let mut ops_map = HashMap::new();
280 ops_map.insert(
281 "modified".to_string(),
282 Value::String("{{date}} at {{time}}".to_string()),
283 );
284
285 let ops = FrontmatterOps::Simple(ops_map);
286 let result = apply_ops(doc, &ops, &make_ctx()).unwrap();
287
288 let fm = result.frontmatter.unwrap();
289 assert_eq!(
290 fm.fields.get("modified").and_then(|v| v.as_str()),
291 Some("2024-01-15 at 14:30")
292 );
293 }
294
295 #[test]
296 fn test_creates_frontmatter_if_missing() {
297 let doc =
298 ParsedDocument { frontmatter: None, body: "# No frontmatter".to_string() };
299
300 let mut ops_map = HashMap::new();
301 ops_map.insert("new_field".to_string(), Value::Bool(true));
302
303 let ops = FrontmatterOps::Simple(ops_map);
304 let result = apply_ops(doc, &ops, &make_ctx()).unwrap();
305
306 assert!(result.frontmatter.is_some());
307 let fm = result.frontmatter.unwrap();
308 assert_eq!(fm.fields.get("new_field").and_then(|v| v.as_bool()), Some(true));
309 }
310}