use anyhow::Result;
use clap::ArgMatches;
use std::{fs, path::Path};
#[derive(Debug)]
pub enum ProcessError {
DirectoryCreation {
dir_type: String,
path: String,
source: std::io::Error,
},
MissingArgument(String),
CompilationError(String),
IoError(std::io::Error),
FrontmatterError(String),
}
impl std::fmt::Display for ProcessError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DirectoryCreation {
dir_type,
path,
source,
} => write!(
f,
"Failed to create {dir_type} directory at '{path}': {source}"
),
Self::MissingArgument(arg) => {
write!(f, "Required argument missing: {arg}")
}
Self::CompilationError(msg) => {
write!(f, "Compilation error: {msg}")
}
Self::IoError(e) => write!(f, "{e}"),
Self::FrontmatterError(msg) => {
write!(f, "Frontmatter processing error: {msg}")
}
}
}
}
impl std::error::Error for ProcessError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::DirectoryCreation { source, .. } => Some(source),
Self::IoError(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for ProcessError {
fn from(e: std::io::Error) -> Self {
Self::IoError(e)
}
}
pub fn get_argument(
matches: &ArgMatches,
name: &str,
) -> Result<String, ProcessError> {
matches
.get_one::<String>(name)
.ok_or_else(|| ProcessError::MissingArgument(name.to_string()))
.map(String::from)
}
pub fn ensure_directory(
path: &Path,
dir_type: &str,
) -> Result<(), ProcessError> {
if path.exists() {
if !path.is_dir() {
return Err(ProcessError::DirectoryCreation {
dir_type: dir_type.to_string(),
path: path.display().to_string(),
source: std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"Path exists but is not a directory",
),
});
}
} else {
fs::create_dir_all(path).map_err(|e| {
ProcessError::DirectoryCreation {
dir_type: dir_type.to_string(),
path: path.display().to_string(),
source: e,
}
})?;
}
Ok(())
}
fn internal_compile(
build_path: &Path,
content_path: &Path,
site_path: &Path,
template_path: &Path,
) -> Result<(), String> {
staticdatagen::compiler::service::compile(
build_path,
content_path,
site_path,
template_path,
)
.map_err(|e| e.to_string())
}
fn preprocess_content(content_path: &Path) -> Result<(), ProcessError> {
if !content_path.exists() {
return Ok(());
}
for entry in fs::read_dir(content_path)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
let content = fs::read_to_string(&path)?;
let processed_content = process_frontmatter(&content)?;
fs::write(&path, processed_content)?;
}
}
Ok(())
}
fn process_frontmatter(content: &str) -> Result<String, ProcessError> {
const DELIMITER: &str = "---";
let parts: Vec<&str> = content.splitn(3, DELIMITER).collect();
match parts.len() {
3 => {
let frontmatter = parts[1].trim();
let main_content = parts[2].trim();
Ok(format!(
"---\n{frontmatter}\n---\n<!--frontmatter-processed-->\n{main_content}"
))
}
_ => Ok(content.to_string()), }
}
pub fn args(matches: &ArgMatches) -> Result<(), ProcessError> {
let content_dir = get_argument(matches, "content")?;
let output_dir = get_argument(matches, "output")?;
let site_dir = get_argument(matches, "new")?;
let template_dir = get_argument(matches, "template")?;
let content_path = Path::new(&content_dir);
let build_path = Path::new(&output_dir);
let site_path = Path::new(&site_dir);
let template_path = Path::new(&template_dir);
ensure_directory(content_path, "content")?;
ensure_directory(build_path, "output")?;
ensure_directory(site_path, "project")?;
ensure_directory(template_path, "template")?;
preprocess_content(content_path)?;
internal_compile(build_path, content_path, site_path, template_path)
.map_err(ProcessError::CompilationError)?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use clap::{arg, Command};
use std::fs::{self, File};
use tempfile::tempdir;
fn create_test_command() -> ArgMatches {
Command::new("test")
.arg(arg!(--"content" <CONTENT> "Content directory"))
.arg(arg!(--"output" <OUTPUT> "Output directory"))
.arg(arg!(--"new" <NEW> "New site directory"))
.arg(arg!(--"template" <TEMPLATE> "Template directory"))
.get_matches_from(vec![
"test",
"--content",
"content",
"--output",
"output",
"--new",
"new_site",
"--template",
"template",
])
}
#[test]
fn test_get_argument_present() {
let matches = create_test_command();
let content = get_argument(&matches, "content").unwrap();
assert_eq!(content, "content");
}
#[test]
fn test_get_argument_missing() {
let matches = Command::new("test")
.arg(arg!(--"config" <CONFIG> "Config file"))
.get_matches_from(vec!["test"]);
let result = get_argument(&matches, "config");
assert!(matches!(result, Err(ProcessError::MissingArgument(_))));
}
#[test]
fn test_ensure_directory_exists() {
let temp_dir = tempdir().unwrap();
let result = ensure_directory(temp_dir.path(), "temp");
assert!(result.is_ok());
}
#[test]
fn test_args_missing_template_argument() {
let matches = Command::new("test")
.arg(arg!(--"content" <CONTENT> "Content directory"))
.arg(arg!(--"output" <OUTPUT> "Output directory"))
.arg(arg!(--"new" <NEW> "New site directory"))
.arg(arg!(--"template" <TEMPLATE> "Template directory"))
.get_matches_from(vec![
"test",
"--content",
"content",
"--output",
"output",
"--new",
"new_site",
]);
let result = args(&matches);
assert!(matches!(
result,
Err(ProcessError::MissingArgument(ref arg)) if arg == "template"
));
}
#[test]
fn test_ensure_directory_already_exists() -> Result<()> {
let temp_dir = tempdir()?;
ensure_directory(temp_dir.path(), "existing")?;
assert!(temp_dir.path().exists());
Ok(())
}
#[cfg(not(target_os = "windows"))] #[test]
fn test_process_error_display() {
let error = ProcessError::MissingArgument("content".to_string());
assert_eq!(error.to_string(), "Required argument missing: content");
let error = ProcessError::DirectoryCreation {
dir_type: "content".to_string(),
path: "/invalid/path".to_string(),
source: std::io::Error::from_raw_os_error(13),
};
assert_eq!(
error.to_string(),
"Failed to create content directory at '/invalid/path': Permission denied (os error 13)"
);
let error =
ProcessError::CompilationError("Failed to compile".to_string());
assert_eq!(error.to_string(), "Compilation error: Failed to compile");
}
#[test]
fn test_process_error_io_error() {
let io_error = std::io::Error::other("an I/O error occurred");
let error: ProcessError = io_error.into();
assert!(matches!(error, ProcessError::IoError(_)));
assert_eq!(error.to_string(), "an I/O error occurred");
}
#[test]
fn test_process_error_io_error_format() {
let io_error =
std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
let error: ProcessError = io_error.into();
assert!(matches!(error, ProcessError::IoError(_)));
assert_eq!(error.to_string(), "File not found");
}
#[cfg(unix)]
#[test]
fn test_ensure_directory_permission_denied() {
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
let temp_dir = tempdir().unwrap();
let protected_path = temp_dir.path().join("protected_dir");
fs::create_dir(&protected_path).unwrap();
fs::set_permissions(&protected_path, Permissions::from_mode(0o400))
.unwrap();
let sub_dir = protected_path.join("sub_dir");
let result = ensure_directory(&sub_dir, "sub_directory");
assert!(matches!(
result,
Err(ProcessError::DirectoryCreation { .. })
));
fs::set_permissions(&protected_path, Permissions::from_mode(0o700))
.unwrap();
}
#[test]
fn test_args_all_required_arguments(
) -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempdir()?;
let content_dir = temp_dir.path().join("content");
let output_dir = temp_dir.path().join("output");
let site_dir = temp_dir.path().join("new_site");
let template_dir = temp_dir.path().join("template");
let matches = Command::new("test")
.arg(arg!(--"content" <CONTENT> "Content directory"))
.arg(arg!(--"output" <OUTPUT> "Output directory"))
.arg(arg!(--"new" <NEW> "New site directory"))
.arg(arg!(--"template" <TEMPLATE> "Template directory"))
.get_matches_from(vec![
"test",
"--content",
content_dir.to_str().unwrap(),
"--output",
output_dir.to_str().unwrap(),
"--new",
site_dir.to_str().unwrap(),
"--template",
template_dir.to_str().unwrap(),
]);
let result = args(&matches);
assert!(
matches!(result, Err(ProcessError::CompilationError(_))),
"Expected CompilationError from args"
);
Ok(())
}
#[test]
fn test_process_frontmatter_with_valid_frontmatter(
) -> Result<(), ProcessError> {
let content = "\
---
title: Test Post
date: 2024-01-01
---
# Main Content
This is the main content.";
let processed = process_frontmatter(content)?;
assert!(processed.contains("<!--frontmatter-processed-->"));
assert!(processed.contains("title: Test Post"));
assert!(processed.contains("# Main Content"));
Ok(())
}
#[test]
fn test_process_frontmatter_without_frontmatter() -> Result<(), ProcessError>
{
let content = "# Just Content\nNo frontmatter here.";
let processed = process_frontmatter(content)?;
assert_eq!(processed, content);
Ok(())
}
#[test]
fn test_process_frontmatter_with_empty_frontmatter(
) -> Result<(), ProcessError> {
let content = "---\n---\nContent after empty frontmatter";
let processed = process_frontmatter(content)?;
assert!(processed.contains("<!--frontmatter-processed-->"));
Ok(())
}
#[test]
fn test_preprocess_content_with_multiple_files() -> Result<(), ProcessError>
{
let temp_dir = tempdir()?;
let file1_path = temp_dir.path().join("post1.md");
let file2_path = temp_dir.path().join("post2.md");
let non_md_path = temp_dir.path().join("other.txt");
fs::write(&file1_path, "---\ntitle: Post 1\n---\nContent 1")?;
fs::write(&file2_path, "---\ntitle: Post 2\n---\nContent 2")?;
fs::write(&non_md_path, "Not a markdown file")?;
preprocess_content(temp_dir.path())?;
let content1 = fs::read_to_string(&file1_path)?;
let content2 = fs::read_to_string(&file2_path)?;
let other = fs::read_to_string(&non_md_path)?;
assert!(content1.contains("<!--frontmatter-processed-->"));
assert!(content2.contains("<!--frontmatter-processed-->"));
assert_eq!(other, "Not a markdown file");
Ok(())
}
#[test]
fn test_preprocess_content_with_non_existent_directory(
) -> Result<(), ProcessError> {
let non_existent = Path::new("non_existent_directory");
let result = preprocess_content(non_existent);
assert!(result.is_ok());
Ok(())
}
#[cfg(unix)]
#[test]
fn test_preprocess_content_with_invalid_permissions() {
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("readonly.md");
fs::write(&file_path, "---\ntitle: Test\n---\nContent").unwrap();
fs::set_permissions(&file_path, Permissions::from_mode(0o444)).unwrap();
let result = preprocess_content(temp_dir.path());
assert!(result.is_err());
fs::set_permissions(&file_path, Permissions::from_mode(0o666)).unwrap();
}
#[test]
fn test_internal_compile_error_handling() {
let temp_dir = tempdir().unwrap();
let result = internal_compile(
&temp_dir.path().join("build"),
&temp_dir.path().join("content"),
&temp_dir.path().join("site"),
&temp_dir.path().join("template"),
);
assert!(result.is_err());
}
#[test]
fn test_get_argument_with_empty_value() {
let matches = Command::new("test")
.arg(arg!(--"empty" <EMPTY> "Empty value"))
.get_matches_from(vec!["test", "--empty", ""]);
let result = get_argument(&matches, "empty");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "");
}
#[test]
fn test_ensure_directory_with_existing_file(
) -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempdir()?;
let file_path = temp_dir.path().join("existing_file");
let _file = File::create(&file_path)?;
let result = ensure_directory(&file_path, "test");
let err = result.unwrap_err();
match err {
ProcessError::DirectoryCreation { source, .. } => {
assert_eq!(source.kind(), std::io::ErrorKind::AlreadyExists);
}
other => panic!("Expected DirectoryCreation, got: {other}"),
}
Ok(())
}
#[test]
fn test_ensure_directory_with_existing_directory(
) -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempdir()?;
let dir_path = temp_dir.path().join("existing_dir");
fs::create_dir(&dir_path)?;
let result = ensure_directory(&dir_path, "test");
assert!(result.is_ok());
Ok(())
}
#[test]
fn test_preprocess_content_with_invalid_utf8() -> Result<()> {
let temp_dir = tempdir()?;
let file_path = temp_dir.path().join("invalid.md");
let invalid_bytes = vec![0xFF, 0xFF];
fs::write(&file_path, invalid_bytes)?;
let result = preprocess_content(temp_dir.path());
assert!(result.is_err());
Ok(())
}
#[test]
fn test_process_frontmatter_with_multiple_delimiters() -> Result<()> {
let content = "\
---
title: First
---
---
title: Second
---
Content";
let processed = process_frontmatter(content)?;
assert!(processed.contains("title: First"));
assert!(processed.contains("---\ntitle: Second"));
Ok(())
}
#[test]
fn test_process_frontmatter_with_malformed_delimiters(
) -> Result<(), ProcessError> {
let content = "---\ntitle: Test\nContent";
let processed = process_frontmatter(content)?;
assert_eq!(processed, content);
let content = "---\ntitle: Test\n---\nContent";
let processed = process_frontmatter(content)?;
assert!(processed.contains("<!--frontmatter-processed-->"));
assert!(processed.contains("title: Test"));
assert!(processed.contains("Content"));
Ok(())
}
#[test]
fn test_process_frontmatter_with_whitespace() -> Result<(), ProcessError> {
let content = "\n\n---\ntitle: Test\n---\nContent";
let processed = process_frontmatter(content)?;
assert!(processed.contains("<!--frontmatter-processed-->"));
assert!(processed.contains("title: Test"));
assert!(processed.contains("Content"));
let content = "---\n title: Test \n author: Someone \n---\nContent";
let processed = process_frontmatter(content)?;
assert!(processed.contains("<!--frontmatter-processed-->"));
assert!(processed.contains("title: Test"));
assert!(processed.contains("author: Someone"));
assert!(processed.contains("Content"));
Ok(())
}
#[test]
fn test_process_frontmatter_with_invalid_format() -> Result<(), ProcessError>
{
let content = "---\ntitle: Test\nContent";
let processed = process_frontmatter(content)?;
assert_eq!(processed, content);
let content = "===\ntitle: Test\n===\nContent";
let processed = process_frontmatter(content)?;
assert_eq!(processed, content);
let content = "---\n\n---\nContent";
let processed = process_frontmatter(content)?;
assert!(processed.contains("<!--frontmatter-processed-->"));
Ok(())
}
#[test]
fn test_preprocess_content_with_nested_directories(
) -> Result<(), ProcessError> {
let temp_dir = tempdir()?;
let nested_dir = temp_dir.path().join("nested");
fs::create_dir(&nested_dir)?;
let root_file = temp_dir.path().join("root.md");
let nested_file = nested_dir.join("nested.md");
fs::write(&root_file, "---\ntitle: Root\n---\nRoot content")?;
fs::write(&nested_file, "---\ntitle: Nested\n---\nNested content")?;
preprocess_content(temp_dir.path())?;
let root_content = fs::read_to_string(&root_file)?;
assert!(root_content.contains("<!--frontmatter-processed-->"));
let nested_content = fs::read_to_string(&nested_file)?;
assert!(!nested_content.contains("<!--frontmatter-processed-->"));
Ok(())
}
#[test]
fn test_preprocess_content_with_empty_files() -> Result<(), ProcessError> {
let temp_dir = tempdir()?;
let empty_file = temp_dir.path().join("empty.md");
fs::write(&empty_file, "")?;
preprocess_content(temp_dir.path())?;
let content = fs::read_to_string(&empty_file)?;
assert!(content.is_empty());
Ok(())
}
#[test]
fn test_ensure_directory_with_symlink() -> Result<(), ProcessError> {
let temp_dir = tempdir()?;
let real_dir = temp_dir.path().join("real_dir");
let symlink = temp_dir.path().join("symlink_dir");
fs::create_dir(&real_dir)?;
#[cfg(unix)]
std::os::unix::fs::symlink(&real_dir, &symlink)?;
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&real_dir, &symlink)?;
let result = ensure_directory(&symlink, "symlink");
assert!(result.is_ok());
Ok(())
}
#[test]
fn test_process_error_frontmatter_display() {
let error = ProcessError::FrontmatterError("bad yaml".to_string());
assert_eq!(error.to_string(), "Frontmatter processing error: bad yaml");
}
#[test]
fn test_process_error_source_for_directory_creation() {
use std::error::Error;
let error = ProcessError::DirectoryCreation {
dir_type: "output".to_string(),
path: "/bad".to_string(),
source: std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"denied",
),
};
assert!(error.source().is_some());
}
#[test]
fn test_process_error_source_for_io_error() {
use std::error::Error;
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
let error = ProcessError::IoError(io_err);
assert!(error.source().is_some());
}
#[test]
fn test_process_error_source_for_missing_argument() {
use std::error::Error;
let error = ProcessError::MissingArgument("foo".to_string());
assert!(error.source().is_none());
}
#[test]
fn test_process_error_source_for_compilation_error() {
use std::error::Error;
let error = ProcessError::CompilationError("oops".to_string());
assert!(error.source().is_none());
}
#[test]
fn test_process_error_source_for_frontmatter_error() {
use std::error::Error;
let error = ProcessError::FrontmatterError("bad".to_string());
assert!(error.source().is_none());
}
#[test]
fn test_process_error_debug() {
let error = ProcessError::MissingArgument("arg".to_string());
let debug = format!("{error:?}");
assert!(debug.contains("MissingArgument"));
}
#[test]
fn test_preprocess_content_empty_directory() -> Result<(), ProcessError> {
let temp_dir = tempdir()?;
preprocess_content(temp_dir.path())?;
Ok(())
}
#[test]
fn test_process_frontmatter_only_delimiters() -> Result<(), ProcessError> {
let content = "---\n---\n";
let processed = process_frontmatter(content)?;
assert!(processed.contains("<!--frontmatter-processed-->"));
Ok(())
}
#[test]
fn test_internal_compile_with_empty_directories() {
let temp_dir = tempdir().unwrap();
let build_dir = temp_dir.path().join("build");
let content_dir = temp_dir.path().join("content");
let site_dir = temp_dir.path().join("site");
let template_dir = temp_dir.path().join("template");
fs::create_dir_all(&build_dir).unwrap();
fs::create_dir_all(&content_dir).unwrap();
fs::create_dir_all(&site_dir).unwrap();
fs::create_dir_all(&template_dir).unwrap();
let result = internal_compile(
&build_dir,
&content_dir,
&site_dir,
&template_dir,
);
assert!(result.is_err());
}
}