use anyhow::{Context, Result};
use serde::de::DeserializeOwned;
use std::path::{Path, PathBuf};
use std::{fs, io};
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct Entry<T> {
pub data: T,
pub body: String,
pub slug: String,
pub path: PathBuf,
}
pub fn get_collection<T: DeserializeOwned>(
dir: impl AsRef<Path>,
) -> Result<Vec<Entry<T>>> {
let dir = dir.as_ref();
let mut files = Vec::new();
walk_markdown(dir, &mut files)?;
files.sort();
let mut out = Vec::with_capacity(files.len());
for path in files {
let entry = load_entry::<T>(&path)?;
if let Some(e) = entry {
out.push(e);
}
}
out.sort_by(|a, b| a.slug.cmp(&b.slug));
Ok(out)
}
pub fn get_entry<T: DeserializeOwned>(
dir: impl AsRef<Path>,
slug: &str,
) -> Result<Option<Entry<T>>> {
let dir = dir.as_ref();
let mut files = Vec::new();
walk_markdown(dir, &mut files)?;
for path in files {
let candidate = derive_slug(&path, dir);
if candidate == slug {
return load_entry::<T>(&path);
}
}
Ok(None)
}
fn walk_markdown(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
if !dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
walk_markdown(&path, out)?;
} else if path.extension().is_some_and(|e| {
e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown")
}) {
out.push(path);
}
}
Ok(())
}
fn load_entry<T: DeserializeOwned>(path: &Path) -> Result<Option<Entry<T>>> {
let raw = fs::read_to_string(path)
.with_context(|| format!("read {}", path.display()))?;
let Ok((fm, body)) = frontmatter_gen::extract(&raw) else {
return Ok(None); };
let json_map = crate::frontmatter::frontmatter_to_json(&fm);
let json_value = serde_json::Value::Object(json_map.into_iter().collect());
let data: T = serde_json::from_value(json_value).with_context(|| {
format!("deserialize frontmatter from {}", path.display())
})?;
let dir_anchor = path.parent().unwrap_or(path);
Ok(Some(Entry {
data,
body: body.to_string(),
slug: derive_slug(path, dir_anchor),
path: path.to_path_buf(),
}))
}
fn derive_slug(path: &Path, _dir: &Path) -> String {
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if stem == "index" {
if let Some(parent) = path.parent().and_then(Path::file_name) {
return parent.to_string_lossy().to_string();
}
}
stem
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
use tempfile::tempdir;
#[derive(Debug, Deserialize, PartialEq, Eq)]
struct Post {
title: String,
date: String,
#[serde(default)]
tags: Vec<String>,
}
fn write_post(dir: &Path, name: &str, body: &str) {
let path = dir.join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, body).unwrap();
}
#[test]
fn derive_slug_uses_file_stem() {
let p = PathBuf::from("posts/hello-world.md");
assert_eq!(derive_slug(&p, Path::new("posts")), "hello-world");
}
#[test]
fn derive_slug_index_uses_parent_dir() {
let p = PathBuf::from("posts/about/index.md");
assert_eq!(derive_slug(&p, Path::new("posts")), "about");
}
#[test]
fn get_collection_loads_typed_entries() {
let dir = tempdir().unwrap();
write_post(
dir.path(),
"first.md",
"---\ntitle: First\ndate: 2026-01-01\ntags: [rust, ssg]\n---\nBody one.\n",
);
write_post(
dir.path(),
"second.md",
"---\ntitle: Second\ndate: 2026-01-02\n---\nBody two.\n",
);
let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
assert_eq!(posts.len(), 2);
assert_eq!(posts[0].slug, "first");
assert_eq!(posts[1].slug, "second");
assert_eq!(posts[0].data.title, "First");
assert!(posts[0].body.starts_with("Body one"));
}
#[test]
fn get_collection_skips_files_without_frontmatter() {
let dir = tempdir().unwrap();
write_post(dir.path(), "naked.md", "# No frontmatter\n");
write_post(
dir.path(),
"ok.md",
"---\ntitle: x\ndate: 2026-01-01\n---\n",
);
let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
assert_eq!(posts.len(), 1);
assert_eq!(posts[0].slug, "ok");
}
#[test]
fn get_collection_recurses_into_subdirectories() {
let dir = tempdir().unwrap();
write_post(
dir.path(),
"a.md",
"---\ntitle: A\ndate: 2026-01-01\n---\n",
);
write_post(
dir.path(),
"nested/b.md",
"---\ntitle: B\ndate: 2026-01-02\n---\n",
);
let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
assert_eq!(posts.len(), 2);
}
#[test]
fn get_collection_returns_error_with_path_context_on_bad_yaml() {
let dir = tempdir().unwrap();
write_post(
dir.path(),
"broken.md",
"---\ntitle: 12\ndate: 2026-01-01\n---\n",
);
write_post(
dir.path(),
"bad.md",
"---\ntitle:\n - a list\ndate: 2026-01-01\n---\n",
);
let err = get_collection::<Post>(dir.path()).unwrap_err();
let chain: String = err
.chain()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(
chain.contains("bad.md") || chain.contains("broken.md"),
"expected file path in error chain, got: {chain}"
);
}
#[test]
fn get_entry_finds_by_slug() {
let dir = tempdir().unwrap();
write_post(
dir.path(),
"hello.md",
"---\ntitle: H\ndate: 2026-01-01\n---\nbody\n",
);
let post: Option<Entry<Post>> = get_entry(dir.path(), "hello").unwrap();
assert!(post.is_some());
assert_eq!(post.unwrap().data.title, "H");
}
#[test]
fn get_entry_returns_none_for_unknown_slug() {
let dir = tempdir().unwrap();
write_post(
dir.path(),
"exists.md",
"---\ntitle: E\ndate: 2026-01-01\n---\n",
);
let post: Option<Entry<Post>> =
get_entry(dir.path(), "missing").unwrap();
assert!(post.is_none());
}
#[test]
fn get_collection_empty_dir_returns_empty_vec() {
let dir = tempdir().unwrap();
let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
assert!(posts.is_empty());
}
#[test]
fn get_collection_missing_dir_returns_empty_vec() {
let posts: Vec<Entry<Post>> =
get_collection("/nonexistent/path/here").unwrap();
assert!(posts.is_empty());
}
}