use futures::future::join_all;
use serde::de::DeserializeOwned;
use std::{
fmt, fs, io,
path::{Path, PathBuf},
};
#[derive(Debug, Clone)]
pub struct MarkdownFile<T: DeserializeOwned> {
pub frontmatter: Option<T>,
pub content: String,
}
impl<T: DeserializeOwned + Send + 'static> MarkdownFile<T> {
pub fn parse(path: impl AsRef<Path>) -> Result<Self, ParseError> {
let raw_content = fs::read_to_string(path)?;
match split_frontmatter(&raw_content) {
Some((yaml_str, body)) => {
let frontmatter = serde_yml::from_str(yaml_str).ok();
Ok(Self { frontmatter, content: body.to_string() })
}
None => Ok(Self { frontmatter: None, content: raw_content.trim().to_string() }),
}
}
pub fn list(dir: impl AsRef<Path>) -> Result<Vec<PathBuf>, io::Error> {
let paths: Vec<_> = fs::read_dir(dir)?
.filter_map(|entry| {
let path = entry.ok()?.path();
(path.extension().and_then(|s| s.to_str()) == Some("md")).then_some(path)
})
.collect();
Ok(paths)
}
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ParseError> {
let path = path.as_ref();
if !path.exists() {
return Err(ParseError::Io(io::Error::new(
io::ErrorKind::NotFound,
format!("File not found: {}", path.display()),
)));
}
Self::parse(path)
}
pub async fn from_dir(dir: &PathBuf) -> Result<Vec<(PathBuf, Self)>, io::Error> {
if !dir.exists() {
return Err(io::Error::new(io::ErrorKind::NotFound, format!("Directory not found: {}", dir.display())));
}
if !dir.is_dir() {
return Err(io::Error::new(io::ErrorKind::NotADirectory, format!("Not a directory: {}", dir.display())));
}
let parse_tasks: Vec<_> = Self::list(dir)?
.into_iter()
.map(|path| {
tokio::spawn(async move {
let path_clone = path.clone();
Self::parse(path).map(|f| (path_clone, f))
})
})
.collect();
let results = join_all(parse_tasks).await;
let items = results
.into_iter()
.filter_map(|result| match result {
Ok(Ok(item)) => Some(item),
Ok(Err(e)) => {
tracing::warn!("Failed to parse file: {}", e);
None
}
Err(_) => None,
})
.collect();
Ok(items)
}
pub async fn from_nested_dirs(
parent_dir: impl AsRef<Path>,
filename: &str,
) -> Result<Vec<(PathBuf, Self)>, io::Error> {
let parent_dir = parent_dir.as_ref();
if !parent_dir.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Directory not found: {}", parent_dir.display()),
));
}
if !parent_dir.is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
format!("Not a directory: {}", parent_dir.display()),
));
}
let subdirs = list_subdirs(parent_dir)?;
let filename = filename.to_string();
let parse_tasks: Vec<_> = subdirs
.into_iter()
.map(|dir| {
let filename = filename.clone();
tokio::spawn(async move {
let file_path = dir.join(&filename);
Self::parse(&file_path).map(|f| (dir, f))
})
})
.collect();
let results = join_all(parse_tasks).await;
let items = results
.into_iter()
.filter_map(|result| match result {
Ok(Ok(item)) => Some(item),
Ok(Err(e)) => {
tracing::debug!("Skipping directory: {}", e);
None
}
Err(_) => None,
})
.collect();
Ok(items)
}
}
pub fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
let content = content.trim();
let rest = content.strip_prefix("---")?;
let end_pos = rest.find("\n---")?;
Some((&rest[..end_pos], rest[end_pos + 4..].trim()))
}
fn list_subdirs(dir: impl AsRef<Path>) -> Result<Vec<PathBuf>, io::Error> {
let paths: Vec<_> = fs::read_dir(dir)?
.filter_map(|entry| {
let path = entry.ok()?.path();
path.is_dir().then_some(path)
})
.collect();
Ok(paths)
}
#[derive(Debug)]
pub enum ParseError {
InvalidFilename,
Io(io::Error),
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseError::InvalidFilename => write!(f, "Invalid filename"),
ParseError::Io(e) => write!(f, "IO error: {e}"),
}
}
}
impl std::error::Error for ParseError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ParseError::Io(e) => Some(e),
ParseError::InvalidFilename => None,
}
}
}
impl From<io::Error> for ParseError {
fn from(e: io::Error) -> Self {
ParseError::Io(e)
}
}