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