Skip to main content

mdvault_core/frontmatter/
modifier.rs

1//! Frontmatter modification operations.
2
3use 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/// Errors that can occur during frontmatter modification.
12#[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
22/// Apply frontmatter operations to a document.
23pub fn apply_ops(
24    mut doc: ParsedDocument,
25    ops: &FrontmatterOps,
26    render_ctx: &HashMap<String, String>,
27) -> Result<ParsedDocument, FrontmatterModifyError> {
28    // Ensure frontmatter exists
29    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
51/// Apply a single frontmatter operation.
52fn 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                    // Default: toggle from false to true
72                    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
115/// Render {{var}} placeholders in YAML values.
116fn 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        // Recursively handle nested structures
123        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
135/// Render {{var}} placeholders in a string.
136fn 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: &regex::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, // Default increment of 1
215        }]);
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}