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 field path — `title`, `generated.how` for a key inside a
86    /// mapping, `sources[2].resource` for a key inside one item of a list —
87    /// and return the first value it reaches. This is how a `fields`
88    /// declaration names what it governs (see [`crate::field`]); a dot is
89    /// always a separator, as it is for `prov get`. A path with a `[]` step
90    /// reaches every item, and this returns the first — a caller that wants
91    /// them all walks [`field::values_at`](crate::field::values_at). `None`
92    /// when the path lands on nothing.
93    pub fn get_path(&self, path: &str) -> Option<&Value> {
94        crate::field::values_at(self, &crate::field::FieldPath::parse(path))
95            .into_iter()
96            .next()
97            .map(|(_, value)| value)
98    }
99
100    /// Interpret this value as a list of link strings: a bare string yields one
101    /// element, a sequence yields its string-shaped elements, anything else
102    /// yields nothing. This is how a relation field (single or multi) is read.
103    pub fn link_strings(&self) -> Vec<String> {
104        match self {
105            Value::String(s) => vec![s.clone()],
106            Value::Sequence(seq) => seq
107                .iter()
108                .filter_map(|v| v.as_str().map(str::to_owned))
109                .collect(),
110            _ => Vec::new(),
111        }
112    }
113}
114
115/// Insert `value` at the dotted field `path` in `map`, creating the mappings
116/// on the way — the write that pairs with [`Value::get_path`]. A segment that
117/// exists and is not a mapping is replaced by one, since the path says what
118/// the caller means to write.
119///
120/// A path with a list step (`sources[].resource`) has nowhere to write: a
121/// new document has no list to fill, and one item of a list is not a
122/// starting value. Nothing is written for such a path.
123pub fn insert_path(map: &mut Mapping, path: &str, value: Value) {
124    if crate::field::FieldPath::parse(path).enters_list() {
125        return;
126    }
127    let mut segments = path.split('.').peekable();
128    let mut map = map;
129    while let Some(key) = segments.next() {
130        if segments.peek().is_none() {
131            map.insert(key.to_string(), value);
132            return;
133        }
134        let entry = map
135            .entry(key.to_string())
136            .or_insert_with(|| Value::Mapping(Mapping::new()));
137        if !matches!(entry, Value::Mapping(_)) {
138            *entry = Value::Mapping(Mapping::new());
139        }
140        let Value::Mapping(inner) = entry else {
141            unreachable!()
142        };
143        map = inner;
144    }
145}
146
147/// Interpret a `fig::Value` as a list of link strings, mirroring
148/// [`Value::link_strings`] for callers that have migrated to reading
149/// `fig::Value` directly at the accessor boundary. A bare string yields one
150/// element, a sequence yields its string-shaped elements, anything else
151/// yields nothing.
152pub fn link_strings(value: &fig::Value) -> Vec<String> {
153    match value {
154        fig::Value::Str(s) => vec![s.clone()],
155        fig::Value::Seq(seq) => seq
156            .iter()
157            .filter_map(|v| v.as_str().map(str::to_owned))
158            .collect(),
159        _ => Vec::new(),
160    }
161}
162
163/// [`link_strings`] with each link's position in the sequence it was read
164/// from: `None` for a bare string, `Some(i)` for the `i`th item of a sequence,
165/// counting items that are not strings — they yield no link, but they hold a
166/// place, and the position reported is the one in the list as written.
167pub fn indexed_link_strings(value: &fig::Value) -> Vec<(Option<usize>, String)> {
168    match value {
169        fig::Value::Str(s) => vec![(None, s.clone())],
170        fig::Value::Seq(seq) => seq
171            .iter()
172            .enumerate()
173            .filter_map(|(i, v)| v.as_str().map(|s| (Some(i), s.to_owned())))
174            .collect(),
175        _ => Vec::new(),
176    }
177}
178
179/// Parse a metadata document in `format` into a [`Value`], serde-free.
180///
181/// An empty document is [`Value::Null`].
182pub fn parse_value(s: &str, format: fig::Format) -> Result<Value> {
183    let doc = fig::Document::parse(s.as_bytes(), format)?;
184    Ok(Value::from(doc.to_value()?))
185}
186
187/// Parse a metadata mapping (the shape of frontmatter) in `format`. An empty
188/// document is an empty mapping; a non-mapping top level is an error.
189pub fn parse_mapping(s: &str, format: fig::Format) -> Result<Mapping> {
190    match parse_value(s, format)? {
191        Value::Mapping(m) => Ok(m),
192        Value::Null => Ok(Mapping::new()),
193        _ => Err(crate::error::Error::Structure(
194            "frontmatter must be a mapping".into(),
195        )),
196    }
197}
198
199/// Serialize a metadata mapping back to a string in `format` — the same format
200/// it was parsed from, so a ```` ```fig ```` block is never rewritten as YAML.
201///
202/// Forces block layout (one list item per line) rather than fig 2.0's default
203/// flow style for short sequences, matching the diffs humans expect from
204/// frontmatter.
205pub fn serialize_mapping(map: &Mapping, format: fig::Format) -> Result<String> {
206    let value = fig::Value::from(&Value::Mapping(map.clone()));
207    Ok(value.serialize_with(format, fig::SerializeOptions::default().width(1))?)
208}
209
210/// Serialize any metadata value to a string in `format`. What `serialize_mapping`
211/// is for whole frontmatter blocks, this is for a value plucked out of one
212/// (the CLI's `get` on a compound field).
213pub fn serialize_value(value: &Value, format: fig::Format) -> Result<String> {
214    Ok(
215        fig::Value::from(value)
216            .serialize_with(format, fig::SerializeOptions::default().width(1))?,
217    )
218}
219
220// ---------------------------------------------------------------------------
221// Conversions to/from fig's native value tree (the serde-free bridge).
222// ---------------------------------------------------------------------------
223
224impl From<&Value> for fig::Value {
225    fn from(value: &Value) -> Self {
226        match value {
227            Value::Null => fig::Value::Null,
228            Value::Bool(b) => fig::Value::Bool(*b),
229            Value::Int(i) => fig::Value::Int(*i),
230            Value::Float(f) => fig::Value::Float(*f),
231            Value::String(s) => fig::Value::Str(s.clone()),
232            Value::Sequence(seq) => fig::Value::Seq(seq.iter().map(fig::Value::from).collect()),
233            Value::Mapping(map) => fig::Value::Map(
234                map.iter()
235                    .map(|(k, v)| (fig::Value::Str(k.clone()), fig::Value::from(v)))
236                    .collect(),
237            ),
238        }
239    }
240}
241
242impl From<fig::Value> for Value {
243    fn from(value: fig::Value) -> Self {
244        match value {
245            fig::Value::Null => Value::Null,
246            fig::Value::Bool(b) => Value::Bool(b),
247            fig::Value::Int(i) => Value::Int(i),
248            fig::Value::Uint(u) => {
249                if u <= i64::MAX as u64 {
250                    Value::Int(u as i64)
251                } else {
252                    Value::Float(u as f64)
253                }
254            }
255            fig::Value::Float(f) => Value::Float(f),
256            fig::Value::Str(s) => Value::String(s),
257            // Format-specific scalars (TOML datetimes, ZON literals) surface as
258            // their verbatim text, matching fig's serde path.
259            fig::Value::Extended { text, .. } => Value::String(text),
260            fig::Value::Seq(items) => Value::Sequence(items.into_iter().map(Value::from).collect()),
261            fig::Value::Map(entries) => {
262                let mut map = IndexMap::with_capacity(entries.len());
263                for (k, v) in entries {
264                    map.insert(fig_key_to_string(k), Value::from(v));
265                }
266                Value::Mapping(map)
267            }
268        }
269    }
270}
271
272/// Stringify a `fig` mapping key. Frontmatter keys are virtually always strings;
273/// other scalars render to text, and non-scalar keys collapse to empty.
274fn fig_key_to_string(key: fig::Value) -> String {
275    match key {
276        fig::Value::Str(s) => s,
277        fig::Value::Bool(b) => b.to_string(),
278        fig::Value::Int(i) => i.to_string(),
279        fig::Value::Uint(u) => u.to_string(),
280        fig::Value::Null => "null".to_string(),
281        fig::Value::Extended { text, .. } => text,
282        fig::Value::Float(_) | fig::Value::Seq(_) | fig::Value::Map(_) => String::new(),
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[cfg(feature = "yaml")]
291    #[test]
292    fn parses_frontmatter_mapping() {
293        let m = parse_mapping(
294            "title: Hello\ncount: 42\ntags:\n- a\n- b\n",
295            fig::Format::Yaml,
296        )
297        .unwrap();
298        assert_eq!(m.get("title").and_then(Value::as_str), Some("Hello"));
299        assert_eq!(m.get("count"), Some(&Value::Int(42)));
300        assert_eq!(
301            m.get("tags").map(Value::link_strings),
302            Some(vec!["a".to_string(), "b".to_string()])
303        );
304    }
305
306    #[cfg(feature = "fig-lang")]
307    #[test]
308    fn parses_fig_dialect_mapping() {
309        let m = parse_mapping("title = Hello\ntags = [a, b]\n", fig::Format::Fig).unwrap();
310        assert_eq!(m.get("title").and_then(Value::as_str), Some("Hello"));
311        assert_eq!(
312            m.get("tags").map(Value::link_strings),
313            Some(vec!["a".to_string(), "b".to_string()])
314        );
315    }
316
317    #[test]
318    fn get_path_reaches_into_a_mapping_and_insert_path_writes_there() {
319        let mut m = Mapping::new();
320        insert_path(&mut m, "generated.how", Value::String("drafted".into()));
321        insert_path(&mut m, "title", Value::String("x".into()));
322        let v = Value::Mapping(m);
323        assert_eq!(v.get_path("title").and_then(Value::as_str), Some("x"));
324        assert_eq!(
325            v.get_path("generated.how").and_then(Value::as_str),
326            Some("drafted")
327        );
328        assert!(v.get_path("generated.by").is_none());
329        assert!(v.get_path("title.how").is_none());
330        assert!(v.get_path("missing").is_none());
331    }
332
333    #[test]
334    fn link_strings_handles_scalar_and_sequence() {
335        assert_eq!(Value::String("x".into()).link_strings(), vec!["x"]);
336        let seq = Value::Sequence(vec![
337            Value::String("a".into()),
338            Value::Int(3),
339            Value::String("b".into()),
340        ]);
341        assert_eq!(seq.link_strings(), vec!["a".to_string(), "b".to_string()]);
342        assert!(Value::Null.link_strings().is_empty());
343    }
344
345    #[cfg(all(feature = "yaml", feature = "fig-lang"))]
346    #[test]
347    fn round_trips_through_fig() {
348        for format in [fig::Format::Yaml, fig::Format::Fig] {
349            let m = parse_mapping(
350                "title: Root\ncontents:\n- a.md\n- b.md\n",
351                fig::Format::Yaml,
352            )
353            .unwrap();
354            let out = serialize_mapping(&m, format).unwrap();
355            let reparsed = parse_mapping(&out, format).unwrap();
356            assert_eq!(m, reparsed, "round-trip through {format:?}");
357        }
358    }
359}