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    /// Interpret this value as a list of link strings: a bare string yields one
86    /// element, a sequence yields its string-shaped elements, anything else
87    /// yields nothing. This is how a relation field (single or multi) is read.
88    pub fn link_strings(&self) -> Vec<String> {
89        match self {
90            Value::String(s) => vec![s.clone()],
91            Value::Sequence(seq) => seq
92                .iter()
93                .filter_map(|v| v.as_str().map(str::to_owned))
94                .collect(),
95            _ => Vec::new(),
96        }
97    }
98}
99
100/// Interpret a `fig::Value` as a list of link strings, mirroring
101/// [`Value::link_strings`] for callers that have migrated to reading
102/// `fig::Value` directly at the accessor boundary. A bare string yields one
103/// element, a sequence yields its string-shaped elements, anything else
104/// yields nothing.
105pub fn link_strings(value: &fig::Value) -> Vec<String> {
106    match value {
107        fig::Value::Str(s) => vec![s.clone()],
108        fig::Value::Seq(seq) => seq
109            .iter()
110            .filter_map(|v| v.as_str().map(str::to_owned))
111            .collect(),
112        _ => Vec::new(),
113    }
114}
115
116/// Parse a metadata document in `format` into a [`Value`], serde-free.
117///
118/// An empty document is [`Value::Null`].
119pub fn parse_value(s: &str, format: fig::Format) -> Result<Value> {
120    let doc = fig::Document::parse(s.as_bytes(), format)?;
121    Ok(Value::from(doc.to_value()?))
122}
123
124/// Parse a metadata mapping (the shape of frontmatter) in `format`. An empty
125/// document is an empty mapping; a non-mapping top level is an error.
126pub fn parse_mapping(s: &str, format: fig::Format) -> Result<Mapping> {
127    match parse_value(s, format)? {
128        Value::Mapping(m) => Ok(m),
129        Value::Null => Ok(Mapping::new()),
130        _ => Err(crate::error::Error::Structure(
131            "frontmatter must be a mapping".into(),
132        )),
133    }
134}
135
136/// Serialize a metadata mapping back to a string in `format` — the same format
137/// it was parsed from, so a ```` ```fig ```` block is never rewritten as YAML.
138///
139/// Forces block layout (one list item per line) rather than fig 2.0's default
140/// flow style for short sequences, matching the diffs humans expect from
141/// frontmatter.
142pub fn serialize_mapping(map: &Mapping, format: fig::Format) -> Result<String> {
143    let value = fig::Value::from(&Value::Mapping(map.clone()));
144    Ok(value.serialize_with(format, fig::SerializeOptions::default().width(1))?)
145}
146
147/// Serialize any metadata value to a string in `format`. What `serialize_mapping`
148/// is for whole frontmatter blocks, this is for a value plucked out of one
149/// (the CLI's `get` on a compound field).
150pub fn serialize_value(value: &Value, format: fig::Format) -> Result<String> {
151    Ok(
152        fig::Value::from(value)
153            .serialize_with(format, fig::SerializeOptions::default().width(1))?,
154    )
155}
156
157// ---------------------------------------------------------------------------
158// Conversions to/from fig's native value tree (the serde-free bridge).
159// ---------------------------------------------------------------------------
160
161impl From<&Value> for fig::Value {
162    fn from(value: &Value) -> Self {
163        match value {
164            Value::Null => fig::Value::Null,
165            Value::Bool(b) => fig::Value::Bool(*b),
166            Value::Int(i) => fig::Value::Int(*i),
167            Value::Float(f) => fig::Value::Float(*f),
168            Value::String(s) => fig::Value::Str(s.clone()),
169            Value::Sequence(seq) => fig::Value::Seq(seq.iter().map(fig::Value::from).collect()),
170            Value::Mapping(map) => fig::Value::Map(
171                map.iter()
172                    .map(|(k, v)| (fig::Value::Str(k.clone()), fig::Value::from(v)))
173                    .collect(),
174            ),
175        }
176    }
177}
178
179impl From<fig::Value> for Value {
180    fn from(value: fig::Value) -> Self {
181        match value {
182            fig::Value::Null => Value::Null,
183            fig::Value::Bool(b) => Value::Bool(b),
184            fig::Value::Int(i) => Value::Int(i),
185            fig::Value::Uint(u) => {
186                if u <= i64::MAX as u64 {
187                    Value::Int(u as i64)
188                } else {
189                    Value::Float(u as f64)
190                }
191            }
192            fig::Value::Float(f) => Value::Float(f),
193            fig::Value::Str(s) => Value::String(s),
194            // Format-specific scalars (TOML datetimes, ZON literals) surface as
195            // their verbatim text, matching fig's serde path.
196            fig::Value::Extended { text, .. } => Value::String(text),
197            fig::Value::Seq(items) => Value::Sequence(items.into_iter().map(Value::from).collect()),
198            fig::Value::Map(entries) => {
199                let mut map = IndexMap::with_capacity(entries.len());
200                for (k, v) in entries {
201                    map.insert(fig_key_to_string(k), Value::from(v));
202                }
203                Value::Mapping(map)
204            }
205        }
206    }
207}
208
209/// Stringify a `fig` mapping key. Frontmatter keys are virtually always strings;
210/// other scalars render to text, and non-scalar keys collapse to empty.
211fn fig_key_to_string(key: fig::Value) -> String {
212    match key {
213        fig::Value::Str(s) => s,
214        fig::Value::Bool(b) => b.to_string(),
215        fig::Value::Int(i) => i.to_string(),
216        fig::Value::Uint(u) => u.to_string(),
217        fig::Value::Null => "null".to_string(),
218        fig::Value::Extended { text, .. } => text,
219        fig::Value::Float(_) | fig::Value::Seq(_) | fig::Value::Map(_) => String::new(),
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[cfg(feature = "yaml")]
228    #[test]
229    fn parses_frontmatter_mapping() {
230        let m = parse_mapping(
231            "title: Hello\ncount: 42\ntags:\n- a\n- b\n",
232            fig::Format::Yaml,
233        )
234        .unwrap();
235        assert_eq!(m.get("title").and_then(Value::as_str), Some("Hello"));
236        assert_eq!(m.get("count"), Some(&Value::Int(42)));
237        assert_eq!(
238            m.get("tags").map(Value::link_strings),
239            Some(vec!["a".to_string(), "b".to_string()])
240        );
241    }
242
243    #[cfg(feature = "fig-lang")]
244    #[test]
245    fn parses_fig_dialect_mapping() {
246        let m = parse_mapping("title = Hello\ntags = [a, b]\n", fig::Format::Fig).unwrap();
247        assert_eq!(m.get("title").and_then(Value::as_str), Some("Hello"));
248        assert_eq!(
249            m.get("tags").map(Value::link_strings),
250            Some(vec!["a".to_string(), "b".to_string()])
251        );
252    }
253
254    #[test]
255    fn link_strings_handles_scalar_and_sequence() {
256        assert_eq!(Value::String("x".into()).link_strings(), vec!["x"]);
257        let seq = Value::Sequence(vec![
258            Value::String("a".into()),
259            Value::Int(3),
260            Value::String("b".into()),
261        ]);
262        assert_eq!(seq.link_strings(), vec!["a".to_string(), "b".to_string()]);
263        assert!(Value::Null.link_strings().is_empty());
264    }
265
266    #[cfg(all(feature = "yaml", feature = "fig-lang"))]
267    #[test]
268    fn round_trips_through_fig() {
269        for format in [fig::Format::Yaml, fig::Format::Fig] {
270            let m = parse_mapping(
271                "title: Root\ncontents:\n- a.md\n- b.md\n",
272                fig::Format::Yaml,
273            )
274            .unwrap();
275            let out = serialize_mapping(&m, format).unwrap();
276            let reparsed = parse_mapping(&out, format).unwrap();
277            assert_eq!(m, reparsed, "round-trip through {format:?}");
278        }
279    }
280}