1use anyhow::{Context, Result};
69use serde::de::DeserializeOwned;
70use std::path::{Path, PathBuf};
71use std::{fs, io};
72
73#[non_exhaustive]
80#[derive(Debug, Clone)]
81pub struct Entry<T> {
82 pub data: T,
84 pub body: String,
86 pub slug: String,
88 pub path: PathBuf,
90}
91
92pub fn get_collection<T: DeserializeOwned>(
130 dir: impl AsRef<Path>,
131) -> Result<Vec<Entry<T>>> {
132 let dir = dir.as_ref();
133 let mut files = Vec::new();
134 walk_markdown(dir, &mut files)?;
135 files.sort();
136
137 let mut out = Vec::with_capacity(files.len());
138 for path in files {
139 let entry = load_entry::<T>(&path)?;
140 if let Some(e) = entry {
141 out.push(e);
142 }
143 }
144
145 out.sort_by(|a, b| a.slug.cmp(&b.slug));
146 Ok(out)
147}
148
149pub fn get_entry<T: DeserializeOwned>(
177 dir: impl AsRef<Path>,
178 slug: &str,
179) -> Result<Option<Entry<T>>> {
180 let dir = dir.as_ref();
181 let mut files = Vec::new();
182 walk_markdown(dir, &mut files)?;
183
184 for path in files {
185 let candidate = derive_slug(&path, dir);
186 if candidate == slug {
187 return load_entry::<T>(&path);
188 }
189 }
190 Ok(None)
191}
192
193fn walk_markdown(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
194 if !dir.is_dir() {
195 return Ok(());
196 }
197 for entry in fs::read_dir(dir)? {
198 let entry = entry?;
199 let path = entry.path();
200 if path.is_dir() {
201 walk_markdown(&path, out)?;
202 } else if path.extension().is_some_and(|e| {
203 e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown")
204 }) {
205 out.push(path);
206 }
207 }
208 Ok(())
209}
210
211fn load_entry<T: DeserializeOwned>(path: &Path) -> Result<Option<Entry<T>>> {
212 let raw = fs::read_to_string(path)
213 .with_context(|| format!("read {}", path.display()))?;
214 let Ok((fm, body)) = frontmatter_gen::extract(&raw) else {
215 return Ok(None); };
217 let json_map = crate::frontmatter::frontmatter_into_json(fm);
218 let json_value = serde_json::Value::Object(json_map.into_iter().collect());
219 let data: T = serde_json::from_value(json_value).with_context(|| {
220 format!("deserialize frontmatter from {}", path.display())
221 })?;
222 let dir_anchor = path.parent().unwrap_or(path);
223 Ok(Some(Entry {
224 data,
225 body: body.to_string(),
226 slug: derive_slug(path, dir_anchor),
227 path: path.to_path_buf(),
228 }))
229}
230
231fn derive_slug(path: &Path, _dir: &Path) -> String {
237 let stem = path
238 .file_stem()
239 .map(|s| s.to_string_lossy().to_string())
240 .unwrap_or_default();
241 if stem == "index" {
242 if let Some(parent) = path.parent().and_then(Path::file_name) {
243 return parent.to_string_lossy().to_string();
244 }
245 }
246 stem
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use serde::Deserialize;
253 use tempfile::tempdir;
254
255 #[derive(Debug, Deserialize, PartialEq, Eq)]
256 struct Post {
257 title: String,
258 date: String,
259 #[serde(default)]
260 tags: Vec<String>,
261 }
262
263 fn write_post(dir: &Path, name: &str, body: &str) {
264 let path = dir.join(name);
265 if let Some(parent) = path.parent() {
266 fs::create_dir_all(parent).unwrap();
267 }
268 fs::write(path, body).unwrap();
269 }
270
271 #[test]
272 fn derive_slug_uses_file_stem() {
273 let p = PathBuf::from("posts/hello-world.md");
274 assert_eq!(derive_slug(&p, Path::new("posts")), "hello-world");
275 }
276
277 #[test]
278 fn derive_slug_index_uses_parent_dir() {
279 let p = PathBuf::from("posts/about/index.md");
280 assert_eq!(derive_slug(&p, Path::new("posts")), "about");
281 }
282
283 #[test]
284 fn get_collection_loads_typed_entries() {
285 let dir = tempdir().unwrap();
286 write_post(
290 dir.path(),
291 "first.md",
292 "---\ntitle: First\ndate: 2026-01-01\ntags: [rust, ssg]\n---\nBody one.\n",
293 );
294 write_post(
295 dir.path(),
296 "second.md",
297 "---\ntitle: Second\ndate: 2026-01-02\n---\nBody two.\n",
298 );
299
300 let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
301 assert_eq!(posts.len(), 2);
302 assert_eq!(posts[0].slug, "first");
304 assert_eq!(posts[1].slug, "second");
305 assert_eq!(posts[0].data.title, "First");
306 assert!(posts[0].body.starts_with("Body one"));
307 }
308
309 #[test]
310 fn get_collection_skips_files_without_frontmatter() {
311 let dir = tempdir().unwrap();
312 write_post(dir.path(), "naked.md", "# No frontmatter\n");
313 write_post(
314 dir.path(),
315 "ok.md",
316 "---\ntitle: x\ndate: 2026-01-01\n---\n",
317 );
318 let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
319 assert_eq!(posts.len(), 1);
320 assert_eq!(posts[0].slug, "ok");
321 }
322
323 #[test]
324 fn get_collection_recurses_into_subdirectories() {
325 let dir = tempdir().unwrap();
326 write_post(
327 dir.path(),
328 "a.md",
329 "---\ntitle: A\ndate: 2026-01-01\n---\n",
330 );
331 write_post(
332 dir.path(),
333 "nested/b.md",
334 "---\ntitle: B\ndate: 2026-01-02\n---\n",
335 );
336 let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
337 assert_eq!(posts.len(), 2);
338 }
339
340 #[test]
341 fn get_collection_returns_error_with_path_context_on_bad_yaml() {
342 let dir = tempdir().unwrap();
343 write_post(
344 dir.path(),
345 "broken.md",
346 "---\ntitle: 12\ndate: 2026-01-01\n---\n",
347 );
348 write_post(
352 dir.path(),
353 "bad.md",
354 "---\ntitle:\n - a list\ndate: 2026-01-01\n---\n",
355 );
356 let err = get_collection::<Post>(dir.path()).unwrap_err();
357 let chain: String = err
358 .chain()
359 .map(|c| c.to_string())
360 .collect::<Vec<_>>()
361 .join("\n");
362 assert!(
363 chain.contains("bad.md") || chain.contains("broken.md"),
364 "expected file path in error chain, got: {chain}"
365 );
366 }
367
368 #[test]
369 fn get_entry_finds_by_slug() {
370 let dir = tempdir().unwrap();
371 write_post(
372 dir.path(),
373 "hello.md",
374 "---\ntitle: H\ndate: 2026-01-01\n---\nbody\n",
375 );
376 let post: Option<Entry<Post>> = get_entry(dir.path(), "hello").unwrap();
377 assert!(post.is_some());
378 assert_eq!(post.unwrap().data.title, "H");
379 }
380
381 #[test]
382 fn get_entry_returns_none_for_unknown_slug() {
383 let dir = tempdir().unwrap();
384 write_post(
385 dir.path(),
386 "exists.md",
387 "---\ntitle: E\ndate: 2026-01-01\n---\n",
388 );
389 let post: Option<Entry<Post>> =
390 get_entry(dir.path(), "missing").unwrap();
391 assert!(post.is_none());
392 }
393
394 #[test]
395 fn get_collection_empty_dir_returns_empty_vec() {
396 let dir = tempdir().unwrap();
397 let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
398 assert!(posts.is_empty());
399 }
400
401 #[test]
402 fn get_collection_missing_dir_returns_empty_vec() {
403 let posts: Vec<Entry<Post>> =
404 get_collection("/nonexistent/path/here").unwrap();
405 assert!(posts.is_empty());
406 }
407
408 #[test]
409 fn derive_slug_root_index_falls_back_to_stem() {
410 let p = PathBuf::from("index.md");
413 assert_eq!(derive_slug(&p, Path::new("")), "index");
414 }
415
416 #[test]
417 fn walk_markdown_accepts_markdown_extension_and_skips_others() {
418 let dir = tempdir().unwrap();
419 write_post(
420 dir.path(),
421 "long.markdown",
422 "---\ntitle: L\ndate: 2026-01-01\n---\nBody\n",
423 );
424 write_post(dir.path(), "notes.txt", "not collected");
425
426 let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
427 assert_eq!(posts.len(), 1);
428 assert_eq!(posts[0].slug, "long");
429 }
430
431 #[test]
432 fn load_entry_read_failure_carries_path_context() {
433 let err = load_entry::<Post>(Path::new(
434 "/nonexistent-ssg-collections/missing.md",
435 ))
436 .unwrap_err();
437 let msg = format!("{err:?}");
438 assert!(
439 msg.contains("read /nonexistent-ssg-collections"),
440 "got: {msg}"
441 );
442 }
443
444 #[cfg(unix)]
445 #[test]
446 fn get_collection_propagates_unreadable_root_dir_error() {
447 use std::os::unix::fs::PermissionsExt;
448
449 let dir = tempdir().unwrap();
450 let content = dir.path().join("locked");
451 fs::create_dir_all(&content).unwrap();
452 fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
453 .unwrap();
454
455 let result = get_collection::<Post>(&content);
456
457 fs::set_permissions(&content, fs::Permissions::from_mode(0o755))
458 .unwrap();
459 assert!(result.is_err(), "unreadable dir must error");
460 }
461
462 #[cfg(unix)]
463 #[test]
464 fn get_collection_propagates_nested_unreadable_dir_error() {
465 use std::os::unix::fs::PermissionsExt;
466
467 let dir = tempdir().unwrap();
469 let nested = dir.path().join("sub");
470 fs::create_dir_all(&nested).unwrap();
471 fs::set_permissions(&nested, fs::Permissions::from_mode(0o000))
472 .unwrap();
473
474 let result = get_collection::<Post>(dir.path());
475
476 fs::set_permissions(&nested, fs::Permissions::from_mode(0o755))
477 .unwrap();
478 assert!(result.is_err(), "nested unreadable dir must error");
479 }
480
481 #[cfg(unix)]
482 #[test]
483 fn get_entry_propagates_unreadable_root_dir_error() {
484 use std::os::unix::fs::PermissionsExt;
489
490 let dir = tempdir().unwrap();
491 let content = dir.path().join("locked");
492 fs::create_dir_all(&content).unwrap();
493 fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
494 .unwrap();
495
496 let result = get_entry::<Post>(&content, "whatever");
497
498 fs::set_permissions(&content, fs::Permissions::from_mode(0o755))
499 .unwrap();
500 assert!(result.is_err(), "unreadable dir must error");
501 }
502
503 #[test]
504 fn get_entry_propagates_frontmatter_type_error() {
505 let dir = tempdir().unwrap();
506 write_post(
507 dir.path(),
508 "bad.md",
509 "---\ntitle:\n - a list\ndate: 2026-01-01\n---\n",
510 );
511 let result = get_entry::<Post>(dir.path(), "bad");
512 assert!(result.is_err(), "type mismatch must propagate");
513 }
514}