use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use log::{debug, info, warn};
use std::path::PathBuf;
use thiserror::Error;
use crate::{config::Config, engine::Engine};
#[derive(Error, Debug)]
pub enum SsgError {
#[error("Configuration error: {0}")]
ConfigurationError(String),
#[error("Build error: {0}")]
BuildError(String),
#[error("Server error: {0}")]
ServerError(String),
#[error("File system error for path '{path}': {message}")]
FileSystemError {
path: PathBuf,
message: String,
},
}
#[derive(Parser, Debug)]
#[command(author, version, about = "Static Site Generator")]
pub struct SsgCommand {
#[arg(
short = 'd',
long,
global = true,
default_value = "content",
help = "Directory containing source content files"
)]
content_dir: PathBuf,
#[arg(
short = 'o',
long,
global = true,
default_value = "public",
help = "Directory where the generated site will be placed"
)]
output_dir: PathBuf,
#[arg(
short = 't',
long,
global = true,
default_value = "templates",
help = "Directory containing site templates"
)]
template_dir: PathBuf,
#[arg(
short = 'f',
long,
global = true,
help = "Path to custom configuration file"
)]
config: Option<PathBuf>,
#[command(subcommand)]
command: SsgSubCommand,
}
#[derive(Subcommand, Debug, Copy, Clone)]
pub enum SsgSubCommand {
Build(BuildArgs),
Serve(ServeArgs),
}
#[derive(Args, Debug, Copy, Clone)]
pub struct BuildArgs {
#[arg(
short,
long,
help = "Clean output directory before building"
)]
clean: bool,
}
#[derive(Args, Debug, Copy, Clone)]
pub struct ServeArgs {
#[arg(
short,
long,
default_value = "8000",
help = "Port number for development server"
)]
port: u16,
}
impl SsgCommand {
pub async fn execute(&self) -> Result<()> {
info!("Starting static site generation");
debug!(
"Configuration: content_dir={:?}, output_dir={:?}, template_dir={:?}",
self.content_dir, self.output_dir, self.template_dir
);
let config = self
.load_config()
.await
.context("Failed to load configuration")?;
let engine = Engine::new().context(
"Failed to initialize the static site generator engine",
)?;
match &self.command {
SsgSubCommand::Build(args) => {
self.build(&engine, &config, args.clean)
.await
.context("Build process failed")?;
}
SsgSubCommand::Serve(args) => {
self.serve(&engine, &config, args.port)
.await
.context("Development server failed")?;
}
}
info!("Site generation completed successfully");
Ok(())
}
async fn load_config(&self) -> Result<Config> {
self.config.as_ref().map_or_else(
|| {
Config::builder()
.site_name("Static Site")
.content_dir(&self.content_dir)
.output_dir(&self.output_dir)
.template_dir(&self.template_dir)
.build()
.context("Failed to create default configuration")
},
|config_path| {
Config::from_file(config_path).context(format!(
"Failed to load configuration from {}",
config_path.display()
))
},
)
}
async fn build(
&self,
engine: &Engine,
config: &Config,
clean: bool,
) -> Result<()> {
info!("Building static site");
debug!("Build configuration: {:#?}", config);
if clean {
self.clean_output_directory(config).await?;
}
tokio::fs::create_dir_all(&config.output_dir)
.await
.context(format!(
"Failed to create output directory: {}",
config.output_dir.display()
))?;
engine
.generate(config)
.await
.context("Site generation failed")?;
info!("Site built successfully");
Ok(())
}
async fn serve(
&self,
engine: &Engine,
config: &Config,
port: u16,
) -> Result<()> {
info!("Starting development server on port {}", port);
self.build(engine, config, false).await?;
warn!("Hot reloading is not yet implemented");
info!("Development server started");
Ok(())
}
async fn clean_output_directory(
&self,
config: &Config,
) -> Result<()> {
if config.output_dir.exists() {
debug!(
"Cleaning output directory: {}",
config.output_dir.display()
);
tokio::fs::remove_dir_all(&config.output_dir)
.await
.context(format!(
"Failed to clean output directory: {}",
config.output_dir.display()
))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_build_command() -> Result<()> {
let temp = tempdir()?;
let content_dir = temp.path().join("content");
let output_dir = temp.path().join("public");
let template_dir = temp.path().join("templates");
tokio::fs::create_dir_all(&content_dir).await?;
tokio::fs::create_dir_all(&output_dir).await?; tokio::fs::create_dir_all(&template_dir).await?;
let cmd = SsgCommand {
content_dir: content_dir.clone(),
output_dir: output_dir.clone(),
template_dir: template_dir.clone(),
config: None,
command: SsgSubCommand::Build(BuildArgs { clean: true }),
};
cmd.execute().await?;
assert!(output_dir.exists());
Ok(())
}
#[tokio::test]
async fn test_clean_build() -> Result<()> {
let temp = tempdir()?;
let output_dir = temp.path().join("public");
tokio::fs::create_dir_all(&output_dir).await?;
tokio::fs::write(output_dir.join("old.html"), "old content")
.await?;
let cmd = SsgCommand {
content_dir: temp.path().join("content"),
output_dir: output_dir.clone(),
template_dir: temp.path().join("templates"),
config: None,
command: SsgSubCommand::Build(BuildArgs { clean: true }),
};
tokio::fs::create_dir_all(&cmd.content_dir).await?;
tokio::fs::create_dir_all(&cmd.template_dir).await?;
cmd.execute().await?;
assert!(!output_dir.join("old.html").exists());
Ok(())
}
#[test]
fn test_command_parsing() {
let cmd = SsgCommand::try_parse_from([
"ssg",
"--content-dir",
"content",
"--output-dir",
"public",
"--template-dir",
"templates",
"build",
"--clean",
])
.unwrap();
assert_eq!(cmd.content_dir, PathBuf::from("content"));
assert_eq!(cmd.output_dir, PathBuf::from("public"));
assert!(matches!(
cmd.command,
SsgSubCommand::Build(BuildArgs { clean: true })
));
}
#[tokio::test]
async fn test_invalid_config() {
let temp = tempdir().unwrap();
let cmd = SsgCommand {
content_dir: temp.path().join("nonexistent"),
output_dir: temp.path().join("public"),
template_dir: temp.path().join("templates"),
config: Some(PathBuf::from("nonexistent.toml")),
command: SsgSubCommand::Build(BuildArgs { clean: false }),
};
let result = cmd.execute().await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_serve_command() -> Result<()> {
let temp = tempdir()?;
let content_dir = temp.path().join("content");
let output_dir = temp.path().join("public");
let template_dir = temp.path().join("templates");
tokio::fs::create_dir_all(&content_dir).await?;
tokio::fs::create_dir_all(&output_dir).await?; tokio::fs::create_dir_all(&template_dir).await?;
let cmd = SsgCommand {
content_dir: content_dir.clone(),
output_dir: output_dir.clone(),
template_dir: template_dir.clone(),
config: None,
command: SsgSubCommand::Serve(ServeArgs { port: 8080 }),
};
cmd.execute().await?;
assert!(output_dir.exists());
Ok(())
}
#[tokio::test]
async fn test_load_config_valid() -> Result<()> {
let temp = tempdir()?;
let config_path = temp.path().join("config.toml");
let content_dir = temp.path().join("content");
let output_dir = temp.path().join("public");
let template_dir = temp.path().join("templates");
tokio::fs::create_dir_all(&content_dir).await?;
tokio::fs::create_dir_all(&output_dir).await?;
tokio::fs::create_dir_all(&template_dir).await?;
let config_contents = format!(
r#"
site_name = "Test Site"
content_dir = "{}"
output_dir = "{}"
template_dir = "{}"
"#,
content_dir.display(),
output_dir.display(),
template_dir.display()
);
tokio::fs::write(&config_path, config_contents).await?;
let cmd = SsgCommand {
content_dir: content_dir.clone(),
output_dir: output_dir.clone(),
template_dir: template_dir.clone(),
config: Some(config_path.clone()),
command: SsgSubCommand::Build(BuildArgs { clean: false }),
};
let config = cmd.load_config().await?;
assert_eq!(config.site_name, "Test Site");
assert_eq!(config.content_dir, content_dir);
assert_eq!(config.output_dir, output_dir);
assert_eq!(config.template_dir, template_dir);
Ok(())
}
#[tokio::test]
async fn test_load_config_invalid() -> Result<()> {
let temp = tempdir()?;
let config_path = temp.path().join("config.toml");
tokio::fs::write(&config_path, "invalid_toml_content").await?;
let cmd = SsgCommand {
content_dir: PathBuf::from("content"),
output_dir: PathBuf::from("public"),
template_dir: PathBuf::from("templates"),
config: Some(config_path.clone()),
command: SsgSubCommand::Build(BuildArgs { clean: false }),
};
let result = cmd.load_config().await;
assert!(result.is_err());
Ok(())
}
#[tokio::test]
async fn test_clean_output_directory_exists() -> Result<()> {
let temp = tempdir()?;
let output_dir = temp.path().join("public");
tokio::fs::create_dir_all(&output_dir).await?;
tokio::fs::write(output_dir.join("test.html"), "test content")
.await?;
let cmd = SsgCommand {
content_dir: temp.path().join("content"),
output_dir: output_dir.clone(),
template_dir: temp.path().join("templates"),
config: None,
command: SsgSubCommand::Build(BuildArgs { clean: true }),
};
tokio::fs::create_dir_all(&cmd.content_dir).await?;
tokio::fs::create_dir_all(&cmd.template_dir).await?;
let config = Config::builder()
.site_name("Test Site")
.content_dir(&cmd.content_dir)
.output_dir(&cmd.output_dir)
.template_dir(&cmd.template_dir)
.build()
.unwrap();
cmd.clean_output_directory(&config).await?;
assert!(!output_dir.exists());
Ok(())
}
#[tokio::test]
async fn test_clean_output_directory_not_exists() -> Result<()> {
let temp = tempdir()?;
let output_dir = temp.path().join("public");
let cmd = SsgCommand {
content_dir: temp.path().join("content"),
output_dir: output_dir.clone(),
template_dir: temp.path().join("templates"),
config: None,
command: SsgSubCommand::Build(BuildArgs { clean: true }),
};
tokio::fs::create_dir_all(&cmd.content_dir).await?;
tokio::fs::create_dir_all(&cmd.output_dir).await?; tokio::fs::create_dir_all(&cmd.template_dir).await?;
let config = Config::builder()
.site_name("Test Site")
.content_dir(&cmd.content_dir)
.output_dir(&cmd.output_dir)
.template_dir(&cmd.template_dir)
.build()
.unwrap();
cmd.clean_output_directory(&config).await?;
assert!(!output_dir.exists());
Ok(())
}
#[tokio::test]
async fn test_execute_load_config_failure() -> Result<()> {
let temp = tempdir()?;
let invalid_config_path =
temp.path().join("invalid_config.toml");
tokio::fs::write(&invalid_config_path, "invalid_content")
.await?;
let cmd = SsgCommand {
content_dir: PathBuf::from("content"),
output_dir: PathBuf::from("public"),
template_dir: PathBuf::from("templates"),
config: Some(invalid_config_path.clone()),
command: SsgSubCommand::Build(BuildArgs { clean: false }),
};
let result = cmd.execute().await;
assert!(result.is_err());
let err_message = result.unwrap_err().to_string();
assert!(
err_message.contains("Failed to load configuration"),
"Unexpected error message: {}",
err_message
);
Ok(())
}
#[test]
fn test_command_parsing_invalid() {
let result = SsgCommand::try_parse_from([
"ssg",
"--unknown-arg",
"value",
"build",
]);
assert!(result.is_err());
}
}