Skip to main content

frontmatter_gen/
parser.rs

1//! # Front Matter Parser and Serialiser Module
2//!
3//! This module provides robust functionality for parsing and serialising front matter
4//! in various formats (YAML, TOML, and JSON). It focuses on:
5//!
6//! - Memory efficiency through pre-allocation and string optimisation
7//! - Type safety with comprehensive error handling
8//! - Performance optimisation with minimal allocations
9//! - Validation of input data
10//! - Consistent cross-format handling
11//!
12//! ## Features
13//!
14//! - Multi-format support (YAML, TOML, JSON)
15//! - Zero-copy parsing where possible
16//! - Efficient memory management
17//! - Comprehensive validation
18//! - Rich error context
19//!
20//! ## Usage Example
21//!
22//! ```rust
23//! use frontmatter_gen::{Format, parser};
24//!
25//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
26//! let yaml = "title: My Post\ndate: 2025-09-09\n";
27//! let front_matter = parser::parse_with_options(
28//!     yaml,
29//!     Format::Yaml,
30//!     None
31//! )?;
32//! # Ok(())
33//! # }
34//! ```
35
36use noyalib::Value as YamlValue;
37use serde::Serialize;
38use serde_json::Value as JsonValue;
39use std::{collections::HashMap, sync::Arc};
40use toml::Value as TomlValue;
41
42use crate::{error::Error, types::Frontmatter, Format, Value};
43
44// Constants for optimisation and validation
45const SMALL_STRING_SIZE: usize = 24;
46const MAX_NESTING_DEPTH: usize = 32;
47const MAX_KEYS: usize = 1000;
48
49/// Options for controlling parsing behaviour.
50///
51/// Provides configuration for maximum allowed nesting depth, maximum number of keys,
52/// and whether to perform validation.
53#[derive(Debug, Clone, Copy)]
54pub struct ParseOptions {
55    /// Maximum allowed nesting depth.
56    pub max_depth: usize,
57    /// Maximum allowed number of keys.
58    pub max_keys: usize,
59    /// Whether to validate the structure.
60    pub validate: bool,
61}
62
63impl Default for ParseOptions {
64    fn default() -> Self {
65        Self {
66            max_depth: MAX_NESTING_DEPTH,
67            max_keys: MAX_KEYS,
68            validate: true,
69        }
70    }
71}
72
73/// Optimises string storage based on length.
74///
75/// For strings shorter than `SMALL_STRING_SIZE`, uses standard allocation.
76/// For longer strings, pre-allocates exact capacity to avoid reallocations.
77///
78/// # Arguments
79///
80/// * `s` - The input string slice to optimise.
81///
82/// # Returns
83///
84/// An optimised owned `String`.
85#[inline]
86fn optimise_string(s: &str) -> String {
87    if s.len() <= SMALL_STRING_SIZE {
88        s.to_string()
89    } else {
90        let mut string = String::with_capacity(s.len());
91        string.push_str(s);
92        string
93    }
94}
95
96/// Parses raw front matter string into a `Frontmatter` object based on the specified format.
97///
98/// This function attempts to parse the provided string into a structured `Frontmatter`
99/// object according to the specified format. It performs validation by default
100/// and optimises memory allocation where possible.
101///
102/// # Arguments
103///
104/// * `raw_front_matter` - A string slice containing the raw front matter content.
105/// * `format` - The `Format` enum specifying the desired format.
106/// * `options` - Optional parsing options for controlling validation and limits.
107///
108/// # Returns
109///
110/// A `Result` containing either the parsed `Frontmatter` object or a `Error`.
111///
112/// # Errors
113///
114/// Returns `Error` if:
115/// - The input is not valid in the specified format
116/// - The structure exceeds configured limits
117/// - The format is unsupported
118pub fn parse_with_options(
119    raw_front_matter: &str,
120    format: Format,
121    options: Option<ParseOptions>,
122) -> Result<Frontmatter, Error> {
123    let options = options.unwrap_or_default();
124
125    // Trim the input and validate format assumptions
126    let trimmed = raw_front_matter.trim();
127
128    let front_matter = match format {
129        Format::Yaml => parse_yaml_checked(trimmed)?,
130        Format::Toml => parse_toml_checked(trimmed)?,
131        Format::Json => parse_json_checked(trimmed)?,
132        Format::Unsupported => parse_unsupported(format)?,
133    };
134
135    // Perform validation if specified in options
136    if options.validate {
137        let max_depth = options.max_depth;
138        let max_keys = options.max_keys;
139        log::debug!("Validating front matter: maximum allowed nesting depth is {max_depth}, maximum allowed number of keys is {max_keys}");
140
141        validate_frontmatter(&front_matter, max_depth, max_keys)
142            .map_err(|e| {
143                log::error!("Front matter validation failed: {}", e);
144                e
145            })?;
146    }
147
148    Ok(front_matter)
149}
150
151/// Validates and parses YAML front matter content.
152fn parse_yaml_checked(trimmed: &str) -> Result<Frontmatter, Error> {
153    if !trimmed.starts_with("---") {
154        log::debug!("YAML front matter validation: Content structure appears non-standard");
155    }
156    parse_yaml(trimmed).map_err(|e| {
157        log::error!("YAML parsing failed: {}", e);
158        e
159    })
160}
161
162const ERR_TOML_MISSING_EQ: &str =
163    "Format set to TOML but input does not contain '=' signs.";
164const ERR_JSON_MISSING_BRACE: &str =
165    "Format set to JSON but input does not start with '{'.";
166
167/// Validates and parses TOML front matter content.
168fn parse_toml_checked(trimmed: &str) -> Result<Frontmatter, Error> {
169    if !trimmed.contains('=') {
170        let msg = ERR_TOML_MISSING_EQ.to_string();
171        return Err(Error::ConversionError(msg));
172    }
173    parse_toml(trimmed).map_err(|e| {
174        log::error!("TOML parsing failed: {}", e);
175        e
176    })
177}
178
179/// Validates and parses JSON front matter content.
180fn parse_json_checked(trimmed: &str) -> Result<Frontmatter, Error> {
181    if !trimmed.starts_with('{') {
182        let msg = ERR_JSON_MISSING_BRACE.to_string();
183        return Err(Error::ConversionError(msg));
184    }
185    parse_json(trimmed).map_err(|e| {
186        log::error!("JSON parsing failed: {}", e);
187        e
188    })
189}
190
191/// Constructs and logs an unsupported format error.
192fn parse_unsupported(format: Format) -> Result<Frontmatter, Error> {
193    let err_msg = format!("Unsupported format: {format:?}. Supported formats are YAML, TOML, and JSON.");
194    log::error!("{err_msg}");
195    Err(Error::ConversionError(err_msg))
196}
197
198/// Convenience wrapper around `parse_with_options` using default options.
199///
200/// # Arguments
201///
202/// * `raw_front_matter` - A string slice containing the raw front matter content.
203/// * `format` - The `Format` enum specifying the desired format.
204///
205/// # Returns
206///
207/// A `Result` containing either the parsed `Frontmatter` object or a `Error`.
208///
209/// # Errors
210///
211/// Returns an `Error` if:
212/// - The format is invalid or unsupported.
213/// - Parsing fails due to invalid syntax.
214/// - Validation fails if enabled.
215pub fn parse(
216    raw_front_matter: &str,
217    format: Format,
218) -> Result<Frontmatter, Error> {
219    parse_with_options(raw_front_matter, format, None)
220}
221
222/// Converts a `Frontmatter` object to a string representation in the specified format.
223///
224/// # Arguments
225///
226/// * `front_matter` - Reference to the `Frontmatter` object to serialise.
227/// * `format` - The target format for serialisation.
228///
229/// # Returns
230///
231/// A `Result` containing the serialised string or a `Error`.
232///
233/// # Errors
234///
235/// Returns `Error` if:
236/// - Serialisation fails.
237/// - The specified format is unsupported.
238pub fn to_string(
239    front_matter: &Frontmatter,
240    format: Format,
241) -> Result<String, Error> {
242    match format {
243        Format::Yaml => to_yaml(front_matter),
244        Format::Toml => to_toml(front_matter),
245        Format::Json => to_json_optimised(front_matter),
246        Format::Unsupported => Err(Error::ConversionError(
247            "Unsupported format".to_string(),
248        )),
249    }
250}
251
252// YAML Implementation
253// -------------------
254
255/// Parses a YAML string into a `Frontmatter` object.
256///
257/// # Arguments
258///
259/// * `raw` - The raw YAML string.
260///
261/// # Returns
262///
263/// A `Result` containing the parsed `Frontmatter` or a `Error`.
264fn parse_yaml(raw: &str) -> Result<Frontmatter, Error> {
265    // Parse the YAML content into a noyalib::Value
266    let yaml_value: YamlValue = noyalib::from_str(raw)
267        .map_err(|e| Error::YamlParseError { source: e.into() })?;
268
269    // Prepare the front matter container
270    let capacity =
271        yaml_value.as_mapping().map_or(0, noyalib::Mapping::len);
272    let mut front_matter =
273        Frontmatter(HashMap::with_capacity(capacity));
274
275    // Convert the YAML mapping into the front matter structure.
276    //
277    // `noyalib::Mapping` keys are guaranteed `String` (unlike serde_yml's
278    // `Value`-keyed mapping), so we can iterate directly without an extra
279    // type check.
280    if let YamlValue::Mapping(mapping) = yaml_value {
281        for (key, value) in mapping {
282            // `mapping` is consumed by value, so each `value` is owned
283            // here. Passing `&value` forced `yaml_to_value` to allocate a
284            // fresh `String` for every scalar it had just been handed.
285            let _ = front_matter.insert(key, yaml_into_value(value));
286        }
287    } else {
288        return Err(Error::ParseError(
289            "YAML front matter is not a valid mapping".to_string(),
290        ));
291    }
292
293    Ok(front_matter)
294}
295
296/// Converts an **owned** `noyalib::Value` into a `Value`, moving strings
297/// rather than copying them.
298///
299/// The borrowed [`yaml_to_value`] is kept for callers that genuinely only
300/// have a reference (nested sequences reached through a borrow). Where the
301/// caller owns the value — which the mapping loop in [`parse_yaml`] does,
302/// because it consumes the mapping — this avoids one heap allocation and
303/// one copy per scalar.
304///
305/// Measured on a 2,000-document corpus of typical frontmatter: the parse
306/// phase allocated 22.4 MB across 202k allocations to read 0.9 MB of
307/// input, roughly eight allocations per value. Every string was allocated
308/// once by the YAML parser and again here.
309fn yaml_into_value(yaml: YamlValue) -> Value {
310    match yaml {
311        YamlValue::String(s) => Value::String(s),
312        YamlValue::Sequence(seq) => {
313            Value::Array(seq.into_iter().map(yaml_into_value).collect())
314        }
315        YamlValue::Mapping(map) => {
316            let mut inner = HashMap::with_capacity(map.len());
317            for (k, v) in map {
318                let _ = inner.insert(k, yaml_into_value(v));
319            }
320            Value::Object(Box::new(Frontmatter(inner)))
321        }
322        // Scalars that own nothing: reuse the borrowed conversion so the
323        // number-precision handling lives in exactly one place.
324        other => yaml_to_value(&other),
325    }
326}
327
328/// Converts a `noyalib::Value` into a `Value`.
329fn yaml_to_value(yaml: &YamlValue) -> Value {
330    match yaml {
331        YamlValue::Null => Value::Null,
332        YamlValue::Bool(b) => Value::Boolean(*b),
333        YamlValue::Number(n) => {
334            // `noyalib::Number::as_f64` returns `f64` directly (not
335            // `Option<f64>` — integers are losslessly widened when
336            // possible). We still prefer the integer path when the value
337            // fits in f64's 52-bit mantissa, falling back to the float
338            // representation otherwise.
339            n.as_i64().map_or_else(
340                || Value::Number(n.as_f64()),
341                |i| {
342                    if i.abs() < (1_i64 << 52) {
343                        Value::Number(i as f64)
344                    } else {
345                        log::warn!(
346                            "Integer {} exceeds precision of f64. Defaulting to 0.0",
347                            i
348                        );
349                        Value::Number(0.0) // Fallback for large values outside f64 precision
350                    }
351                },
352            )
353        }
354        YamlValue::String(s) => Value::String(optimise_string(s)),
355        YamlValue::Sequence(seq) => {
356            let mut vec = Vec::with_capacity(seq.len());
357            vec.extend(seq.iter().map(yaml_to_value));
358            Value::Array(vec)
359        }
360        YamlValue::Mapping(map) => {
361            // `noyalib::Mapping` is keyed by `String` directly — no need
362            // to filter out non-string keys the way we did with the old
363            // `serde_yml::Value`-keyed mapping.
364            let mut result =
365                Frontmatter(HashMap::with_capacity(map.len()));
366            for (k, v) in map {
367                let key = optimise_string(k);
368                let _ = result.insert(key, yaml_to_value(v));
369            }
370            Value::Object(Box::new(result))
371        }
372        YamlValue::Tagged(tagged) => Value::Tagged(
373            optimise_string(tagged.tag().as_ref()),
374            Box::new(yaml_to_value(tagged.value())),
375        ),
376    }
377}
378
379/// Serialises a `Frontmatter` object into a YAML string.
380///
381/// # Arguments
382///
383/// * `front_matter` - The `Frontmatter` object to serialise.
384///
385/// # Returns
386///
387/// A `Result` containing the serialised YAML string or a `Error`.
388fn to_yaml(front_matter: &Frontmatter) -> Result<String, Error> {
389    noyalib::to_string(&front_matter.0)
390        .map_err(|e| Error::ConversionError(e.to_string()))
391}
392
393// TOML Implementation
394// -------------------
395
396/// Parses a TOML string into a `Frontmatter` object.
397///
398/// # Arguments
399///
400/// * `raw` - The raw TOML string.
401///
402/// # Returns
403///
404/// A `Result` containing the parsed `Frontmatter` or a `Error`.
405fn parse_toml(raw: &str) -> Result<Frontmatter, Error> {
406    // toml 1.x: `Value::from_str` parses a single TOML *value*, not a
407    // document. Parse into `toml::Table` (i.e. a document) and wrap it.
408    let table: toml::Table =
409        raw.parse().map_err(Error::TomlParseError)?;
410
411    let mut front_matter =
412        Frontmatter(HashMap::with_capacity(table.len()));
413
414    for (key, value) in table {
415        let _ = front_matter.0.insert(key, toml_to_value(&value));
416    }
417
418    Ok(front_matter)
419}
420
421/// Converts a `toml::Value` into a `Value`.
422fn toml_to_value(toml: &TomlValue) -> Value {
423    match toml {
424        TomlValue::String(s) => Value::String(optimise_string(s)),
425        TomlValue::Integer(i) => Value::Number(*i as f64),
426        TomlValue::Float(f) => Value::Number(*f),
427        TomlValue::Boolean(b) => Value::Boolean(*b),
428        TomlValue::Array(arr) => {
429            let mut vec = Vec::with_capacity(arr.len());
430            vec.extend(arr.iter().map(toml_to_value));
431            Value::Array(vec)
432        }
433        TomlValue::Table(table) => {
434            let mut result =
435                Frontmatter(HashMap::with_capacity(table.len()));
436            for (k, v) in table {
437                let key = optimise_string(k);
438                let _ = result.insert(key, toml_to_value(v));
439            }
440            Value::Object(Box::new(result))
441        }
442        TomlValue::Datetime(dt) => Value::String(dt.to_string()),
443    }
444}
445
446/// Serialises a `Frontmatter` object into a TOML string.
447///
448/// # Arguments
449///
450/// * `front_matter` - The `Frontmatter` object to serialise.
451///
452/// # Returns
453///
454/// A `Result` containing the serialised TOML string or a `Error`.
455fn to_toml(front_matter: &Frontmatter) -> Result<String, Error> {
456    toml::to_string(&front_matter.0)
457        .map_err(|e| Error::ConversionError(e.to_string()))
458}
459
460// JSON Implementation
461// -------------------
462
463/// Parses a JSON string into a `Frontmatter` object.
464///
465/// # Arguments
466///
467/// * `raw` - The raw JSON string.
468///
469/// # Returns
470///
471/// A `Result` containing the parsed `Frontmatter` or a `Error`.
472fn parse_json(raw: &str) -> Result<Frontmatter, Error> {
473    let json_value: JsonValue = serde_json::from_str(raw)
474        .map_err(|e| Error::JsonParseError(Arc::new(e)))?;
475
476    let capacity = match &json_value {
477        JsonValue::Object(obj) => obj.len(),
478        _ => 0,
479    };
480
481    let mut front_matter =
482        Frontmatter(HashMap::with_capacity(capacity));
483
484    if let JsonValue::Object(obj) = json_value {
485        for (key, value) in obj {
486            let _ = front_matter.0.insert(key, json_to_value(&value));
487        }
488    }
489
490    Ok(front_matter)
491}
492
493/// Converts a `serde_json::Value` into a `Value`.
494fn json_to_value(json: &JsonValue) -> Value {
495    match json {
496        JsonValue::Null => Value::Null,
497        JsonValue::Bool(b) => Value::Boolean(*b),
498        JsonValue::Number(n) => n.as_i64().map_or_else(
499            || Value::Number(n.as_f64().unwrap_or(0.0)),
500            |i| Value::Number(i as f64),
501        ),
502        JsonValue::String(s) => Value::String(optimise_string(s)),
503        JsonValue::Array(arr) => {
504            let mut vec = Vec::with_capacity(arr.len());
505            vec.extend(arr.iter().map(json_to_value));
506            Value::Array(vec)
507        }
508        JsonValue::Object(obj) => {
509            let mut result =
510                Frontmatter(HashMap::with_capacity(obj.len()));
511            for (k, v) in obj {
512                let key = optimise_string(k);
513                let _ = result.insert(key, json_to_value(v));
514            }
515            Value::Object(Box::new(result))
516        }
517    }
518}
519
520/// Optimised JSON serialisation with pre-allocated buffer.
521///
522/// # Arguments
523///
524/// * `front_matter` - The `Frontmatter` object to serialise.
525///
526/// # Returns
527///
528/// A `Result` containing the serialised JSON string or a `Error`.
529fn to_json_optimised(
530    front_matter: &Frontmatter,
531) -> Result<String, Error> {
532    let estimated_size = estimate_json_size(front_matter);
533    let buf = Vec::with_capacity(estimated_size);
534    let mut ser = serde_json::Serializer::new(buf);
535
536    let _ = front_matter.0.serialize(&mut ser);
537
538    Ok(String::from_utf8(ser.into_inner()).unwrap_or_default())
539}
540
541// Validation and Utilities
542// ------------------------
543
544/// Validates a front matter structure against configured limits.
545///
546/// Checks:
547/// - Maximum nesting depth.
548/// - Maximum number of keys.
549/// - Structure validity.
550///
551/// # Arguments
552///
553/// * `fm` - Reference to the front matter to validate.
554/// * `max_depth` - Maximum allowed nesting depth.
555/// * `max` - Maximum allowed number of keys.
556///
557/// # Returns
558///
559/// `Ok(())` if validation passes, `Error` otherwise.
560///
561/// # Errors
562///
563/// Returns `Error` if:
564/// - The number of keys exceeds `max`.
565/// - The nesting depth exceeds `max_depth`.
566pub fn validate_frontmatter(
567    fm: &Frontmatter,
568    max_depth: usize,
569    max: usize,
570) -> Result<(), Error> {
571    let size = fm.0.len();
572    if size > max {
573        return Err(Error::ContentTooLarge { size, max });
574    }
575
576    // Validate nesting depth
577    for value in fm.0.values() {
578        check_depth(value, 1, max_depth)?;
579    }
580
581    Ok(())
582}
583
584/// Recursively checks the nesting depth of a value.
585///
586/// # Arguments
587///
588/// * `value` - The `Value` to check.
589/// * `depth` - The current depth of recursion.
590/// * `max` - The maximum allowed depth.
591///
592/// # Returns
593///
594/// `Ok(())` if the depth is within limits, `Error` otherwise.
595fn check_depth(
596    value: &Value,
597    depth: usize,
598    max: usize,
599) -> Result<(), Error> {
600    if depth > max {
601        return Err(Error::NestingTooDeep { depth, max });
602    }
603
604    if let Value::Array(arr) = value {
605        for item in arr {
606            check_depth(item, depth + 1, max)?;
607        }
608    } else if let Value::Object(obj) = value {
609        for v in obj.0.values() {
610            check_depth(v, depth + 1, max)?;
611        }
612    }
613
614    Ok(())
615}
616
617/// Estimates the JSON string size for a front matter object.
618///
619/// Used for pre-allocating buffers in serialisation.
620///
621/// # Arguments
622///
623/// * `fm` - The `Frontmatter` object.
624///
625/// # Returns
626///
627/// An estimated size in bytes.
628fn estimate_json_size(fm: &Frontmatter) -> usize {
629    let mut size = 2; // {}
630    for (k, v) in &fm.0 {
631        size += k.len() + 3; // "key":
632        size += estimate_value_size(v);
633        size += 1; // ,
634    }
635    size
636}
637
638/// Estimates the serialised size of a value.
639///
640/// # Arguments
641///
642/// * `value` - The `Value` to estimate.
643///
644/// # Returns
645///
646/// An estimated size in bytes.
647fn estimate_value_size(value: &Value) -> usize {
648    match value {
649        Value::Null => 4,                // null
650        Value::String(s) => s.len() + 2, // "string"
651        Value::Number(_) => 8,           // average number length
652        Value::Boolean(_) => 5,          // false/true
653        Value::Array(arr) => {
654            2 + arr.iter().map(estimate_value_size).sum::<usize>() // []
655        }
656        Value::Object(obj) => estimate_json_size(obj),
657        Value::Tagged(tag, val) => {
658            tag.len() + 2 + estimate_value_size(val)
659        }
660    }
661}
662
663#[cfg(test)]
664mod tests {
665    /// The scalar arms of the borrowed converter: null, a non-integral
666    /// float, an integer beyond f64's exact range, and a tagged value.
667    ///
668    /// These were reached on every parse when the mapping loop went
669    /// through `yaml_to_value`; now that it moves through
670    /// `yaml_into_value`, only `Tagged` and nested borrows arrive here, and
671    /// Codecov reported them as newly uncovered. Each is asserted on its
672    /// documented behaviour, not merely executed.
673    #[test]
674    fn yaml_to_value_borrowed_handles_every_scalar_arm() {
675        let doc = "nothing: ~\nratio: 2.5\nhuge: 9007199254740993\ntagged: !custom text\n";
676        let root: YamlValue = noyalib::from_str(doc).expect("yaml");
677
678        assert_eq!(
679            yaml_to_value(root.get("nothing").expect("nothing")),
680            Value::Null
681        );
682        assert_eq!(
683            yaml_to_value(root.get("ratio").expect("ratio")),
684            Value::Number(2.5)
685        );
686        // 2^53 + 1 is not exactly representable; the converter documents a
687        // 0.0 fallback rather than a silently rounded value.
688        assert_eq!(
689            yaml_to_value(root.get("huge").expect("huge")),
690            Value::Number(0.0)
691        );
692        let tagged_val =
693            yaml_to_value(root.get("tagged").expect("tagged"));
694        let (tag, inner) =
695            tagged_val.as_tagged().expect("expected tagged");
696        assert!(tag.contains("custom"), "tag was {tag:?}");
697        assert_eq!(*inner, Value::String("text".into()));
698    }
699
700    /// The borrowed converter still handles every variant.
701    ///
702    /// The mapping loop now moves values through `yaml_into_value`, so
703    /// these arms of `yaml_to_value` are only reached through a `Tagged`
704    /// wrapper or a nested borrow. They must keep working, and without a
705    /// direct test they had silently gone uncovered.
706    #[test]
707    fn yaml_to_value_borrowed_handles_compound_values() {
708        let doc = "list:\n  - one\n  - two\nmap:\n  k: v\n";
709        let root: YamlValue = noyalib::from_str(doc).expect("yaml");
710        let list = root.get("list").expect("list");
711        let map = root.get("map").expect("map");
712
713        let list_val = yaml_to_value(list);
714        let items = list_val.as_array().expect("expected array");
715        assert_eq!(items.len(), 2);
716        assert_eq!(items[0], Value::String("one".into()));
717
718        let map_val = yaml_to_value(map);
719        let obj = map_val.as_object().expect("expected object");
720        assert_eq!(obj.get("k"), Some(&Value::String("v".into())));
721    }
722
723    /// Moving and borrowing produce identical results.
724    #[test]
725    fn yaml_into_value_matches_yaml_to_value() {
726        let doc =
727            "s: text\nn: 3\nb: true\nlist:\n  - a\nmap:\n  x: y\n";
728        let root: YamlValue = noyalib::from_str(doc).expect("yaml");
729        let m = root.as_mapping().cloned().unwrap();
730        for (k, v) in m {
731            let borrowed = yaml_to_value(&v);
732            let moved = yaml_into_value(v);
733            assert_eq!(borrowed, moved, "key {k}");
734        }
735    }
736
737    /// The README install snippet must name the crate's own version.
738    ///
739    /// REPO-STANDARD §1 asks for docs verified against the manifest. This
740    /// README said `0.0.6` through two releases; nothing checked it.
741    #[test]
742    fn readme_install_snippet_matches_cargo_version() {
743        let readme = include_str!("../README.md");
744        let version = env!("CARGO_PKG_VERSION");
745        let expected = format!("frontmatter-gen = \"{version}\"");
746        assert!(
747            readme.contains(&expected),
748            "README does not contain `{expected}`; update the install \
749             snippet when bumping Cargo.toml"
750        );
751    }
752
753    use super::*;
754    use std::f64::consts::PI;
755
756    // Helper for creating a test `Frontmatter`
757    fn create_test_frontmatter() -> Frontmatter {
758        let mut fm = Frontmatter::new();
759        let _ = fm.insert(
760            "title".to_string(),
761            Value::String("Test".to_string()),
762        );
763        let _ = fm.insert("number".to_string(), Value::Number(PI));
764        let _ = fm.insert("boolean".to_string(), Value::Boolean(true));
765        let _ = fm.insert(
766            "array".to_string(),
767            Value::Array(vec![
768                Value::Number(1.0),
769                Value::Number(2.0),
770                Value::Number(3.0),
771            ]),
772        );
773        fm
774    }
775
776    /// Tests for `ParseOptions::default`.
777    mod parse_options_tests {
778        use super::*;
779
780        #[test]
781        fn test_parse_options_default() {
782            let default_options = ParseOptions::default();
783            assert_eq!(default_options.max_depth, MAX_NESTING_DEPTH);
784            assert_eq!(default_options.max_keys, MAX_KEYS);
785            assert!(default_options.validate);
786        }
787    }
788
789    /// Tests for `optimise_string`.
790    mod optimise_string_tests {
791        use super::*;
792
793        #[test]
794        fn test_optimise_string_short() {
795            let short_string = "short";
796            let optimised = optimise_string(short_string);
797            assert_eq!(optimised, short_string);
798            assert_eq!(optimised.capacity(), short_string.len());
799        }
800
801        #[test]
802        fn test_optimise_string_long() {
803            let long_string = "a".repeat(SMALL_STRING_SIZE + 1);
804            let optimised = optimise_string(&long_string);
805            assert_eq!(optimised, long_string);
806            assert!(optimised.capacity() >= long_string.len());
807        }
808    }
809
810    /// Tests for parsing functions.
811    mod parsing_tests {
812        use super::*;
813
814        #[test]
815        fn test_parse_yaml() {
816            let yaml = "key: value";
817            let result = parse_yaml(yaml);
818            assert!(result.is_ok());
819            let fm = result.unwrap();
820            assert_eq!(
821                fm.0.get("key"),
822                Some(&Value::String("value".to_string()))
823            );
824        }
825
826        #[test]
827        fn test_parse_toml() {
828            let toml = "key = \"value\"";
829            let result = parse_toml(toml);
830            assert!(result.is_ok());
831            let fm = result.unwrap();
832            assert_eq!(
833                fm.0.get("key"),
834                Some(&Value::String("value".to_string()))
835            );
836        }
837
838        #[test]
839        fn test_parse_json() {
840            let json = r#"{"key": "value"}"#;
841            let result = parse_json(json);
842            assert!(result.is_ok());
843            let fm = result.unwrap();
844            assert_eq!(
845                fm.0.get("key"),
846                Some(&Value::String("value".to_string()))
847            );
848        }
849
850        #[test]
851        fn test_parse_with_options() {
852            let yaml = "key: value";
853            let result = parse_with_options(yaml, Format::Yaml, None);
854            assert!(result.is_ok());
855            let fm = result.unwrap();
856            assert_eq!(
857                fm.0.get("key"),
858                Some(&Value::String("value".to_string()))
859            );
860        }
861
862        #[test]
863        fn test_parse_with_invalid_format() {
864            let yaml = "key: value";
865            let result =
866                parse_with_options(yaml, Format::Unsupported, None);
867            assert!(matches!(result, Err(Error::ConversionError(_))));
868        }
869    }
870
871    /// Tests for serialization functions.
872    mod serialization_tests {
873        use super::*;
874
875        #[test]
876        fn test_to_yaml() {
877            let fm = create_test_frontmatter();
878            let yaml = to_yaml(&fm).unwrap();
879            assert!(yaml.contains("title:"));
880            assert!(yaml.contains("Test"));
881        }
882
883        #[test]
884        fn test_to_toml() {
885            let fm = create_test_frontmatter();
886            let toml = to_toml(&fm).unwrap();
887            assert!(toml.contains("title = \"Test\""));
888        }
889
890        #[test]
891        fn test_to_json_optimised() {
892            let fm = create_test_frontmatter();
893            let json = to_json_optimised(&fm).unwrap();
894            assert!(json.contains("\"title\":\"Test\""));
895        }
896
897        #[test]
898        fn test_to_string() {
899            let fm = create_test_frontmatter();
900
901            // Test YAML format
902            let yaml = to_string(&fm, Format::Yaml).unwrap();
903            assert!(yaml.contains("title: Test"));
904
905            // Test TOML format
906            let toml = to_string(&fm, Format::Toml).unwrap();
907            assert!(toml.contains("title = \"Test\""));
908
909            // Test JSON format
910            let json = to_string(&fm, Format::Json).unwrap();
911            assert!(json.contains("\"title\":\"Test\""));
912        }
913    }
914
915    /// Tests for validation functions.
916    mod validation_tests {
917        use super::*;
918
919        #[test]
920        fn test_validate_frontmatter_valid() {
921            let fm = create_test_frontmatter();
922            assert!(validate_frontmatter(
923                &fm,
924                MAX_NESTING_DEPTH,
925                MAX_KEYS
926            )
927            .is_ok());
928        }
929
930        #[test]
931        fn test_validate_frontmatter_exceeds_keys() {
932            let mut fm = Frontmatter::new();
933            for i in 0..MAX_KEYS + 1 {
934                let _ = fm.insert(
935                    i.to_string(),
936                    Value::String("value".to_string()),
937                );
938            }
939            let result =
940                validate_frontmatter(&fm, MAX_NESTING_DEPTH, MAX_KEYS);
941            assert!(matches!(
942                result,
943                Err(Error::ContentTooLarge { .. })
944            ));
945        }
946
947        #[test]
948        fn test_validate_frontmatter_exceeds_depth() {
949            let mut current = Value::Null;
950            for _ in 0..MAX_NESTING_DEPTH + 1 {
951                current = Value::Object(Box::new(Frontmatter(
952                    [("nested".to_string(), current)]
953                        .into_iter()
954                        .collect(),
955                )));
956            }
957            let mut fm = Frontmatter::new();
958            let _ = fm.insert("deep".to_string(), current);
959            let result =
960                validate_frontmatter(&fm, MAX_NESTING_DEPTH, MAX_KEYS);
961            assert!(matches!(
962                result,
963                Err(Error::NestingTooDeep { .. })
964            ));
965        }
966    }
967
968    /// Tests for utility functions.
969    mod utility_tests {
970        use super::*;
971
972        #[test]
973        fn test_estimate_json_size() {
974            let fm = create_test_frontmatter();
975            let estimated_size = estimate_json_size(&fm);
976            let actual_json = to_string(&fm, Format::Json).unwrap();
977            assert!(estimated_size >= actual_json.len());
978        }
979
980        #[test]
981        fn test_check_depth_valid() {
982            let value =
983                Value::Object(Box::new(create_test_frontmatter()));
984            assert!(check_depth(&value, 1, MAX_NESTING_DEPTH).is_ok());
985        }
986
987        #[test]
988        fn test_check_depth_exceeds() {
989            let mut current = Value::Null;
990            for _ in 0..MAX_NESTING_DEPTH + 1 {
991                current = Value::Object(Box::new(Frontmatter(
992                    [("nested".to_string(), current)]
993                        .into_iter()
994                        .collect(),
995                )));
996            }
997            let result = check_depth(&current, 1, MAX_NESTING_DEPTH);
998            assert!(matches!(
999                result,
1000                Err(Error::NestingTooDeep { .. })
1001            ));
1002        }
1003    }
1004}
1005
1006#[cfg(test)]
1007mod exhaustive_parser_tests {
1008    //! One case per branch of the three value converters and per
1009    //! failure mode of the entry points.
1010    //!
1011    //! `yaml_to_value`, `toml_to_value` and `json_to_value` are three
1012    //! parallel matches over three foreign enums. They are exactly the
1013    //! shape where one arm drifts from the others and nothing notices:
1014    //! the output is still a `Value`, just the wrong one.
1015
1016    use super::*;
1017
1018    fn get<'a>(fm: &'a Frontmatter, key: &str) -> &'a Value {
1019        fm.get(key).unwrap()
1020    }
1021
1022    #[test]
1023    fn toml_converts_every_scalar_and_container_kind() {
1024        let raw = "\
1025s = \"text\"
1026i = 7
1027f = 2.5
1028b = true
1029when = 2024-01-02
1030arr = [1, 2]
1031nested = [[1], [2]]
1032
1033[table]
1034inner = \"x\"
1035";
1036        let fm = parse_toml(raw).expect("valid TOML");
1037        assert_eq!(get(&fm, "s"), &Value::String("text".into()));
1038        assert_eq!(get(&fm, "i"), &Value::Number(7.0));
1039        assert_eq!(get(&fm, "f"), &Value::Number(2.5));
1040        assert_eq!(get(&fm, "b"), &Value::Boolean(true));
1041        // Datetimes render as strings; TOML's type is not in `Value`.
1042        assert!(
1043            matches!(get(&fm, "when"), Value::String(s) if s.contains("2024"))
1044        );
1045        assert_eq!(get(&fm, "arr").array_len(), Some(2));
1046        assert_eq!(get(&fm, "nested").array_len(), Some(2));
1047        let table = get(&fm, "table")
1048            .as_object()
1049            .expect("table should convert to an object");
1050        assert_eq!(
1051            table.get("inner"),
1052            Some(&Value::String("x".into()))
1053        );
1054    }
1055
1056    #[test]
1057    fn json_converts_every_scalar_and_container_kind() {
1058        let raw = r#"{
1059            "null": null,
1060            "bool": false,
1061            "int": 7,
1062            "float": 2.5,
1063            "big": 12345678901234567890,
1064            "str": "text",
1065            "arr": [1, "a", null],
1066            "obj": {"inner": {"deep": 1}}
1067        }"#;
1068        let fm = parse_json(raw).expect("valid JSON");
1069        assert_eq!(get(&fm, "null"), &Value::Null);
1070        assert_eq!(get(&fm, "bool"), &Value::Boolean(false));
1071        assert_eq!(get(&fm, "int"), &Value::Number(7.0));
1072        assert_eq!(get(&fm, "float"), &Value::Number(2.5));
1073        // Beyond i64: the f64 fallback, not the integer path.
1074        assert!(
1075            matches!(get(&fm, "big"), Value::Number(n) if *n > 1e19)
1076        );
1077        assert_eq!(get(&fm, "str"), &Value::String("text".into()));
1078        assert_eq!(get(&fm, "arr").array_len(), Some(3));
1079        let obj = get(&fm, "obj").as_object().expect("nested object");
1080        assert!(matches!(obj.get("inner"), Some(Value::Object(_))));
1081    }
1082
1083    #[test]
1084    fn yaml_converts_every_scalar_and_container_kind() {
1085        let raw = "\
1086s: text
1087i: 7
1088f: 2.5
1089b: true
1090n: null
1091arr:
1092  - 1
1093  - a
1094obj:
1095  inner: x
1096";
1097        let fm = parse_yaml(raw).expect("valid YAML");
1098        assert_eq!(get(&fm, "s"), &Value::String("text".into()));
1099        assert_eq!(get(&fm, "i"), &Value::Number(7.0));
1100        assert_eq!(get(&fm, "b"), &Value::Boolean(true));
1101        assert_eq!(get(&fm, "n"), &Value::Null);
1102        assert_eq!(get(&fm, "arr").array_len(), Some(2));
1103        assert!(matches!(get(&fm, "obj"), Value::Object(_)));
1104    }
1105
1106    #[test]
1107    fn the_three_converters_agree_on_the_same_document() {
1108        let yaml =
1109            parse_yaml("a: 1\nb: text\nc: true\n").expect("yaml");
1110        let toml = parse_toml("a = 1\nb = \"text\"\nc = true\n")
1111            .expect("toml");
1112        let json = parse_json(r#"{"a": 1, "b": "text", "c": true}"#)
1113            .expect("json");
1114        for key in ["a", "b", "c"] {
1115            assert_eq!(
1116                get(&yaml, key),
1117                get(&toml, key),
1118                "yaml vs toml on {key}"
1119            );
1120            assert_eq!(
1121                get(&yaml, key),
1122                get(&json, key),
1123                "yaml vs json on {key}"
1124            );
1125        }
1126    }
1127
1128    #[test]
1129    fn format_mismatch_is_rejected_before_parsing() {
1130        let err =
1131            parse_with_options("no equals here", Format::Toml, None)
1132                .expect_err("TOML without '=' is rejected");
1133        assert!(
1134            matches!(err, Error::ConversionError(ref m) if m.contains("TOML"))
1135        );
1136
1137        let err =
1138            parse_with_options("not an object", Format::Json, None)
1139                .expect_err("JSON not starting with '{' is rejected");
1140        assert!(
1141            matches!(err, Error::ConversionError(ref m) if m.contains("JSON"))
1142        );
1143    }
1144
1145    #[test]
1146    fn validation_limits_are_enforced_only_when_asked() {
1147        let deep = r#"{"a": {"b": {"c": {"d": 1}}}}"#;
1148
1149        // Off by default for this entry point: the document parses.
1150        let options = ParseOptions {
1151            max_depth: 2,
1152            max_keys: 100,
1153            validate: false,
1154        };
1155        assert!(parse_with_options(deep, Format::Json, Some(options))
1156            .is_ok());
1157
1158        let options = ParseOptions {
1159            max_depth: 2,
1160            max_keys: 100,
1161            validate: true,
1162        };
1163        let err = parse_with_options(deep, Format::Json, Some(options))
1164            .expect_err("nesting beyond max_depth is rejected");
1165        assert!(matches!(err, Error::NestingTooDeep { .. }), "{err:?}");
1166
1167        let wide = r#"{"a": 1, "b": 2, "c": 3}"#;
1168        let options = ParseOptions {
1169            max_depth: 10,
1170            max_keys: 2,
1171            validate: true,
1172        };
1173        assert!(parse_with_options(wide, Format::Json, Some(options))
1174            .is_err());
1175    }
1176
1177    #[test]
1178    fn round_trips_through_every_format() {
1179        let fm = parse_json(r#"{"title": "T", "n": 2, "flag": true}"#)
1180            .expect("json");
1181        for format in [Format::Yaml, Format::Toml, Format::Json] {
1182            let text = to_string(&fm, format).expect("serialise");
1183            let back = parse(&text, format).expect("re-parse");
1184            assert_eq!(
1185                back.get("title"),
1186                Some(&Value::String("T".into())),
1187                "{format} lost the title"
1188            );
1189            assert_eq!(back.get("flag"), Some(&Value::Boolean(true)));
1190        }
1191    }
1192
1193    #[test]
1194    fn non_mapping_yaml_and_non_object_json_and_deep_yaml_serialisation(
1195    ) {
1196        log::set_max_level(log::LevelFilter::Trace);
1197
1198        // YAML document starting with `---` in parse_with_options
1199        let with_delim = parse_with_options(
1200            "---\ntitle: test\n",
1201            Format::Yaml,
1202            None,
1203        )
1204        .expect("yaml with ---");
1205        assert_eq!(
1206            with_delim.get("title"),
1207            Some(&Value::String("test".into()))
1208        );
1209
1210        // YAML scalar/sequence is rejected as non-mapping
1211        assert!(matches!(
1212            parse_yaml("- item1\n- item2\n"),
1213            Err(Error::ParseError(_))
1214        ));
1215
1216        // JSON non-object at parse_json level returns empty Frontmatter
1217        let empty = parse_json("[1, 2, 3]").expect("json array");
1218        assert!(empty.is_empty());
1219
1220        // TOML serialisation error on root Null
1221        let mut null_fm = Frontmatter::new();
1222        let _ = null_fm.insert("k".into(), Value::Null);
1223        assert!(matches!(
1224            to_toml(&null_fm),
1225            Err(Error::ConversionError(_))
1226        ));
1227
1228        // YAML serialisation error when nesting exceeds noyalib's max_depth
1229        let mut deep_val = Value::Null;
1230        for _ in 0..150 {
1231            deep_val = Value::Array(vec![deep_val]);
1232        }
1233        let mut deep_fm = Frontmatter::new();
1234        let _ = deep_fm.insert("deep".into(), deep_val);
1235        assert!(matches!(
1236            to_yaml(&deep_fm),
1237            Err(Error::ConversionError(_))
1238        ));
1239    }
1240
1241    #[test]
1242    fn unsupported_format_is_rejected_rather_than_reaching_unreachable()
1243    {
1244        // The `unreachable!()` arms downstream are guarded by this
1245        // check; if it ever stops rejecting, they start panicking.
1246        let err =
1247            parse_with_options("anything", Format::Unsupported, None)
1248                .expect_err("Unsupported must be refused up front");
1249        assert!(!err.to_string().is_empty());
1250    }
1251
1252    #[test]
1253    fn size_estimates_grow_with_the_document() {
1254        // The estimate feeds `to_json_optimised`'s buffer reservation.
1255        // It does not have to be exact, but it must account for every
1256        // variant: a variant returning 0 would make the reservation
1257        // useless for documents made of it.
1258        let empty = Frontmatter::new();
1259        assert!(estimate_json_size(&empty) >= 2, "at least the braces");
1260
1261        let mut fm = Frontmatter::new();
1262        let _ = fm.insert("k".into(), Value::String("value".into()));
1263        let one = estimate_json_size(&fm);
1264        let _ = fm.insert("k2".into(), Value::Number(1.0));
1265        assert!(
1266            estimate_json_size(&fm) > one,
1267            "adding a key must grow the estimate"
1268        );
1269
1270        for value in [
1271            Value::Null,
1272            Value::String("s".into()),
1273            Value::Number(1.0),
1274            Value::Boolean(true),
1275            Value::Array(vec![Value::Null, Value::Number(1.0)]),
1276            Value::Object(Box::new(Frontmatter::new())),
1277            Value::Tagged("t".into(), Box::new(Value::Null)),
1278        ] {
1279            assert!(
1280                estimate_value_size(&value) > 0,
1281                "{value:?} estimated as zero bytes"
1282            );
1283        }
1284
1285        // Nesting is counted through, not truncated at the first level.
1286        let nested =
1287            Value::Array(vec![Value::Array(vec![Value::String(
1288                "deep".into(),
1289            )])]);
1290        assert!(
1291            estimate_value_size(&nested)
1292                > estimate_value_size(&Value::Array(vec![]))
1293        );
1294    }
1295
1296    #[test]
1297    fn malformed_input_reports_the_formats_own_error() {
1298        assert!(parse_yaml("a: [").is_err());
1299        assert!(parse_toml("a = ").is_err());
1300        assert!(parse_json("{").is_err());
1301    }
1302}