Skip to main content

prov_graph/
document.rs

1//! Documents — a plaintext file with an embedded metadata block and a body,
2//! or a config file whose *entire content* is the metadata.
3//!
4//! The two shapes are one model: a config file is simply a document whose
5//! metadata carrier is the whole file and whose body is empty. Both parse to
6//! the same [`Document`], link through the same relations, and participate in
7//! traversal, validation, and mutation identically — which is what lets a
8//! workspace mix prose documents and config documents in one tree.
9
10use std::path::{Path, PathBuf};
11
12use crate::error::Result;
13use crate::meta::{self, Value};
14
15/// The embed archetype a fenced metadata block was found in. Re-exported from
16/// `fig`, which owns both detection (`fig::detect`) and the fence/format
17/// coupling ([`EmbedType::inner_format`]).
18pub use fig::EmbedType;
19
20/// Where a document's metadata physically lives — recorded at parse time so a
21/// write can preserve the original carrier exactly (a ```` ```fig ```` block is
22/// never rewritten as `---` YAML; a bare `.yaml` file never grows fences).
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum MetaCarrier {
25    /// A fenced block inside a host file (`---` YAML, `;;;` JSON,
26    /// ```` ```fig ````, endmatter), with the prose body around it.
27    Fenced(EmbedType),
28    /// The entire file is the metadata (a config document); the body is empty.
29    /// The format comes from the file extension.
30    WholeFile(fig::Format),
31}
32
33impl MetaCarrier {
34    /// The format the metadata is written in.
35    pub fn format(&self) -> fig::Format {
36        match self {
37            MetaCarrier::Fenced(kind) => kind.inner_format(),
38            MetaCarrier::WholeFile(format) => *format,
39        }
40    }
41}
42
43/// The whole-file metadata format implied by `path`'s extension, if any.
44/// These are the extensions prov treats as config documents.
45///
46/// Each extension is recognized only when its format feature is compiled in: a
47/// `.json` file is a config document under the `json` feature, and an ordinary
48/// (metadata-less) prose document without it. This keeps prov from claiming
49/// to read a format whose parser was left out of the build.
50pub fn whole_file_format(path: &Path) -> Option<fig::Format> {
51    match path.extension()?.to_str()? {
52        #[cfg(feature = "yaml")]
53        "yaml" | "yml" => Some(fig::Format::Yaml),
54        #[cfg(feature = "json")]
55        "json" => Some(fig::Format::Json),
56        #[cfg(feature = "toml")]
57        "toml" => Some(fig::Format::Toml),
58        #[cfg(feature = "fig-lang")]
59        "fig" | "figl" => Some(fig::Format::Fig),
60        _ => None,
61    }
62}
63
64/// Enforce that a **record store** at `path` (the id registry, the recycle-bin
65/// index, a flat vocabulary) is a whole-file config document, returning its
66/// format. A [`MetaCarrier::Fenced`] carrier — markdown frontmatter — is
67/// rejected with [`Error::MarkdownStore`](crate::error::Error::MarkdownStore): prov re-lays-out these stores as a
68/// sorted record list (DESIGN §5), so human prose has no stable home in them and
69/// unambiguous extension→format sniffing depends on the carrier being whole-file.
70/// The single choke point every store loader passes through, so the rule cannot
71/// be enforced in one place and forgotten in another.
72pub fn require_whole_file(path: &Path, carrier: MetaCarrier) -> Result<fig::Format> {
73    match carrier {
74        MetaCarrier::WholeFile(format) => Ok(format),
75        MetaCarrier::Fenced(_) => Err(crate::error::Error::MarkdownStore(path.to_path_buf())),
76    }
77}
78
79/// Whether prov can read `path` as text — a recognized body format
80/// (Markdown/Djot/HTML) or a whole-file metadata format (YAML/JSON/…). Its
81/// negation is an **opaque payload**: a file prov treats as bytes (an image,
82/// a PDF, a font, any binary) and never parses. An *attachment* is exactly a
83/// whole-file metadata sidecar whose `content` points at such a payload, which
84/// is how an arbitrary file gains workspace-linked metadata without being able
85/// to carry frontmatter itself.
86pub fn is_opaque_payload(path: &Path) -> bool {
87    crate::content::ContentFormat::from_extension(path).is_none()
88        && whole_file_format(path).is_none()
89}
90
91/// The canonical whole-file extension for a metadata `format` — the inverse of
92/// [`whole_file_format`]. Used when materializing a whole-file metadata document
93/// (a config/registry sidecar, or the metadata half of a *separated* document):
94/// `yaml`, `json`, `figl`. A format whose feature is not compiled falls back to
95/// `yaml` (the always-present default).
96pub fn whole_file_extension(format: fig::Format) -> &'static str {
97    match format {
98        #[cfg(feature = "json")]
99        fig::Format::Json => "json",
100        #[cfg(feature = "toml")]
101        fig::Format::Toml => "toml",
102        #[cfg(feature = "fig-lang")]
103        fig::Format::Fig => "figl",
104        _ => "yaml",
105    }
106}
107
108/// The fenced-frontmatter carrier for `format` — the archetype a new document
109/// gets when it inherits no parent block and the workspace default is `format`.
110/// A format whose feature is not compiled falls back to YAML frontmatter (which
111/// the default `yaml` feature always provides).
112pub fn frontmatter_carrier(format: fig::Format) -> MetaCarrier {
113    let embed = match format {
114        #[cfg(feature = "json")]
115        fig::Format::Json => EmbedType::FrontmatterJson,
116        #[cfg(feature = "toml")]
117        fig::Format::Toml => EmbedType::PlusToml,
118        #[cfg(feature = "fig-lang")]
119        fig::Format::Fig => EmbedType::FrontmatterFig,
120        _ => EmbedType::FrontmatterYaml,
121    };
122    MetaCarrier::Fenced(embed)
123}
124
125/// The archetype *family* a workspace authors embedded metadata in — the
126/// "embed type" the CLI's `init` prompts for, one level above the concrete
127/// [`EmbedType`]. A family plus a metadata [`fig::Format`] resolves to a
128/// carrier through [`embed_carrier`]: e.g. (`CodeBlock`, YAML) is a
129/// ```` ```yaml ```` block, (`Delimited`, TOML) is a `+++` block, and
130/// (`Separate`, JSON) is a whole-file `.json` sidecar. It is what the config
131/// document records (via `prov`'s `WorkspaceConfig`) so a
132/// workspace stays self-describing about *how* its metadata is embedded, not
133/// just which format it is written in.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum EmbedStyle {
136    /// Character-delimited frontmatter: `---` YAML, `+++` TOML, `;;;` JSON. A
137    /// Markdown convention; the fig dialect has no delimiter form.
138    Delimited,
139    /// A typed fenced code block — ```` ```yaml ````, ```` ```toml ````,
140    /// ```` ```json ````, ```` ```fig ```` — that renders as a visible block in
141    /// Markdown or Djot.
142    CodeBlock,
143    /// An HTML `<script type="application/…">` data island (not rendered).
144    HtmlScript,
145    /// An HTML `<pre><code class="language-…">` visible code block.
146    HtmlCode,
147    /// Metadata kept in a sibling *whole-file* document, joined to a plain body
148    /// file by a `content` attribute — neither file carries fences.
149    Separate,
150}
151
152impl EmbedStyle {
153    /// The `embed_type` config-document spelling for this style.
154    pub fn as_config_str(self) -> &'static str {
155        match self {
156            EmbedStyle::Delimited => "delimited",
157            EmbedStyle::CodeBlock => "code_block",
158            EmbedStyle::HtmlScript => "html_script",
159            EmbedStyle::HtmlCode => "html_code",
160            EmbedStyle::Separate => "separate",
161        }
162    }
163
164    /// Parse an `embed_type` config value. Unknown → `None` (keep the default).
165    pub fn from_config_str(value: &str) -> Option<Self> {
166        Some(match value {
167            "delimited" => EmbedStyle::Delimited,
168            "code_block" => EmbedStyle::CodeBlock,
169            "html_script" => EmbedStyle::HtmlScript,
170            "html_code" => EmbedStyle::HtmlCode,
171            "separate" => EmbedStyle::Separate,
172            _ => return None,
173        })
174    }
175}
176
177/// The [`EmbedStyle`] *family* a concrete [`EmbedType`] belongs to — the inverse
178/// (on the style axis) of [`embed_carrier`], which resolves a `(style, format)`
179/// pair back to a carrier. Pairing it with a new metadata format is how a
180/// *format conversion* keeps a document's embedding shape while changing only its
181/// frontmatter language: classify the current archetype, then [`embed_carrier`]
182/// the same style with the target format.
183///
184/// The bare delimiter frontmatters (`---`/`+++`/`;;;`) and the labeled markdown
185/// frontmatters (`---json`/`---toml`/`---fig`) are all [`Delimited`](EmbedStyle::Delimited);
186/// [`embed_carrier`] then lands each format on prov's canonical delimiter
187/// spelling. Endmatter is grouped with the fenced [`CodeBlock`](EmbedStyle::CodeBlock)
188/// forms (it is a trailing ```` ```endmatter ```` block); converting it to another
189/// format therefore moves it to a leading fenced block, since only YAML has an
190/// endmatter archetype.
191pub fn embed_style_of(kind: EmbedType) -> EmbedStyle {
192    use EmbedType as E;
193    match kind {
194        E::FrontmatterYaml
195        | E::FrontmatterJson
196        | E::PlusToml
197        | E::MdFrontmatterJson
198        | E::MdFrontmatterToml
199        | E::MdFrontmatterFig => EmbedStyle::Delimited,
200        E::EndmatterYaml | E::FencedYaml | E::FencedJson | E::FencedToml | E::FrontmatterFig => {
201            EmbedStyle::CodeBlock
202        }
203        E::HtmlScriptYaml | E::HtmlScriptJson | E::HtmlScriptToml | E::HtmlScriptFig => {
204            EmbedStyle::HtmlScript
205        }
206        E::HtmlCodeYaml | E::HtmlCodeJson | E::HtmlCodeToml | E::HtmlCodeFig => {
207            EmbedStyle::HtmlCode
208        }
209        // `EmbedType` is `#[non_exhaustive]`: a fig version newer than this crate
210        // may detect an archetype prov doesn't know yet. Guessing a style here
211        // would risk silently misclassifying a real document mid-`convert`, so
212        // this needs an actual case added (here, `embed_carrier`, and the prose
213        // in `about.rs`/`history/docs.rs`) before such a document can be handled.
214        _ => unreachable!("unhandled fig::EmbedType variant — add a case in embed_style_of"),
215    }
216}
217
218/// Resolve an [`EmbedStyle`] + metadata `format` to the carrier a new document
219/// should get. `Separate` maps to a whole-file sidecar in `format`; every other
220/// style maps to the concrete [`EmbedType`] for that `(style, format)` pair.
221/// `None` for a combination that has no archetype — notably `Delimited` + fig
222/// (the dialect has no `---`-style delimiter) and any fenced style paired with a
223/// format fig cannot fence (`Zon`).
224pub fn embed_carrier(style: EmbedStyle, format: fig::Format) -> Option<MetaCarrier> {
225    use EmbedType as E;
226    use fig::Format as F;
227    // JSON's three dialects share one fenced/frontmatter archetype.
228    let is_json = matches!(format, F::Json | F::Jsonc | F::Json5);
229    let kind = match style {
230        EmbedStyle::Separate => return Some(MetaCarrier::WholeFile(format)),
231        EmbedStyle::Delimited => match format {
232            F::Yaml => E::FrontmatterYaml,
233            F::Toml => E::PlusToml,
234            _ if is_json => E::FrontmatterJson,
235            _ => return None,
236        },
237        EmbedStyle::CodeBlock => match format {
238            F::Yaml => E::FencedYaml,
239            F::Toml => E::FencedToml,
240            F::Fig => E::FrontmatterFig,
241            _ if is_json => E::FencedJson,
242            _ => return None,
243        },
244        EmbedStyle::HtmlScript => match format {
245            F::Yaml => E::HtmlScriptYaml,
246            F::Toml => E::HtmlScriptToml,
247            F::Fig => E::HtmlScriptFig,
248            _ if is_json => E::HtmlScriptJson,
249            _ => return None,
250        },
251        EmbedStyle::HtmlCode => match format {
252            F::Yaml => E::HtmlCodeYaml,
253            F::Toml => E::HtmlCodeToml,
254            F::Fig => E::HtmlCodeFig,
255            _ if is_json => E::HtmlCodeJson,
256            _ => return None,
257        },
258    };
259    Some(MetaCarrier::Fenced(kind))
260}
261
262/// A parsed document: its path, its embedded metadata, and its body text.
263///
264/// Metadata is stored as a dynamic [`Value`] (a mapping, or [`Value::Null`] when
265/// the document has no frontmatter) because link fields are configurable and
266/// therefore accessed dynamically.
267#[derive(Debug, Clone)]
268pub struct Document {
269    /// Path this document was read from (workspace-relative or absolute — the
270    /// caller decides; prov does not interpret it here).
271    pub path: PathBuf,
272    /// Parsed embedded metadata.
273    pub meta: Value,
274    /// Everything outside the metadata block (the host prose). Empty for a
275    /// config document.
276    pub body: String,
277    /// Where the metadata was found, or `None` when the document has no
278    /// (well-formed) metadata. Preserved on write.
279    pub carrier: Option<MetaCarrier>,
280}
281
282impl Document {
283    /// Parse a document from its full text.
284    ///
285    /// If `path` has a config extension (`.yaml`, `.yml`, `.json`, `.fig`,
286    /// `.figl`), the entire text is the metadata and the body is empty.
287    /// Otherwise the embedded metadata block is auto-detected via
288    /// `fig::detect` — any archetype fig knows (`---` YAML, `;;;` JSON,
289    /// ```` ```fig ````, ```` ```endmatter ````) — and parsed in that
290    /// archetype's inner format. If there is no (well-formed) block, `meta`
291    /// is [`Value::Null`] and the whole text is the body. An unterminated
292    /// opening fence is treated as no metadata — we do not guess where it
293    /// ends.
294    pub fn parse(path: impl Into<PathBuf>, text: &str) -> Result<Self> {
295        let path = path.into();
296        if let Some(format) = whole_file_format(&path) {
297            let meta = meta::parse_value(text, format)?;
298            return Ok(Self {
299                path,
300                meta,
301                body: String::new(),
302                carrier: Some(MetaCarrier::WholeFile(format)),
303            });
304        }
305        let (meta, body, carrier) = match fig::detect(text) {
306            Some(kind) => match fig::split(text, kind) {
307                Some((content, body)) => (
308                    meta::parse_value(content, kind.inner_format())?,
309                    body.to_owned(),
310                    Some(MetaCarrier::Fenced(kind)),
311                ),
312                // Detected by its open delimiter but with no matching close:
313                // recognized-but-malformed degrades to "no metadata".
314                None => (Value::Null, text.to_owned(), None),
315            },
316            None => (Value::Null, text.to_owned(), None),
317        };
318        Ok(Self {
319            path,
320            meta,
321            body,
322            carrier,
323        })
324    }
325
326    /// Zero-copy counterpart to [`parse`](Self::parse): locate a fenced
327    /// metadata block in `text` without parsing it, returning the
328    /// [`MetaCarrier`] found and the two slices it borrows from `text` —
329    /// `(meta_block, body)`. Mirrors `fig::detect`/`fig::split` composed into
330    /// one step, the same primitives `parse` builds its owned, parsed
331    /// [`Value`] from.
332    ///
333    /// Only recognizes a *fenced* carrier — a whole-file (config) document has
334    /// no split to offer, since its entire text already is the metadata; a
335    /// caller steering by path extension (as `parse` does via
336    /// [`whole_file_format`]) should check that first. Returns `None` when
337    /// `text` opens no known archetype, or its opening fence has no matching
338    /// close (an unterminated fence degrades to "no metadata", matching
339    /// `parse`).
340    ///
341    /// The caller who wants the parsed [`Value`] should use [`parse`](Self::parse)
342    /// instead; this exists for one who wants to defer parsing to their own
343    /// deserializer, or just needs the raw borrowed text (e.g. to detect which
344    /// archetype a document uses without allocating).
345    pub fn split(text: &str) -> Option<(MetaCarrier, &str, &str)> {
346        let kind = fig::detect(text)?;
347        let (meta, body) = fig::split(text, kind)?;
348        Some((MetaCarrier::Fenced(kind), meta, body))
349    }
350
351    /// The document's path.
352    pub fn path(&self) -> &Path {
353        &self.path
354    }
355
356    /// `true` if the document declares any embedded metadata mapping.
357    pub fn has_meta(&self) -> bool {
358        self.meta.as_mapping().is_some()
359    }
360
361    /// The raw `content` attribute — the relative path to a *separated*
362    /// document's body file — or `None` for an ordinary (combined) document
363    /// whose body is [`self.body`](Document::body). A separated document is a
364    /// whole-file metadata document (`.yaml`/`.json`/`.figl`) that points at its
365    /// prose body in a sibling file, keeping both halves plain text and linked.
366    pub fn content_attr(&self) -> Option<&str> {
367        self.meta.get("content").and_then(Value::as_str)
368    }
369
370    /// The raw `manifest` attribute — the relative path to the manifest
371    /// document listing the files this node stands for — or `None` for a node
372    /// that stands for itself.
373    ///
374    /// The bulk counterpart of [`content_attr`](Document::content_attr) and
375    /// **mutually exclusive** with it: a node covers one payload or a set of
376    /// them, never both. See [`manifest`](crate::manifest) for the record shape.
377    pub fn manifest_attr(&self) -> Option<&str> {
378        self.meta
379            .get(crate::manifest::MANIFEST_KEY)
380            .and_then(Value::as_str)
381    }
382
383    /// `true` when this document is a **manifest node**: it declares a
384    /// `manifest` pointer, so the files it stands for are listed there rather
385    /// than being a single `content` payload.
386    pub fn is_manifest_node(&self) -> bool {
387        self.manifest_attr().is_some()
388    }
389
390    /// `true` when this document declares *both* `content` and `manifest` —
391    /// a node claiming to be a single payload's sidecar and a whole
392    /// directory's at once. Neither reading is safe to pick, so the pair is
393    /// reported rather than resolved.
394    pub fn manifest_conflicts(&self) -> bool {
395        self.content_attr().is_some() && self.manifest_attr().is_some()
396    }
397
398    /// `true` when this document is an **attachment sidecar**: a whole-file
399    /// metadata document whose `content` points at an [opaque
400    /// payload](is_opaque_payload) rather than a prose body. Recognized two ways,
401    /// so a hand-written sidecar need not be verbose: an explicit `attachment:
402    /// true` flag (what `prov`'s `Workspace::attach` writes),
403    /// **or** a `content` target whose extension prov cannot read as text.
404    ///
405    /// A *separated prose* document (`content` → a `.md`/`.dj`/`.html` body) is
406    /// deliberately **not** an attachment: its body is a prov document in its
407    /// own right, scanned for links and titles; an attachment's payload is bytes
408    /// prov never opens.
409    pub fn is_attachment(&self) -> bool {
410        match self.content_attr() {
411            None => false,
412            Some(content) => {
413                self.meta.get("attachment").and_then(Value::as_bool) == Some(true)
414                    || is_opaque_payload(Path::new(content))
415            }
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[cfg(feature = "yaml")]
425    #[test]
426    fn parses_yaml_frontmatter_and_body() {
427        let text = "---\ntitle: Root\ncontents:\n- a.md\n---\n# Body\n\nhello\n";
428        let doc = Document::parse("index.md", text).unwrap();
429        assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("Root"));
430        assert_eq!(doc.body, "# Body\n\nhello\n");
431        assert_eq!(
432            doc.carrier,
433            Some(MetaCarrier::Fenced(EmbedType::FrontmatterYaml))
434        );
435        assert!(doc.has_meta());
436    }
437
438    #[cfg(feature = "fig-lang")]
439    #[test]
440    fn parses_fig_fenced_frontmatter() {
441        let text = "```fig\ntitle = prov\ncontents = [docs/design.md]\n```\n# Body\n";
442        let doc = Document::parse("README.md", text).unwrap();
443        assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("prov"));
444        assert_eq!(doc.body, "# Body\n");
445        assert_eq!(
446            doc.carrier,
447            Some(MetaCarrier::Fenced(EmbedType::FrontmatterFig))
448        );
449        assert!(doc.has_meta());
450    }
451
452    #[cfg(feature = "json")]
453    #[test]
454    fn parses_json_frontmatter() {
455        let text = ";;;\n{\"title\": \"Root\"}\n;;;\nbody\n";
456        let doc = Document::parse("note.md", text).unwrap();
457        assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("Root"));
458        assert_eq!(
459            doc.carrier,
460            Some(MetaCarrier::Fenced(EmbedType::FrontmatterJson))
461        );
462    }
463
464    #[cfg(feature = "yaml")]
465    #[test]
466    fn parses_yaml_endmatter() {
467        let text = "# Body first\n```endmatter\ntitle: Tail\n```\n";
468        let doc = Document::parse("note.md", text).unwrap();
469        assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("Tail"));
470        assert_eq!(doc.body, "# Body first\n");
471        assert_eq!(
472            doc.carrier,
473            Some(MetaCarrier::Fenced(EmbedType::EndmatterYaml))
474        );
475    }
476
477    #[cfg(feature = "yaml")]
478    #[test]
479    fn a_config_file_is_a_document_whose_content_is_all_metadata() {
480        let text = "title: ID registry\npart_of: index.md\nregistry:\n  abc: a.md\n";
481        let doc = Document::parse("registry.yaml", text).unwrap();
482        assert_eq!(
483            doc.meta.get("title").and_then(Value::as_str),
484            Some("ID registry")
485        );
486        assert_eq!(
487            doc.meta.get("part_of").and_then(Value::as_str),
488            Some("index.md")
489        );
490        assert_eq!(doc.body, "");
491        assert_eq!(doc.carrier, Some(MetaCarrier::WholeFile(fig::Format::Yaml)));
492        assert!(doc.has_meta());
493    }
494
495    #[cfg(feature = "fig-lang")]
496    #[test]
497    fn a_fig_config_file_parses_the_dialect() {
498        let text = "title = settings\npart_of = index.md\n";
499        let doc = Document::parse("settings.figl", text).unwrap();
500        assert_eq!(
501            doc.meta.get("title").and_then(Value::as_str),
502            Some("settings")
503        );
504        assert_eq!(doc.carrier, Some(MetaCarrier::WholeFile(fig::Format::Fig)));
505    }
506
507    #[test]
508    fn embed_style_config_str_round_trips() {
509        for style in [
510            EmbedStyle::Delimited,
511            EmbedStyle::CodeBlock,
512            EmbedStyle::HtmlScript,
513            EmbedStyle::HtmlCode,
514            EmbedStyle::Separate,
515        ] {
516            assert_eq!(
517                EmbedStyle::from_config_str(style.as_config_str()),
518                Some(style)
519            );
520        }
521        assert_eq!(EmbedStyle::from_config_str("nonsense"), None);
522    }
523
524    #[test]
525    fn embed_carrier_resolves_style_and_format_to_a_carrier() {
526        use fig::Format;
527        let fenced = |k| Some(MetaCarrier::Fenced(k));
528        // Delimited: the three delimiter formats, but the fig dialect has none.
529        assert_eq!(
530            embed_carrier(EmbedStyle::Delimited, Format::Yaml),
531            fenced(EmbedType::FrontmatterYaml)
532        );
533        assert_eq!(
534            embed_carrier(EmbedStyle::Delimited, Format::Toml),
535            fenced(EmbedType::PlusToml)
536        );
537        assert_eq!(
538            embed_carrier(EmbedStyle::Delimited, Format::Json),
539            fenced(EmbedType::FrontmatterJson)
540        );
541        assert_eq!(embed_carrier(EmbedStyle::Delimited, Format::Fig), None);
542        // Code block: fig lands in the ```fig block; the rest in ```lang blocks.
543        assert_eq!(
544            embed_carrier(EmbedStyle::CodeBlock, Format::Fig),
545            fenced(EmbedType::FrontmatterFig)
546        );
547        assert_eq!(
548            embed_carrier(EmbedStyle::CodeBlock, Format::Yaml),
549            fenced(EmbedType::FencedYaml)
550        );
551        // HTML islands, both shapes.
552        assert_eq!(
553            embed_carrier(EmbedStyle::HtmlScript, Format::Json),
554            fenced(EmbedType::HtmlScriptJson)
555        );
556        assert_eq!(
557            embed_carrier(EmbedStyle::HtmlCode, Format::Toml),
558            fenced(EmbedType::HtmlCodeToml)
559        );
560        // Separate is a whole-file sidecar in the chosen format (any format).
561        assert_eq!(
562            embed_carrier(EmbedStyle::Separate, Format::Yaml),
563            Some(MetaCarrier::WholeFile(Format::Yaml))
564        );
565        assert_eq!(
566            embed_carrier(EmbedStyle::Separate, Format::Fig),
567            Some(MetaCarrier::WholeFile(Format::Fig))
568        );
569    }
570
571    #[test]
572    fn no_frontmatter_is_all_body() {
573        let doc = Document::parse("note.md", "# Just a note\n").unwrap();
574        assert!(doc.meta.is_null());
575        assert_eq!(doc.body, "# Just a note\n");
576        assert_eq!(doc.carrier, None);
577        assert!(!doc.has_meta());
578    }
579
580    #[test]
581    fn unterminated_fence_is_not_frontmatter() {
582        let text = "---\ntitle: oops\nno closing fence\n";
583        let doc = Document::parse("x.md", text).unwrap();
584        assert!(doc.meta.is_null());
585        assert_eq!(doc.body, text);
586        assert_eq!(doc.carrier, None);
587    }
588
589    #[cfg(feature = "yaml")]
590    #[test]
591    fn split_borrows_yaml_frontmatter_and_body_without_parsing() {
592        let text = "---\ntitle: Root\n---\n# Body\n\nhello\n";
593        let (carrier, meta, body) = Document::split(text).unwrap();
594        assert_eq!(carrier, MetaCarrier::Fenced(EmbedType::FrontmatterYaml));
595        assert_eq!(meta, "title: Root\n");
596        assert_eq!(body, "# Body\n\nhello\n");
597        // Byte-identical to what `parse` extracts, just unparsed and borrowed.
598        let doc = Document::parse("x.md", text).unwrap();
599        assert_eq!(doc.body, body);
600    }
601
602    #[cfg(feature = "yaml")]
603    #[test]
604    fn split_handles_crlf_line_endings() {
605        let text = "---\r\ntitle: Root\r\n---\r\nbody\r\n";
606        let (carrier, meta, body) = Document::split(text).unwrap();
607        assert_eq!(carrier, MetaCarrier::Fenced(EmbedType::FrontmatterYaml));
608        assert_eq!(meta, "title: Root\r\n");
609        assert_eq!(body, "body\r\n");
610    }
611
612    #[test]
613    fn split_is_none_with_no_frontmatter() {
614        assert_eq!(Document::split("# Just a note\n"), None);
615    }
616
617    #[test]
618    fn split_is_none_for_an_unterminated_fence() {
619        let text = "---\ntitle: oops\nno closing fence\n";
620        assert_eq!(Document::split(text), None);
621    }
622
623    #[cfg(feature = "fig-lang")]
624    #[test]
625    fn split_recognizes_a_non_yaml_carrier() {
626        let text = "```fig\ntitle = prov\n```\n# Body\n";
627        let (carrier, meta, body) = Document::split(text).unwrap();
628        assert_eq!(carrier, MetaCarrier::Fenced(EmbedType::FrontmatterFig));
629        assert_eq!(meta, "title = prov\n");
630        assert_eq!(body, "# Body\n");
631    }
632
633    #[cfg(feature = "json")]
634    #[test]
635    fn split_recognizes_json_frontmatter() {
636        let text = ";;;\n{\"title\": \"Root\"}\n;;;\nbody\n";
637        let (carrier, meta, body) = Document::split(text).unwrap();
638        assert_eq!(carrier, MetaCarrier::Fenced(EmbedType::FrontmatterJson));
639        assert_eq!(meta, "{\"title\": \"Root\"}\n");
640        assert_eq!(body, "body\n");
641    }
642
643    #[cfg(feature = "yaml")]
644    #[test]
645    fn crlf_fences_are_handled() {
646        let text = "---\r\ntitle: Root\r\n---\r\nbody\r\n";
647        let doc = Document::parse("x.md", text).unwrap();
648        assert_eq!(
649            doc.carrier,
650            Some(MetaCarrier::Fenced(EmbedType::FrontmatterYaml))
651        );
652        assert_eq!(doc.body, "body\r\n");
653        // Exact scalar — fig ≥ 2.1.1 treats \r\n as a single line break.
654        assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("Root"));
655    }
656}