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}