1use anyhow::{Context, Result};
11use std::{collections::BTreeMap, fs, path::Path};
12
13use crate::MAX_DIR_DEPTH;
14
15pub fn emit_sidecars(content_dir: &Path, sidecar_dir: &Path) -> Result<usize> {
40 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(()), };
74
75 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
94pub 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
126pub 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 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
160pub(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
184fn 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#[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 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 #[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 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 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 #[test]
337 fn read_sidecar_missing_file_returns_none() {
338 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 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 #[test]
375 fn read_sidecar_for_html_direct_match_returns_parsed() {
376 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 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 let _ = result;
423 }
424
425 #[test]
426 fn read_sidecar_for_html_no_match_returns_none() {
427 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 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 #[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 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 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 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 #[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 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 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 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(¤t).unwrap();
619 fs::write(current.join("post.md"), "").unwrap();
620 }
621
622 let files = collect_md_files(dir.path()).unwrap();
623 assert!(
625 files.len() <= MAX_DIR_DEPTH + 1,
626 "depth guard should have stopped descent"
627 );
628 }
629
630 #[test]
635 #[cfg(unix)]
636 fn emit_sidecars_propagates_unreadable_content_dir() {
637 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 let _ =
648 fs::set_permissions(&content, fs::Permissions::from_mode(0o755));
649 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 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 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 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 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}