use std::{fs, path::Path, time::SystemTime};
use chrono::{DateTime, Utc};
use walkdir::WalkDir;
use crate::error::{FeedError, Result};
use crate::frontmatter::FrontMatter;
fn systemtime_to_utc(st: SystemTime) -> DateTime<Utc> {
DateTime::<Utc>::from(st)
}
#[derive(Debug)]
pub struct Article {
pub fm: FrontMatter,
pub content: String,
pub path: String,
}
fn file_stem_or_err(path: &Path) -> Result<String> {
path.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.ok_or_else(|| FeedError::MissingFileStem(path.to_path_buf()))
}
fn fallback_frontmatter(
path: &Path,
content: &str,
fallback_date: Option<DateTime<Utc>>,
) -> Result<FrontMatter> {
Ok(FrontMatter {
title: file_stem_or_err(path)?,
date: fallback_date,
author: None,
description: Some(content.to_string()),
})
}
pub fn parse_markdown_file(root: &Path, path: &Path) -> Result<Article> {
let text = fs::read_to_string(path).map_err(|source| FeedError::Io {
path: path.to_path_buf(),
source,
})?;
let mut lines = text.lines();
let mut yaml = String::new();
let mut in_yaml = false;
for line in lines.by_ref() {
let trimmed = line.trim();
if trimmed == "---" {
if !in_yaml {
in_yaml = true;
continue;
}
break;
}
if in_yaml {
yaml.push_str(line);
yaml.push('\n');
}
}
let content = lines.collect::<Vec<_>>().join("\n") + "\n";
let fallback_date = path
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.map(systemtime_to_utc);
let fm = if yaml.trim().is_empty() {
fallback_frontmatter(path, &content, fallback_date)?
} else {
match yaml_serde::from_str(&yaml) {
Ok(fm) => fm,
Err(_) => fallback_frontmatter(path, &content, fallback_date)?,
}
};
let rel_path = path.strip_prefix(root).unwrap_or(path);
Ok(Article {
fm,
content,
path: rel_path.to_string_lossy().into_owned(),
})
}
pub fn collect_articles(src_dir: &Path) -> Result<Vec<Article>> {
let mut articles = Vec::new();
for entry in WalkDir::new(src_dir) {
let entry = entry.map_err(|source| FeedError::WalkDir {
path: src_dir.to_path_buf(),
source,
})?;
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
if !matches!(ext.as_deref(), Some("md" | "markdown")) {
continue;
}
let is_summary = path
.file_name()
.is_some_and(|n| n.to_string_lossy().eq_ignore_ascii_case("SUMMARY.md"));
if is_summary {
continue;
}
if let Ok(article) = parse_markdown_file(src_dir, path) {
articles.push(article);
}
}
articles.sort_by_key(|a| a.fm.date);
articles.reverse();
Ok(articles)
}