1use anyhow::{Context, Result};
69use serde::de::DeserializeOwned;
70use std::fs;
71use std::path::{Path, PathBuf};
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>) -> Result<()> {
199 out.extend(crate::walk::collect_files_matching(
200 dir,
201 crate::MAX_DIR_DEPTH,
202 |p| {
203 p.extension().is_some_and(|e| {
204 e.eq_ignore_ascii_case("md")
205 || e.eq_ignore_ascii_case("markdown")
206 })
207 },
208 )?);
209 Ok(())
210}
211
212fn load_entry<T: DeserializeOwned>(path: &Path) -> Result<Option<Entry<T>>> {
213 let raw = fs::read_to_string(path)
214 .with_context(|| format!("read {}", path.display()))?;
215 let Ok((fm, body)) = frontmatter_gen::extract(&raw) else {
216 return Ok(None); };
218 let json_map = crate::frontmatter::frontmatter_into_json(fm);
219 let json_value = serde_json::Value::Object(json_map.into_iter().collect());
220 let data: T = serde_json::from_value(json_value).with_context(|| {
221 format!("deserialize frontmatter from {}", path.display())
222 })?;
223 let dir_anchor = path.parent().unwrap_or(path);
224 Ok(Some(Entry {
225 data,
226 body: body.to_string(),
227 slug: derive_slug(path, dir_anchor),
228 path: path.to_path_buf(),
229 }))
230}
231
232fn derive_slug(path: &Path, _dir: &Path) -> String {
238 let stem = path
239 .file_stem()
240 .map(|s| s.to_string_lossy().to_string())
241 .unwrap_or_default();
242 if stem == "index" {
243 if let Some(parent) = path.parent().and_then(Path::file_name) {
244 return parent.to_string_lossy().to_string();
245 }
246 }
247 stem
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use serde::Deserialize;
254 use tempfile::tempdir;
255
256 #[derive(Debug, Deserialize, PartialEq, Eq)]
257 struct Post {
258 title: String,
259 date: String,
260 #[serde(default)]
261 tags: Vec<String>,
262 }
263
264 fn write_post(dir: &Path, name: &str, body: &str) {
265 let path = dir.join(name);
266 if let Some(parent) = path.parent() {
267 fs::create_dir_all(parent).unwrap();
268 }
269 fs::write(path, body).unwrap();
270 }
271
272 #[test]
273 fn derive_slug_uses_file_stem() {
274 let p = PathBuf::from("posts/hello-world.md");
275 assert_eq!(derive_slug(&p, Path::new("posts")), "hello-world");
276 }
277
278 #[test]
279 fn derive_slug_index_uses_parent_dir() {
280 let p = PathBuf::from("posts/about/index.md");
281 assert_eq!(derive_slug(&p, Path::new("posts")), "about");
282 }
283
284 #[test]
285 fn get_collection_loads_typed_entries() {
286 let dir = tempdir().unwrap();
287 write_post(
291 dir.path(),
292 "first.md",
293 "---\ntitle: First\ndate: 2026-01-01\ntags: [rust, ssg]\n---\nBody one.\n",
294 );
295 write_post(
296 dir.path(),
297 "second.md",
298 "---\ntitle: Second\ndate: 2026-01-02\n---\nBody two.\n",
299 );
300
301 let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
302 assert_eq!(posts.len(), 2);
303 assert_eq!(posts[0].slug, "first");
305 assert_eq!(posts[1].slug, "second");
306 assert_eq!(posts[0].data.title, "First");
307 assert!(posts[0].body.starts_with("Body one"));
308 }
309
310 #[test]
311 fn get_collection_skips_files_without_frontmatter() {
312 let dir = tempdir().unwrap();
313 write_post(dir.path(), "naked.md", "# No frontmatter\n");
314 write_post(
315 dir.path(),
316 "ok.md",
317 "---\ntitle: x\ndate: 2026-01-01\n---\n",
318 );
319 let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
320 assert_eq!(posts.len(), 1);
321 assert_eq!(posts[0].slug, "ok");
322 }
323
324 #[test]
325 fn get_collection_recurses_into_subdirectories() {
326 let dir = tempdir().unwrap();
327 write_post(
328 dir.path(),
329 "a.md",
330 "---\ntitle: A\ndate: 2026-01-01\n---\n",
331 );
332 write_post(
333 dir.path(),
334 "nested/b.md",
335 "---\ntitle: B\ndate: 2026-01-02\n---\n",
336 );
337 let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
338 assert_eq!(posts.len(), 2);
339 }
340
341 #[test]
342 fn get_collection_returns_error_with_path_context_on_bad_yaml() {
343 let dir = tempdir().unwrap();
344 write_post(
345 dir.path(),
346 "broken.md",
347 "---\ntitle: 12\ndate: 2026-01-01\n---\n",
348 );
349 write_post(
353 dir.path(),
354 "bad.md",
355 "---\ntitle:\n - a list\ndate: 2026-01-01\n---\n",
356 );
357 let err = get_collection::<Post>(dir.path()).unwrap_err();
358 let chain: String = err
359 .chain()
360 .map(|c| c.to_string())
361 .collect::<Vec<_>>()
362 .join("\n");
363 assert!(
364 chain.contains("bad.md") || chain.contains("broken.md"),
365 "expected file path in error chain, got: {chain}"
366 );
367 }
368
369 #[test]
370 fn get_entry_finds_by_slug() {
371 let dir = tempdir().unwrap();
372 write_post(
373 dir.path(),
374 "hello.md",
375 "---\ntitle: H\ndate: 2026-01-01\n---\nbody\n",
376 );
377 let post: Option<Entry<Post>> = get_entry(dir.path(), "hello").unwrap();
378 assert!(post.is_some());
379 assert_eq!(post.unwrap().data.title, "H");
380 }
381
382 #[test]
383 fn get_entry_returns_none_for_unknown_slug() {
384 let dir = tempdir().unwrap();
385 write_post(
386 dir.path(),
387 "exists.md",
388 "---\ntitle: E\ndate: 2026-01-01\n---\n",
389 );
390 let post: Option<Entry<Post>> =
391 get_entry(dir.path(), "missing").unwrap();
392 assert!(post.is_none());
393 }
394
395 #[test]
396 fn get_collection_empty_dir_returns_empty_vec() {
397 let dir = tempdir().unwrap();
398 let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
399 assert!(posts.is_empty());
400 }
401
402 #[test]
403 fn get_collection_missing_dir_returns_empty_vec() {
404 let posts: Vec<Entry<Post>> =
405 get_collection("/nonexistent/path/here").unwrap();
406 assert!(posts.is_empty());
407 }
408
409 #[test]
410 fn derive_slug_root_index_falls_back_to_stem() {
411 let p = PathBuf::from("index.md");
414 assert_eq!(derive_slug(&p, Path::new("")), "index");
415 }
416
417 #[test]
418 fn walk_markdown_accepts_markdown_extension_and_skips_others() {
419 let dir = tempdir().unwrap();
420 write_post(
421 dir.path(),
422 "long.markdown",
423 "---\ntitle: L\ndate: 2026-01-01\n---\nBody\n",
424 );
425 write_post(dir.path(), "notes.txt", "not collected");
426
427 let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
428 assert_eq!(posts.len(), 1);
429 assert_eq!(posts[0].slug, "long");
430 }
431
432 #[cfg(unix)]
434 fn walk_relative(root: &Path) -> Vec<PathBuf> {
435 let mut files = Vec::new();
436 walk_markdown(root, &mut files).unwrap();
437 files
438 .iter()
439 .map(|p| p.strip_prefix(root).unwrap().to_path_buf())
440 .collect()
441 }
442
443 #[cfg(unix)]
444 #[test]
445 fn walk_markdown_does_not_follow_a_symlink_loop() {
446 use std::os::unix::fs::symlink;
447
448 let dir = tempdir().unwrap();
449 let root = dir.path();
450 fs::create_dir_all(root.join("sub/deep")).unwrap();
451 for rel in ["zeta.md", "alpha.md", "sub/deep/beta.markdown"] {
452 fs::write(root.join(rel), "x").unwrap();
453 }
454 symlink(root, root.join("sub/loop")).unwrap();
456
457 assert_eq!(
458 walk_relative(root),
459 ["alpha.md", "sub/deep/beta.markdown", "zeta.md"]
460 .map(PathBuf::from),
461 "each file once, sorted, nothing reached through the loop"
462 );
463 }
464
465 #[cfg(unix)]
466 #[test]
467 fn walk_markdown_terminates_on_branching_symlink_loops() {
468 use std::os::unix::fs::symlink;
469 use std::sync::mpsc;
470 use std::time::Duration;
471
472 let dir = tempdir().unwrap();
475 let root = dir.path().to_path_buf();
476 fs::write(root.join("a.md"), "x").unwrap();
477 symlink(&root, root.join("l1")).unwrap();
478 symlink(&root, root.join("l2")).unwrap();
479
480 let (tx, rx) = mpsc::channel();
481 let walk_root = root.clone();
482 let _ = std::thread::spawn(move || {
483 let _ = tx.send(walk_relative(&walk_root));
484 });
485 let found = rx
486 .recv_timeout(Duration::from_secs(10))
487 .expect("walk did not finish within 10 s");
488 assert_eq!(found, [PathBuf::from("a.md")]);
489 }
490
491 #[test]
492 fn load_entry_read_failure_carries_path_context() {
493 let err = load_entry::<Post>(Path::new(
494 "/nonexistent-ssg-collections/missing.md",
495 ))
496 .unwrap_err();
497 let msg = format!("{err:?}");
498 assert!(
499 msg.contains("read /nonexistent-ssg-collections"),
500 "got: {msg}"
501 );
502 }
503
504 #[cfg(unix)]
505 #[test]
506 fn get_collection_propagates_unreadable_root_dir_error() {
507 use std::os::unix::fs::PermissionsExt;
508
509 let dir = tempdir().unwrap();
510 let content = dir.path().join("locked");
511 fs::create_dir_all(&content).unwrap();
512 fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
513 .unwrap();
514
515 let result = get_collection::<Post>(&content);
516
517 fs::set_permissions(&content, fs::Permissions::from_mode(0o755))
518 .unwrap();
519 assert!(result.is_err(), "unreadable dir must error");
520 }
521
522 #[cfg(unix)]
523 #[test]
524 fn get_collection_propagates_nested_unreadable_dir_error() {
525 use std::os::unix::fs::PermissionsExt;
526
527 let dir = tempdir().unwrap();
529 let nested = dir.path().join("sub");
530 fs::create_dir_all(&nested).unwrap();
531 fs::set_permissions(&nested, fs::Permissions::from_mode(0o000))
532 .unwrap();
533
534 let result = get_collection::<Post>(dir.path());
535
536 fs::set_permissions(&nested, fs::Permissions::from_mode(0o755))
537 .unwrap();
538 assert!(result.is_err(), "nested unreadable dir must error");
539 }
540
541 #[cfg(unix)]
542 #[test]
543 fn get_entry_propagates_unreadable_root_dir_error() {
544 use std::os::unix::fs::PermissionsExt;
549
550 let dir = tempdir().unwrap();
551 let content = dir.path().join("locked");
552 fs::create_dir_all(&content).unwrap();
553 fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
554 .unwrap();
555
556 let result = get_entry::<Post>(&content, "whatever");
557
558 fs::set_permissions(&content, fs::Permissions::from_mode(0o755))
559 .unwrap();
560 assert!(result.is_err(), "unreadable dir must error");
561 }
562
563 #[test]
564 fn get_entry_propagates_frontmatter_type_error() {
565 let dir = tempdir().unwrap();
566 write_post(
567 dir.path(),
568 "bad.md",
569 "---\ntitle:\n - a list\ndate: 2026-01-01\n---\n",
570 );
571 let result = get_entry::<Post>(dir.path(), "bad");
572 assert!(result.is_err(), "type mismatch must propagate");
573 }
574}