specfold_core/markdown/frontmatter/
yaml.rs1use serde_json::{Map, Value};
4use yaml_rust2::{Yaml, YamlLoader};
5
6#[derive(Debug)]
7pub enum Parsed {
8 None,
9 Map(Map<String, Value>),
10 Invalid(String),
11}
12
13pub fn parse(raw: Option<&str>) -> Parsed {
14 let Some(raw) = raw else { return Parsed::None };
15 if raw.trim().is_empty() {
16 return Parsed::Map(Map::new());
17 }
18 match YamlLoader::load_from_str(raw) {
19 Ok(docs) => match docs.into_iter().next() {
20 Some(y @ Yaml::Hash(_)) => match to_json(&y) {
21 Value::Object(m) => Parsed::Map(m),
22 _ => Parsed::Invalid("frontmatter is not a mapping".into()),
23 },
24 Some(Yaml::Null) | None => Parsed::Map(Map::new()),
25 Some(_) => Parsed::Invalid("frontmatter is not a mapping".into()),
26 },
27 Err(e) => Parsed::Invalid(e.to_string()),
28 }
29}
30
31pub fn to_json(y: &Yaml) -> Value {
32 match y {
33 Yaml::Real(s) => s
34 .parse::<f64>()
35 .ok()
36 .and_then(serde_json::Number::from_f64)
37 .map(Value::Number)
38 .unwrap_or_else(|| Value::String(s.clone())),
39 Yaml::Integer(i) => Value::Number((*i).into()),
40 Yaml::String(s) => Value::String(s.clone()),
41 Yaml::Boolean(b) => Value::Bool(*b),
42 Yaml::Array(a) => Value::Array(a.iter().map(to_json).collect()),
43 Yaml::Hash(h) => Value::Object(
44 h.iter()
45 .filter_map(|(k, v)| {
46 let key = match k {
47 Yaml::String(s) | Yaml::Real(s) => s.clone(),
48 Yaml::Integer(i) => i.to_string(),
49 Yaml::Boolean(b) => b.to_string(),
50 _ => return None,
51 };
52 Some((key, to_json(v)))
53 })
54 .collect(),
55 ),
56 Yaml::Null | Yaml::BadValue | Yaml::Alias(_) => Value::Null,
57 }
58}
59
60#[cfg(test)]
61mod tests {
62 use super::*;
63
64 #[test]
65 fn nested_and_invalid() {
66 let Parsed::Map(m) = parse(Some("id: a\nrel:\n governs: [\"services/shop/**\"]\n")) else { panic!() };
67 assert_eq!(m["rel"]["governs"][0], "services/shop/**");
68 assert!(matches!(parse(Some("a: [unclosed\n")), Parsed::Invalid(_)));
69 assert!(matches!(parse(Some("- a\n- b\n")), Parsed::Invalid(_)));
70 }
71}