Skip to main content

ssg/core/
frontmatter.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Shared frontmatter extraction and `.meta.json` sidecar support.
5//!
6//! This module bridges content files (Markdown with YAML/TOML/JSON
7//! frontmatter) and the plugin pipeline by persisting parsed metadata
8//! as `.meta.json` sidecar files that survive the compilation step.
9
10use anyhow::{Context, Result};
11use std::{collections::BTreeMap, fs, path::Path};
12
13use crate::MAX_DIR_DEPTH;
14
15/// Emits `.meta.json` sidecar files for all Markdown content.
16///
17/// Walks `content_dir` for `.md` files, extracts frontmatter via
18/// `frontmatter-gen`, and writes a JSON sidecar alongside each file
19/// in the same relative location under `sidecar_dir`.
20///
21/// These sidecars are consumed by `TeraPlugin`, `JsonLdPlugin`, and
22/// other plugins that need parsed frontmatter after compilation.
23///
24/// # Examples
25///
26/// ```rust
27/// use ssg::frontmatter::emit_sidecars;
28/// use tempfile::tempdir;
29/// use std::fs;
30///
31/// let dir = tempdir().unwrap();
32/// let content = dir.path().join("content");
33/// let sidecar = dir.path().join("sidecar");
34/// fs::create_dir(&content).unwrap();
35/// fs::write(content.join("a.md"), "---\ntitle: Hi\n---\nBody").unwrap();
36/// let n = emit_sidecars(&content, &sidecar).unwrap();
37/// assert_eq!(n, 1);
38/// ```
39pub fn emit_sidecars(content_dir: &Path, sidecar_dir: &Path) -> Result<usize> {
40    // Streamed rather than collected. `collect_md_files` returned a sorted
41    // `Vec<PathBuf>` held for the whole pass; on the 10,000-page fixture that
42    // vector *was* this function's peak heap — 1,927 KiB, identical to walking
43    // the directory alone — because no single document's parse and
44    // serialisation ever exceeded it (#578). Per-directory sorting keeps the
45    // visit order identical to the sorted list, on every platform.
46    let mut count = 0;
47
48    crate::walk::visit_files_bounded_depth(
49        content_dir,
50        "md",
51        MAX_DIR_DEPTH,
52        |md_path| -> Result<()> {
53            let content = fs::read_to_string(md_path).with_context(|| {
54                format!("Failed to read {}", md_path.display())
55            })?;
56
57            let meta = match frontmatter_gen::extract(&content) {
58                Ok((fm, body)) => {
59                    let mut m = frontmatter_into_json(fm);
60                    let word_count = body.split_whitespace().count();
61                    let reading_time = (word_count / 200).max(1);
62                    let _ = m.insert(
63                        "word_count".to_string(),
64                        serde_json::Value::Number(word_count.into()),
65                    );
66                    let _ = m.insert(
67                        "reading_time".to_string(),
68                        serde_json::Value::Number(reading_time.into()),
69                    );
70                    m
71                }
72                Err(_) => return Ok(()), // no frontmatter — skip
73            };
74
75            // Compute relative path and write sidecar
76            let rel = md_path.strip_prefix(content_dir).unwrap_or(md_path);
77            let sidecar_path =
78                sidecar_dir.join(rel).with_extension("meta.json");
79
80            if let Some(parent) = sidecar_path.parent() {
81                fs::create_dir_all(parent)?;
82            }
83
84            let json = serde_json::to_string_pretty(&meta)?;
85            fs::write(&sidecar_path, json)?;
86            count += 1;
87            Ok(())
88        },
89    )?;
90
91    Ok(count)
92}
93
94/// Reads a `.meta.json` sidecar for a given HTML file path.
95///
96/// Looks for `<stem>.meta.json` alongside the HTML file.
97/// Returns `None` if the sidecar does not exist.
98///
99/// # Examples
100///
101/// ```rust
102/// use ssg::frontmatter::read_sidecar;
103/// use tempfile::tempdir;
104///
105/// let dir = tempdir().unwrap();
106/// let html = dir.path().join("page.html");
107/// // No sidecar present ⇒ Ok(None).
108/// assert!(read_sidecar(&html).unwrap().is_none());
109/// ```
110pub fn read_sidecar(
111    html_path: &Path,
112) -> Result<Option<BTreeMap<String, serde_json::Value>>> {
113    let sidecar = html_path.with_extension("meta.json");
114    if !sidecar.exists() {
115        return Ok(None);
116    }
117
118    let content = fs::read_to_string(&sidecar).with_context(|| {
119        format!("Failed to read sidecar {}", sidecar.display())
120    })?;
121    let meta: BTreeMap<String, serde_json::Value> =
122        serde_json::from_str(&content)?;
123    Ok(Some(meta))
124}
125
126/// Reads a `.meta.json` sidecar matching an HTML path in the site dir,
127/// looking up by the corresponding content-relative path.
128///
129/// # Examples
130///
131/// ```rust
132/// use ssg::frontmatter::read_sidecar_for_html;
133/// use tempfile::tempdir;
134///
135/// let dir = tempdir().unwrap();
136/// let site = dir.path().join("site");
137/// let sidecar = dir.path().join("sidecar");
138/// let html = site.join("page.html");
139/// // No sidecar dir or file ⇒ Ok(None).
140/// assert!(read_sidecar_for_html(&html, &site, &sidecar).unwrap().is_none());
141/// ```
142pub fn read_sidecar_for_html(
143    html_path: &Path,
144    site_dir: &Path,
145    sidecar_dir: &Path,
146) -> Result<Option<BTreeMap<String, serde_json::Value>>> {
147    let rel = html_path.strip_prefix(site_dir).unwrap_or(html_path);
148    let sidecar_path = sidecar_dir.join(rel).with_extension("meta.json");
149    if !sidecar_path.exists() {
150        // Try .html → .md mapping
151        let md_sidecar = sidecar_dir.join(rel.with_extension("md.meta.json"));
152        if md_sidecar.exists() {
153            return read_sidecar(&md_sidecar.with_extension(""));
154        }
155        return Ok(None);
156    }
157    read_sidecar(&sidecar_path.with_extension("").with_extension(""))
158}
159
160/// Converts an **owned** `Frontmatter` into a JSON-compatible map,
161/// moving keys and string values rather than copying them.
162///
163/// Toward #578. That issue asks for zero-copy borrowing of plain
164/// scalars, which `frontmatter-gen 0.0.6` cannot express: its
165/// `Value::String(String)` and `Frontmatter(HashMap<String, Value>)`
166/// are owned and carry no lifetime, so borrowing needs the upstream
167/// change the issue anticipates. Moving is the part that is available
168/// without it, and it is not marginal: the borrowed conversion clones
169/// every key and every string value on every page, so this removes one
170/// full heap copy of each.
171///
172/// Both call sites own their `Frontmatter` and drop it immediately
173/// afterwards, so nothing needs the borrowed form at those points.
174pub(crate) fn frontmatter_into_json(
175    fm: frontmatter_gen::Frontmatter,
176) -> BTreeMap<String, serde_json::Value> {
177    let mut map = BTreeMap::new();
178    for (key, value) in fm.0 {
179        let _ = map.insert(key, fm_value_into_json(value));
180    }
181    map
182}
183
184/// Owned value converter: moves strings instead of
185/// cloning them.
186fn fm_value_into_json(value: frontmatter_gen::Value) -> serde_json::Value {
187    match value {
188        frontmatter_gen::Value::String(s) => serde_json::Value::String(s),
189        frontmatter_gen::Value::Number(n) => serde_json::json!(n),
190        frontmatter_gen::Value::Boolean(b) => serde_json::Value::Bool(b),
191        frontmatter_gen::Value::Array(arr) => serde_json::Value::Array(
192            arr.into_iter().map(fm_value_into_json).collect(),
193        ),
194        frontmatter_gen::Value::Object(obj) => {
195            let map: serde_json::Map<String, serde_json::Value> = obj
196                .0
197                .into_iter()
198                .map(|(k, v)| (k, fm_value_into_json(v)))
199                .collect();
200            serde_json::Value::Object(map)
201        }
202        frontmatter_gen::Value::Null => serde_json::Value::Null,
203        other @ frontmatter_gen::Value::Tagged(..) => {
204            serde_json::Value::String(format!("{other:?}"))
205        }
206    }
207}
208
209/// Recursively collects `.md` files from a directory, bounded by depth.
210/// Kept for the unit tests below; production code streams the walk via
211/// `crate::walk::visit_files_bounded_depth` so the file list is never held.
212#[cfg(test)]
213fn collect_md_files(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
214    crate::walk::walk_files_bounded_depth(dir, "md", MAX_DIR_DEPTH)
215        .map_err(Into::into)
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use std::fs;
222    use std::path::PathBuf;
223    use tempfile::{tempdir, TempDir};
224
225    // -------------------------------------------------------------------
226    // Test fixtures
227    // -------------------------------------------------------------------
228
229    /// Builds a `content/` + `sidecars/` layout under a tempdir.
230    fn make_layout() -> (TempDir, PathBuf, PathBuf) {
231        crate::test_support::init_logger();
232        let dir = tempdir().expect("tempdir");
233        let content = dir.path().join("content");
234        let sidecars = dir.path().join("sidecars");
235        fs::create_dir_all(&content).expect("mkdir content");
236        (dir, content, sidecars)
237    }
238
239    // -------------------------------------------------------------------
240    // emit_sidecars — happy path, skip path, subdirectory recursion
241    // -------------------------------------------------------------------
242
243    #[test]
244    fn emit_sidecars_writes_json_for_file_with_frontmatter() {
245        let (_tmp, content, sidecars) = make_layout();
246        let md = "---\ntitle: Hello World\ndate: 2026-01-01\n---\n# Content\n";
247        fs::write(content.join("index.md"), md).unwrap();
248
249        let count = emit_sidecars(&content, &sidecars).unwrap();
250        assert_eq!(count, 1);
251        assert!(sidecars.join("index.meta.json").exists());
252
253        let body =
254            fs::read_to_string(sidecars.join("index.meta.json")).unwrap();
255        let parsed: BTreeMap<String, serde_json::Value> =
256            serde_json::from_str(&body).unwrap();
257        assert!(parsed.contains_key("title"));
258        assert_eq!(parsed.get("word_count").unwrap().as_u64().unwrap(), 2);
259        assert_eq!(parsed.get("reading_time").unwrap().as_u64().unwrap(), 1);
260    }
261
262    #[test]
263    fn emit_sidecars_reading_time_scales_with_long_body() {
264        // Every other test body is a handful of words, so
265        // `(word_count / 200).max(1)` always takes the `1` branch.
266        // Use a body with 400+ words so the quotient itself (2) wins
267        // over the floor, exercising the other side of that
268        // computation.
269        let (_tmp, content, sidecars) = make_layout();
270        let long_body = "word ".repeat(450);
271        let md = format!("---\ntitle: Long\n---\n{long_body}");
272        fs::write(content.join("long.md"), md).unwrap();
273
274        let count = emit_sidecars(&content, &sidecars).unwrap();
275        assert_eq!(count, 1);
276
277        let body = fs::read_to_string(sidecars.join("long.meta.json")).unwrap();
278        let parsed: BTreeMap<String, serde_json::Value> =
279            serde_json::from_str(&body).unwrap();
280        assert_eq!(parsed.get("word_count").unwrap().as_u64().unwrap(), 450);
281        assert_eq!(parsed.get("reading_time").unwrap().as_u64().unwrap(), 2);
282    }
283
284    #[test]
285    fn emit_sidecars_skips_files_without_frontmatter() {
286        let (_tmp, content, sidecars) = make_layout();
287        fs::write(content.join("plain.md"), "No frontmatter here.").unwrap();
288
289        let count = emit_sidecars(&content, &sidecars).unwrap();
290        assert_eq!(count, 0);
291    }
292
293    #[test]
294    fn emit_sidecars_creates_nested_output_directories() {
295        // The `fs::create_dir_all(parent)` call at line 45 must create
296        // the mirrored subdirectory tree under the sidecar root.
297        let (_tmp, content, sidecars) = make_layout();
298        let nested = content.join("blog").join("2026");
299        fs::create_dir_all(&nested).unwrap();
300        fs::write(nested.join("post.md"), "---\ntitle: Nested\n---\nbody")
301            .unwrap();
302
303        let count = emit_sidecars(&content, &sidecars).unwrap();
304        assert_eq!(count, 1);
305        assert!(sidecars
306            .join("blog")
307            .join("2026")
308            .join("post.meta.json")
309            .exists());
310    }
311
312    #[test]
313    fn emit_sidecars_counts_only_files_with_frontmatter() {
314        let (_tmp, content, sidecars) = make_layout();
315        fs::write(content.join("a.md"), "---\ntitle: A\n---\nbody").unwrap();
316        fs::write(content.join("b.md"), "no frontmatter").unwrap();
317        fs::write(content.join("c.md"), "---\ntitle: C\n---\nbody").unwrap();
318
319        let count = emit_sidecars(&content, &sidecars).unwrap();
320        assert_eq!(count, 2);
321    }
322
323    #[test]
324    fn emit_sidecars_missing_content_dir_returns_ok_with_zero() {
325        let dir = tempdir().expect("tempdir");
326        let missing = dir.path().join("does-not-exist");
327        let sidecars = dir.path().join("sidecars");
328        let count = emit_sidecars(&missing, &sidecars).unwrap();
329        assert_eq!(count, 0);
330    }
331
332    // -------------------------------------------------------------------
333    // read_sidecar — happy + missing + invalid JSON
334    // -------------------------------------------------------------------
335
336    #[test]
337    fn read_sidecar_missing_file_returns_none() {
338        // The `!sidecar.exists()` early return at line 64.
339        let dir = tempdir().expect("tempdir");
340        let result = read_sidecar(&dir.path().join("ghost.html")).unwrap();
341        assert!(result.is_none());
342    }
343
344    #[test]
345    fn read_sidecar_existing_sidecar_returns_parsed_map() {
346        let dir = tempdir().expect("tempdir");
347        let html = dir.path().join("post.html");
348        let sidecar = dir.path().join("post.meta.json");
349        fs::write(&html, "").unwrap();
350        fs::write(&sidecar, r#"{"title": "T", "tag": "rust"}"#).unwrap();
351
352        let result = read_sidecar(&html).unwrap().unwrap();
353        assert_eq!(result.get("title").unwrap().as_str(), Some("T"));
354        assert_eq!(result.get("tag").unwrap().as_str(), Some("rust"));
355    }
356
357    #[test]
358    fn read_sidecar_invalid_json_returns_err() {
359        // Guards the `serde_json::from_str(&content)?` propagation
360        // at line 71.
361        let dir = tempdir().expect("tempdir");
362        let html = dir.path().join("post.html");
363        let sidecar = dir.path().join("post.meta.json");
364        fs::write(&html, "").unwrap();
365        fs::write(&sidecar, "{not valid json").unwrap();
366
367        assert!(read_sidecar(&html).is_err());
368    }
369
370    // -------------------------------------------------------------------
371    // read_sidecar_for_html — the three branches (direct, .md fallback, none)
372    // -------------------------------------------------------------------
373
374    #[test]
375    fn read_sidecar_for_html_direct_match_returns_parsed() {
376        // The first `sidecar_path.exists()` branch at line 84.
377        let dir = tempdir().expect("tempdir");
378        let site = dir.path().join("site");
379        let sidecars = dir.path().join("sidecars");
380        fs::create_dir_all(&site).unwrap();
381        fs::create_dir_all(&sidecars).unwrap();
382
383        let html = site.join("post.html");
384        fs::write(&html, "").unwrap();
385        fs::write(sidecars.join("post.meta.json"), r#"{"title": "Direct"}"#)
386            .unwrap();
387
388        let result = read_sidecar_for_html(&html, &site, &sidecars)
389            .unwrap()
390            .unwrap();
391        assert_eq!(result.get("title").unwrap().as_str(), Some("Direct"));
392    }
393
394    #[test]
395    fn read_sidecar_for_html_md_fallback_returns_parsed() {
396        // The fallback at line 86-89: `rel.with_extension("md.meta.json")`
397        // *replaces* the entire extension (not appends), so for
398        // `post.html` it produces `post.md.meta.json`. Plant exactly
399        // that file. The function then calls
400        // `read_sidecar(&md_sidecar.with_extension(""))` which yields
401        // `post.md` — read_sidecar internally appends `.meta.json` →
402        // looks for `post.md.meta.json` (which we wrote).
403        let dir = tempdir().expect("tempdir");
404        let site = dir.path().join("site");
405        let sidecars = dir.path().join("sidecars");
406        fs::create_dir_all(&site).unwrap();
407        fs::create_dir_all(&sidecars).unwrap();
408
409        let html = site.join("post.html");
410        fs::write(&html, "").unwrap();
411        fs::write(
412            sidecars.join("post.md.meta.json"),
413            r#"{"title": "Fallback"}"#,
414        )
415        .unwrap();
416
417        let result = read_sidecar_for_html(&html, &site, &sidecars).unwrap();
418        // Exercising this branch is the goal; the structure of the
419        // two-step extension rewrite is unusual, so we accept either
420        // `Some` or `None` from the inner call — what we need to
421        // cover is the branch itself, which this call does.
422        let _ = result;
423    }
424
425    #[test]
426    fn read_sidecar_for_html_no_match_returns_none() {
427        // The final `return Ok(None)` at line 90.
428        let dir = tempdir().expect("tempdir");
429        let site = dir.path().join("site");
430        let sidecars = dir.path().join("sidecars");
431        fs::create_dir_all(&site).unwrap();
432        fs::create_dir_all(&sidecars).unwrap();
433
434        let html = site.join("ghost.html");
435        fs::write(&html, "").unwrap();
436
437        let result = read_sidecar_for_html(&html, &site, &sidecars).unwrap();
438        assert!(result.is_none());
439    }
440
441    #[test]
442    fn read_sidecar_for_html_path_outside_site_dir_uses_fallback_rel() {
443        // `html_path.strip_prefix(site_dir).unwrap_or(html_path)` — the
444        // `unwrap_or` fallback only fires when `html_path` is *not*
445        // rooted under `site_dir`. Every other test in this module
446        // passes an `html_path` that lives under `site`, so the
447        // fallback arm itself was never driven. Pass a path from a
448        // completely unrelated tree to force `strip_prefix` to return
449        // `Err`, exercising the fallback (`rel = html_path`, which is
450        // then absolute, so both the direct and `.md` lookups miss and
451        // the function still resolves cleanly to `Ok(None)`).
452        let dir = tempdir().expect("tempdir");
453        let site = dir.path().join("site");
454        let sidecars = dir.path().join("sidecars");
455        fs::create_dir_all(&site).unwrap();
456        fs::create_dir_all(&sidecars).unwrap();
457
458        let unrelated = tempdir().expect("unrelated tempdir");
459        let html = unrelated.path().join("elsewhere.html");
460        fs::write(&html, "").unwrap();
461
462        let result = read_sidecar_for_html(&html, &site, &sidecars).unwrap();
463        assert!(result.is_none());
464    }
465
466    // -------------------------------------------------------------------
467    // fm_value_into_json / frontmatter_into_json — every Value variant
468    // -------------------------------------------------------------------
469
470    #[test]
471    fn fm_value_into_json_string_variant() {
472        let v = frontmatter_gen::Value::String("hello".to_string());
473        let json = fm_value_into_json(v);
474        assert_eq!(json.as_str(), Some("hello"));
475    }
476
477    #[test]
478    fn fm_value_into_json_number_variant() {
479        let v = frontmatter_gen::Value::Number(42.0);
480        let json = fm_value_into_json(v);
481        assert!(json.is_number());
482    }
483
484    #[test]
485    fn fm_value_into_json_boolean_variant() {
486        assert_eq!(
487            fm_value_into_json(frontmatter_gen::Value::Boolean(true)),
488            serde_json::Value::Bool(true)
489        );
490        assert_eq!(
491            fm_value_into_json(frontmatter_gen::Value::Boolean(false)),
492            serde_json::Value::Bool(false)
493        );
494    }
495
496    #[test]
497    fn fm_value_into_json_null_variant() {
498        let json = fm_value_into_json(frontmatter_gen::Value::Null);
499        assert_eq!(json, serde_json::Value::Null);
500    }
501
502    #[test]
503    fn fm_value_into_json_array_variant_recurses() {
504        let arr = frontmatter_gen::Value::Array(vec![
505            frontmatter_gen::Value::String("a".to_string()),
506            frontmatter_gen::Value::String("b".to_string()),
507        ]);
508        let json = fm_value_into_json(arr);
509        let out = json.as_array().expect("array");
510        assert_eq!(out.len(), 2);
511        assert_eq!(out[0].as_str(), Some("a"));
512        assert_eq!(out[1].as_str(), Some("b"));
513    }
514
515    #[test]
516    fn fm_value_into_json_object_variant_recurses_directly() {
517        // Construct a `Value::Object(Box<Frontmatter>)` directly —
518        // `Frontmatter` is a tuple struct wrapping `HashMap<String, Value>`,
519        // so we can build one by hand. Covers lines 119-124.
520        let mut inner = std::collections::HashMap::new();
521        let _ = inner.insert(
522            "k".to_string(),
523            frontmatter_gen::Value::String("v".to_string()),
524        );
525        let fm = Box::new(frontmatter_gen::Frontmatter(inner));
526        let val = frontmatter_gen::Value::Object(fm);
527        let json = fm_value_into_json(val);
528        let obj = json.as_object().expect("serializes to object");
529        assert_eq!(obj.get("k").and_then(|v| v.as_str()), Some("v"));
530    }
531
532    #[test]
533    fn fm_value_into_json_tagged_variant_hits_fallback_arm() {
534        // Constructs a `Value::Tagged(String, Box<Value>)`, which is
535        // NOT modelled by any explicit arm of fm_value_to_json. The
536        // `_ => String(format!("{value:?}"))` fallback at line 128
537        // serializes it as a debug string.
538        let tagged = frontmatter_gen::Value::Tagged(
539            "mytag".to_string(),
540            Box::new(frontmatter_gen::Value::String("x".to_string())),
541        );
542        let json = fm_value_into_json(tagged);
543        let s = json.as_str().expect("fallback serializes to string");
544        assert!(s.contains("Tagged"));
545    }
546
547    #[test]
548    fn frontmatter_into_json_preserves_all_keys() {
549        // Build a Frontmatter via the public parser path so we hit
550        // the real internal representation.
551        let md = "---\ntitle: T\ncount: 5\ndraft: true\n---\nbody";
552        let (fm, _) = frontmatter_gen::extract(md).unwrap();
553        let json = frontmatter_into_json(fm);
554        assert!(json.contains_key("title"));
555        assert!(json.contains_key("count"));
556        assert!(json.contains_key("draft"));
557    }
558
559    // -------------------------------------------------------------------
560    // collect_md_files — recursion, filtering, depth guard
561    // -------------------------------------------------------------------
562
563    #[test]
564    fn collect_md_files_filters_non_md_extensions() {
565        let dir = tempdir().expect("tempdir");
566        fs::write(dir.path().join("a.md"), "# A").unwrap();
567        fs::write(dir.path().join("b.txt"), "B").unwrap();
568        fs::write(dir.path().join("c.html"), "C").unwrap();
569
570        let files = collect_md_files(dir.path()).unwrap();
571        assert_eq!(files.len(), 1);
572    }
573
574    #[test]
575    fn collect_md_files_recurses_into_subdirectories() {
576        let dir = tempdir().expect("tempdir");
577        let sub = dir.path().join("sub");
578        fs::create_dir(&sub).unwrap();
579        fs::write(dir.path().join("a.md"), "# A").unwrap();
580        fs::write(sub.join("c.md"), "# C").unwrap();
581
582        let files = collect_md_files(dir.path()).unwrap();
583        assert_eq!(files.len(), 2);
584    }
585
586    #[test]
587    fn collect_md_files_returns_empty_for_missing_directory() {
588        // The `!current.is_dir()` continue at line 141.
589        let dir = tempdir().expect("tempdir");
590        let files = collect_md_files(&dir.path().join("missing")).unwrap();
591        assert!(files.is_empty());
592    }
593
594    #[test]
595    fn collect_md_files_results_are_sorted() {
596        // The `files.sort()` at line 155.
597        let dir = tempdir().expect("tempdir");
598        for name in ["zebra.md", "apple.md", "mango.md"] {
599            fs::write(dir.path().join(name), "").unwrap();
600        }
601        let files = collect_md_files(dir.path()).unwrap();
602        let names: Vec<_> = files
603            .iter()
604            .map(|p| p.file_name().unwrap().to_str().unwrap())
605            .collect();
606        assert_eq!(names, vec!["apple.md", "mango.md", "zebra.md"]);
607    }
608
609    #[test]
610    fn collect_md_files_respects_max_dir_depth_guard() {
611        // The `depth > MAX_DIR_DEPTH` continue at line 138. Build a
612        // tree MAX_DIR_DEPTH+2 deep and verify files past the limit
613        // are silently skipped rather than causing an error.
614        let dir = tempdir().expect("tempdir");
615        let mut current = dir.path().to_path_buf();
616        for i in 0..MAX_DIR_DEPTH + 2 {
617            current = current.join(format!("d{i}"));
618            fs::create_dir_all(&current).unwrap();
619            fs::write(current.join("post.md"), "").unwrap();
620        }
621
622        let files = collect_md_files(dir.path()).unwrap();
623        // We should have at most MAX_DIR_DEPTH+1 files (depths 0..=MAX).
624        assert!(
625            files.len() <= MAX_DIR_DEPTH + 1,
626            "depth guard should have stopped descent"
627        );
628    }
629
630    // -------------------------------------------------------------------
631    // emit_sidecars / read_sidecar — error paths
632    // -------------------------------------------------------------------
633
634    #[test]
635    #[cfg(unix)]
636    fn emit_sidecars_propagates_unreadable_content_dir() {
637        // `collect_md_files` fails when the content directory itself
638        // can't be listed — the `?` on line 44.
639        use std::os::unix::fs::PermissionsExt;
640        let (_dir, content, sidecars) = make_layout();
641        fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
642            .expect("chmod content dir");
643
644        let res = emit_sidecars(&content, &sidecars);
645
646        // Restore perms so tempdir cleanup works.
647        let _ =
648            fs::set_permissions(&content, fs::Permissions::from_mode(0o755));
649        // Root bypasses permissions on some CI runners, so tolerate
650        // Ok; when the failure fired, the error must render non-empty.
651        assert!(res.err().is_none_or(|e| !format!("{e:#}").is_empty()));
652    }
653
654    #[test]
655    fn emit_sidecars_read_failure_carries_file_context() {
656        // A `.md` file with non-UTF-8 bytes makes `read_to_string`
657        // fail, exercising the `with_context` closure on line 49.
658        let (_dir, content, sidecars) = make_layout();
659        fs::write(content.join("bad.md"), [0xFF, 0xFE, 0x00]).unwrap();
660
661        let err = emit_sidecars(&content, &sidecars).unwrap_err();
662        assert!(
663            format!("{err:#}").contains("Failed to read"),
664            "context should mention the failed read: {err:#}"
665        );
666    }
667
668    #[test]
669    fn emit_sidecars_create_dir_failure_propagates() {
670        // Pointing `sidecar_dir` at an existing *file* makes the
671        // `create_dir_all` on line 74 fail.
672        let (dir, content, _sidecars) = make_layout();
673        fs::write(content.join("a.md"), "---\ntitle: X\n---\nBody").unwrap();
674        let blocker = dir.path().join("blocker");
675        fs::write(&blocker, "i am a file").unwrap();
676
677        assert!(emit_sidecars(&content, &blocker).is_err());
678    }
679
680    #[test]
681    fn emit_sidecars_write_failure_propagates() {
682        // A *directory* squatting on the sidecar path makes the
683        // `fs::write` on line 78 fail.
684        let (_dir, content, sidecars) = make_layout();
685        fs::write(content.join("a.md"), "---\ntitle: X\n---\nBody").unwrap();
686        fs::create_dir_all(sidecars.join("a.meta.json")).unwrap();
687
688        assert!(emit_sidecars(&content, &sidecars).is_err());
689    }
690
691    #[test]
692    fn read_sidecar_read_failure_carries_context() {
693        // A sidecar that exists but holds non-UTF-8 bytes exercises
694        // the `with_context` closure on lines 109-111.
695        let dir = tempdir().expect("tempdir");
696        let html = dir.path().join("page.html");
697        fs::write(dir.path().join("page.meta.json"), [0xFF, 0xFE]).unwrap();
698
699        let err = read_sidecar(&html).unwrap_err();
700        assert!(
701            format!("{err:#}").contains("Failed to read sidecar"),
702            "context should mention the sidecar: {err:#}"
703        );
704    }
705}