Skip to main content

prov_graph/
meta.rs

1//! Embedded-metadata values — a dynamic, order-preserving value tree over `fig`.
2//!
3//! This is prov's *common currency*: link fields are configurable, so the
4//! metadata is accessed dynamically rather than through a fixed struct. The
5//! parse/serialize paths are serde-free — they walk `fig`'s native value tree —
6//! mirroring the proven approach in `diaryx_core`'s `yaml` module.
7//!
8//! The functions here are format-parametric: the caller passes the
9//! [`fig::Format`] the block is written in, resolved from the detected embed
10//! archetype via [`fig::EmbedType::inner_format`] (see `document`). Which
11//! formats are compiled in is governed by prov's forwarded `fig` feature
12//! gates (`yaml`, `json`, `fig`).
13
14use indexmap::IndexMap;
15
16use crate::error::Result;
17
18/// A dynamic metadata value. Integers and floats are kept distinct, and
19/// mappings preserve key order (frontmatter is order-significant to humans).
20#[derive(Debug, Clone, PartialEq, Default)]
21pub enum Value {
22    /// Null (`~`, `null`), and the [`Default`].
23    #[default]
24    Null,
25    /// Boolean.
26    Bool(bool),
27    /// Integer.
28    Int(i64),
29    /// Float.
30    Float(f64),
31    /// String.
32    String(String),
33    /// Sequence (`- item`).
34    Sequence(Vec<Value>),
35    /// Mapping (`key: value`), key order preserved.
36    Mapping(Mapping),
37}
38
39/// An order-preserving metadata mapping — the shape of a frontmatter block.
40pub type Mapping = IndexMap<String, Value>;
41
42impl Value {
43    /// The string, if this is a [`Value::String`].
44    pub fn as_str(&self) -> Option<&str> {
45        match self {
46            Value::String(s) => Some(s),
47            _ => None,
48        }
49    }
50
51    /// The boolean, if this is a [`Value::Bool`].
52    pub fn as_bool(&self) -> Option<bool> {
53        match self {
54            Value::Bool(b) => Some(*b),
55            _ => None,
56        }
57    }
58
59    /// The sequence, if this is a [`Value::Sequence`].
60    pub fn as_sequence(&self) -> Option<&[Value]> {
61        match self {
62            Value::Sequence(v) => Some(v),
63            _ => None,
64        }
65    }
66
67    /// The mapping, if this is a [`Value::Mapping`].
68    pub fn as_mapping(&self) -> Option<&Mapping> {
69        match self {
70            Value::Mapping(m) => Some(m),
71            _ => None,
72        }
73    }
74
75    /// `true` if this is [`Value::Null`].
76    pub fn is_null(&self) -> bool {
77        matches!(self, Value::Null)
78    }
79
80    /// Look up a key, if this is a mapping.
81    pub fn get(&self, key: &str) -> Option<&Value> {
82        self.as_mapping().and_then(|m| m.get(key))
83    }
84
85    /// Look up a dotted field path — `title`, or `generated.how` for a key
86    /// inside a mapping — each segment a mapping key. This is how a `fields`
87    /// declaration names what it governs, so a controlled vocabulary can
88    /// reach a key one level down (the act in a `generated` mapping) as it
89    /// reaches a top-level one; a dot is always a separator, as it is for
90    /// `prov get`. `None` when any segment is missing or the value on the way
91    /// is not a mapping.
92    pub fn get_path(&self, path: &str) -> Option<&Value> {
93        path.split('.').try_fold(self, |value, key| value.get(key))
94    }
95
96    /// Interpret this value as a list of link strings: a bare string yields one
97    /// element, a sequence yields its string-shaped elements, anything else
98    /// yields nothing. This is how a relation field (single or multi) is read.
99    pub fn link_strings(&self) -> Vec<String> {
100        match self {
101            Value::String(s) => vec![s.clone()],
102            Value::Sequence(seq) => seq
103                .iter()
104                .filter_map(|v| v.as_str().map(str::to_owned))
105                .collect(),
106            _ => Vec::new(),
107        }
108    }
109}
110
111/// Insert `value` at the dotted field `path` in `map`, creating the mappings
112/// on the way — the write that pairs with [`Value::get_path`]. A segment that
113/// exists and is not a mapping is replaced by one, since the path says what
114/// the caller means to write.
115pub fn insert_path(map: &mut Mapping, path: &str, value: Value) {
116    let mut segments = path.split('.').peekable();
117    let mut map = map;
118    while let Some(key) = segments.next() {
119        if segments.peek().is_none() {
120            map.insert(key.to_string(), value);
121            return;
122        }
123        let entry = map
124            .entry(key.to_string())
125            .or_insert_with(|| Value::Mapping(Mapping::new()));
126        if !matches!(entry, Value::Mapping(_)) {
127            *entry = Value::Mapping(Mapping::new());
128        }
129        let Value::Mapping(inner) = entry else {
130            unreachable!()
131        };
132        map = inner;
133    }
134}
135
136/// Interpret a `fig::Value` as a list of link strings, mirroring
137/// [`Value::link_strings`] for callers that have migrated to reading
138/// `fig::Value` directly at the accessor boundary. A bare string yields one
139/// element, a sequence yields its string-shaped elements, anything else
140/// yields nothing.
141pub fn link_strings(value: &fig::Value) -> Vec<String> {
142    match value {
143        fig::Value::Str(s) => vec![s.clone()],
144        fig::Value::Seq(seq) => seq
145            .iter()
146            .filter_map(|v| v.as_str().map(str::to_owned))
147            .collect(),
148        _ => Vec::new(),
149    }
150}
151
152/// [`link_strings`] with each link's position in the sequence it was read
153/// from: `None` for a bare string, `Some(i)` for the `i`th item of a sequence,
154/// counting items that are not strings — they yield no link, but they hold a
155/// place, and the position reported is the one in the list as written.
156pub fn indexed_link_strings(value: &fig::Value) -> Vec<(Option<usize>, String)> {
157    match value {
158        fig::Value::Str(s) => vec![(None, s.clone())],
159        fig::Value::Seq(seq) => seq
160            .iter()
161            .enumerate()
162            .filter_map(|(i, v)| v.as_str().map(|s| (Some(i), s.to_owned())))
163            .collect(),
164        _ => Vec::new(),
165    }
166}
167
168/// Parse a metadata document in `format` into a [`Value`], serde-free.
169///
170/// An empty document is [`Value::Null`].
171pub fn parse_value(s: &str, format: fig::Format) -> Result<Value> {
172    let doc = fig::Document::parse(s.as_bytes(), format)?;
173    Ok(Value::from(doc.to_value()?))
174}
175
176/// Parse a metadata mapping (the shape of frontmatter) in `format`. An empty
177/// document is an empty mapping; a non-mapping top level is an error.
178pub fn parse_mapping(s: &str, format: fig::Format) -> Result<Mapping> {
179    match parse_value(s, format)? {
180        Value::Mapping(m) => Ok(m),
181        Value::Null => Ok(Mapping::new()),
182        _ => Err(crate::error::Error::Structure(
183            "frontmatter must be a mapping".into(),
184        )),
185    }
186}
187
188/// Serialize a metadata mapping back to a string in `format` — the same format
189/// it was parsed from, so a ```` ```fig ```` block is never rewritten as YAML.
190///
191/// Forces block layout (one list item per line) rather than fig 2.0's default
192/// flow style for short sequences, matching the diffs humans expect from
193/// frontmatter.
194pub fn serialize_mapping(map: &Mapping, format: fig::Format) -> Result<String> {
195    let value = fig::Value::from(&Value::Mapping(map.clone()));
196    Ok(value.serialize_with(format, fig::SerializeOptions::default().width(1))?)
197}
198
199/// Serialize any metadata value to a string in `format`. What `serialize_mapping`
200/// is for whole frontmatter blocks, this is for a value plucked out of one
201/// (the CLI's `get` on a compound field).
202pub fn serialize_value(value: &Value, format: fig::Format) -> Result<String> {
203    Ok(
204        fig::Value::from(value)
205            .serialize_with(format, fig::SerializeOptions::default().width(1))?,
206    )
207}
208
209// ---------------------------------------------------------------------------
210// Conversions to/from fig's native value tree (the serde-free bridge).
211// ---------------------------------------------------------------------------
212
213impl From<&Value> for fig::Value {
214    fn from(value: &Value) -> Self {
215        match value {
216            Value::Null => fig::Value::Null,
217            Value::Bool(b) => fig::Value::Bool(*b),
218            Value::Int(i) => fig::Value::Int(*i),
219            Value::Float(f) => fig::Value::Float(*f),
220            Value::String(s) => fig::Value::Str(s.clone()),
221            Value::Sequence(seq) => fig::Value::Seq(seq.iter().map(fig::Value::from).collect()),
222            Value::Mapping(map) => fig::Value::Map(
223                map.iter()
224                    .map(|(k, v)| (fig::Value::Str(k.clone()), fig::Value::from(v)))
225                    .collect(),
226            ),
227        }
228    }
229}
230
231impl From<fig::Value> for Value {
232    fn from(value: fig::Value) -> Self {
233        match value {
234            fig::Value::Null => Value::Null,
235            fig::Value::Bool(b) => Value::Bool(b),
236            fig::Value::Int(i) => Value::Int(i),
237            fig::Value::Uint(u) => {
238                if u <= i64::MAX as u64 {
239                    Value::Int(u as i64)
240                } else {
241                    Value::Float(u as f64)
242                }
243            }
244            fig::Value::Float(f) => Value::Float(f),
245            fig::Value::Str(s) => Value::String(s),
246            // Format-specific scalars (TOML datetimes, ZON literals) surface as
247            // their verbatim text, matching fig's serde path.
248            fig::Value::Extended { text, .. } => Value::String(text),
249            fig::Value::Seq(items) => Value::Sequence(items.into_iter().map(Value::from).collect()),
250            fig::Value::Map(entries) => {
251                let mut map = IndexMap::with_capacity(entries.len());
252                for (k, v) in entries {
253                    map.insert(fig_key_to_string(k), Value::from(v));
254                }
255                Value::Mapping(map)
256            }
257        }
258    }
259}
260
261/// Stringify a `fig` mapping key. Frontmatter keys are virtually always strings;
262/// other scalars render to text, and non-scalar keys collapse to empty.
263fn fig_key_to_string(key: fig::Value) -> String {
264    match key {
265        fig::Value::Str(s) => s,
266        fig::Value::Bool(b) => b.to_string(),
267        fig::Value::Int(i) => i.to_string(),
268        fig::Value::Uint(u) => u.to_string(),
269        fig::Value::Null => "null".to_string(),
270        fig::Value::Extended { text, .. } => text,
271        fig::Value::Float(_) | fig::Value::Seq(_) | fig::Value::Map(_) => String::new(),
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[cfg(feature = "yaml")]
280    #[test]
281    fn parses_frontmatter_mapping() {
282        let m = parse_mapping(
283            "title: Hello\ncount: 42\ntags:\n- a\n- b\n",
284            fig::Format::Yaml,
285        )
286        .unwrap();
287        assert_eq!(m.get("title").and_then(Value::as_str), Some("Hello"));
288        assert_eq!(m.get("count"), Some(&Value::Int(42)));
289        assert_eq!(
290            m.get("tags").map(Value::link_strings),
291            Some(vec!["a".to_string(), "b".to_string()])
292        );
293    }
294
295    #[cfg(feature = "fig-lang")]
296    #[test]
297    fn parses_fig_dialect_mapping() {
298        let m = parse_mapping("title = Hello\ntags = [a, b]\n", fig::Format::Fig).unwrap();
299        assert_eq!(m.get("title").and_then(Value::as_str), Some("Hello"));
300        assert_eq!(
301            m.get("tags").map(Value::link_strings),
302            Some(vec!["a".to_string(), "b".to_string()])
303        );
304    }
305
306    #[test]
307    fn get_path_reaches_into_a_mapping_and_insert_path_writes_there() {
308        let mut m = Mapping::new();
309        insert_path(&mut m, "generated.how", Value::String("drafted".into()));
310        insert_path(&mut m, "title", Value::String("x".into()));
311        let v = Value::Mapping(m);
312        assert_eq!(v.get_path("title").and_then(Value::as_str), Some("x"));
313        assert_eq!(
314            v.get_path("generated.how").and_then(Value::as_str),
315            Some("drafted")
316        );
317        assert!(v.get_path("generated.by").is_none());
318        assert!(v.get_path("title.how").is_none());
319        assert!(v.get_path("missing").is_none());
320    }
321
322    #[test]
323    fn link_strings_handles_scalar_and_sequence() {
324        assert_eq!(Value::String("x".into()).link_strings(), vec!["x"]);
325        let seq = Value::Sequence(vec![
326            Value::String("a".into()),
327            Value::Int(3),
328            Value::String("b".into()),
329        ]);
330        assert_eq!(seq.link_strings(), vec!["a".to_string(), "b".to_string()]);
331        assert!(Value::Null.link_strings().is_empty());
332    }
333
334    #[cfg(all(feature = "yaml", feature = "fig-lang"))]
335    #[test]
336    fn round_trips_through_fig() {
337        for format in [fig::Format::Yaml, fig::Format::Fig] {
338            let m = parse_mapping(
339                "title: Root\ncontents:\n- a.md\n- b.md\n",
340                fig::Format::Yaml,
341            )
342            .unwrap();
343            let out = serialize_mapping(&m, format).unwrap();
344            let reparsed = parse_mapping(&out, format).unwrap();
345            assert_eq!(m, reparsed, "round-trip through {format:?}");
346        }
347    }
348}