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}