use super::error::CliError;
use super::validation::{validate_path_safety, validate_url};
use super::{default_config, MAX_CONFIG_SIZE};
use clap::ArgMatches;
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
str::FromStr,
};
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct ImageConfig {
#[serde(default = "default_avif_quality")]
pub avif_quality: u8,
#[serde(default)]
pub lazy_avif: bool,
}
const fn default_avif_quality() -> u8 {
70
}
impl Default for ImageConfig {
fn default() -> Self {
Self {
avif_quality: default_avif_quality(),
lazy_avif: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EdgeHeadersConfig {
#[serde(default)]
pub targets: Vec<String>,
#[serde(default)]
pub overrides: BTreeMap<String, String>,
}
impl EdgeHeadersConfig {
#[must_use]
pub const fn is_enabled(&self) -> bool {
!self.targets.is_empty()
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum SriAlgorithm {
Sha256,
#[default]
Sha384,
Sha512,
}
impl SriAlgorithm {
#[must_use]
pub const fn prefix(self) -> &'static str {
match self {
Self::Sha256 => "sha256",
Self::Sha384 => "sha384",
Self::Sha512 => "sha512",
}
}
#[must_use]
pub fn integrity(self, data: &[u8]) -> String {
use base64::{engine::general_purpose::STANDARD, Engine as _};
use sha2::{Digest as _, Sha256, Sha384, Sha512};
let b64 = match self {
Self::Sha256 => STANDARD.encode(Sha256::digest(data)),
Self::Sha384 => STANDARD.encode(Sha384::digest(data)),
Self::Sha512 => STANDARD.encode(Sha512::digest(data)),
};
format!("{}-{}", self.prefix(), b64)
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct SecurityConfig {
#[serde(default)]
pub sri_algorithm: SriAlgorithm,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SsgConfig {
pub site_name: String,
pub content_dir: PathBuf,
pub output_dir: PathBuf,
pub template_dir: PathBuf,
pub serve_dir: Option<PathBuf>,
pub base_url: String,
pub site_title: String,
pub site_description: String,
pub language: String,
#[serde(default)]
pub i18n: Option<crate::i18n::I18nConfig>,
#[serde(default)]
pub cdn_prefix: Option<String>,
#[serde(default)]
pub image: ImageConfig,
#[serde(default)]
pub edge_headers: EdgeHeadersConfig,
#[serde(default)]
pub agents: Option<
crate::plugins_group::postprocess::agentic_discovery::AgentsConfig,
>,
#[serde(default)]
pub transitions: bool,
#[serde(default)]
pub security: SecurityConfig,
}
impl Default for SsgConfig {
fn default() -> Self {
default_config().as_ref().clone()
}
}
impl SsgConfig {
fn override_with_cli(
mut self,
matches: &ArgMatches,
) -> Result<Self, CliError> {
if let Some(site_name) = matches.get_one::<String>("new") {
self.site_name.clone_from(site_name);
}
if let Some(content_dir) = matches.get_one::<PathBuf>("content") {
self.content_dir.clone_from(content_dir);
}
if let Some(output_dir) = matches.get_one::<PathBuf>("output") {
self.output_dir.clone_from(output_dir);
}
if let Some(template_dir) = matches.get_one::<PathBuf>("template") {
self.template_dir.clone_from(template_dir);
}
if let Some(serve_dir) = matches.get_one::<PathBuf>("serve") {
self.serve_dir = Some(serve_dir.clone());
}
self.validate()?;
Ok(self)
}
pub fn from_matches(matches: &ArgMatches) -> Result<Self, CliError> {
if let Some(config_path) = matches.get_one::<PathBuf>("config") {
let loaded_config = Self::from_file(config_path)?;
return Ok(loaded_config);
}
let config = Self::default();
let config = config.override_with_cli(matches)?;
Ok(config)
}
pub fn from_subcommand_matches(
sub_m: &ArgMatches,
) -> Result<Self, CliError> {
if let Some(config_path) = sub_m.get_one::<PathBuf>("config") {
return Self::from_file(config_path);
}
let mut config = Self::default();
if let Some(content_dir) = sub_m.get_one::<PathBuf>("content") {
config.content_dir.clone_from(content_dir);
}
if let Some(output_dir) = sub_m.get_one::<PathBuf>("output") {
config.output_dir.clone_from(output_dir);
}
if let Some(template_dir) = sub_m.get_one::<PathBuf>("template") {
config.template_dir.clone_from(template_dir);
}
if sub_m.try_contains_id("serve").unwrap_or(false) {
if let Some(serve_dir) = sub_m.get_one::<PathBuf>("serve") {
config.serve_dir = Some(serve_dir.clone());
}
}
config.validate()?;
Ok(config)
}
pub fn from_file(path: &Path) -> Result<Self, CliError> {
let metadata = fs::metadata(path)?;
if metadata.len() > MAX_CONFIG_SIZE as u64 {
return Err(CliError::ValidationError(format!(
"Config file too large (max {MAX_CONFIG_SIZE} bytes)"
)));
}
let content = fs::read_to_string(path)?;
let config: Self = toml::from_str(&content)?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<(), CliError> {
debug!("Validating config: {self:?}");
if self.site_name.trim().is_empty() {
error!("site_name cannot be empty");
return Err(CliError::ValidationError(
"site_name cannot be empty".into(),
));
}
if !self.base_url.is_empty() {
validate_url(&self.base_url)?;
}
validate_path_safety(&self.content_dir, "content_dir")?;
validate_path_safety(&self.output_dir, "output_dir")?;
validate_path_safety(&self.template_dir, "template_dir")?;
if let Some(ref serve_dir) = self.serve_dir {
validate_path_safety(serve_dir, "serve_dir")?;
}
info!("Config validation successful");
Ok(())
}
#[must_use]
pub fn builder() -> SsgConfigBuilder {
SsgConfigBuilder::default()
}
}
impl FromStr for SsgConfig {
type Err = CliError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let config: Self = toml::from_str(s)?;
config.validate()?;
Ok(config)
}
}
#[derive(Debug, Clone, Default)]
pub struct SsgConfigBuilder {
config: SsgConfig,
}
impl SsgConfigBuilder {
#[must_use]
pub fn site_name(mut self, name: String) -> Self {
self.config.site_name = name;
self
}
#[must_use]
pub fn base_url(mut self, url: String) -> Self {
self.config.base_url = url;
self
}
#[must_use]
pub fn content_dir(mut self, dir: PathBuf) -> Self {
self.config.content_dir = dir;
self
}
#[must_use]
pub fn output_dir(mut self, dir: PathBuf) -> Self {
self.config.output_dir = dir;
self
}
#[must_use]
pub fn template_dir(mut self, dir: PathBuf) -> Self {
self.config.template_dir = dir;
self
}
#[must_use]
pub fn serve_dir(mut self, dir: Option<PathBuf>) -> Self {
self.config.serve_dir = dir;
self
}
#[must_use]
pub fn site_title(mut self, title: String) -> Self {
self.config.site_title = title;
self
}
#[must_use]
pub fn site_description(mut self, desc: String) -> Self {
self.config.site_description = desc;
self
}
#[must_use]
pub fn language(mut self, lang: String) -> Self {
self.config.language = lang;
self
}
#[must_use]
pub fn i18n(mut self, i18n: Option<crate::i18n::I18nConfig>) -> Self {
self.config.i18n = i18n;
self
}
#[must_use]
pub fn cdn_prefix(mut self, prefix: Option<String>) -> Self {
self.config.cdn_prefix = prefix;
self
}
#[must_use]
pub fn edge_headers(mut self, edge: EdgeHeadersConfig) -> Self {
self.config.edge_headers = edge;
self
}
#[must_use]
pub const fn security(mut self, security: SecurityConfig) -> Self {
self.config.security = security;
self
}
#[must_use]
pub const fn transitions(mut self, enabled: bool) -> Self {
self.config.transitions = enabled;
self
}
pub fn build(self) -> Result<SsgConfig, CliError> {
self.config.validate()?;
Ok(self.config)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::cmd::Cli;
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
fn assert_err_variant<T: std::fmt::Debug>(
result: Result<T, CliError>,
variant: &str,
) {
let err = result.expect_err("expected an error");
let repr = format!("{err:?}");
assert!(repr.starts_with(variant), "expected {variant}, got {repr}");
}
#[test]
fn test_config_validation() {
let config = SsgConfig::builder().site_name(String::new()).build();
assert_err_variant(config, "ValidationError");
}
#[test]
fn test_config_file_size_limit() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("large.toml");
let mut file = File::create(&config_path).unwrap();
write!(file, "{}", "x".repeat(MAX_CONFIG_SIZE + 1)).unwrap();
assert_err_variant(
SsgConfig::from_file(&config_path),
"ValidationError",
);
}
#[test]
fn test_config_from_str() {
let config_str = r#"
site_name = "test"
content_dir = "./examples/content"
output_dir = "./examples/public"
template_dir = "./examples/templates"
base_url = "http://example.com"
site_title = "Test Site"
site_description = "Test Description"
language = "en-GB"
"#;
let config: Result<SsgConfig, _> = config_str.parse();
assert!(config.is_ok());
}
#[test]
fn test_config_builder_all_fields() {
let temp_dir = tempdir().unwrap();
let serve_dir = temp_dir.path().join("serve");
fs::create_dir_all(&serve_dir).unwrap();
let config = SsgConfig::builder()
.site_name("test".to_string())
.base_url("http://example.com".to_string())
.content_dir(PathBuf::from("./examples/content"))
.output_dir(PathBuf::from("./examples/public"))
.template_dir(PathBuf::from("./examples/templates"))
.serve_dir(Some(serve_dir))
.site_title("Test Site".to_string())
.site_description("Test Desc".to_string())
.language("en-GB".to_string())
.build();
assert!(config.is_ok());
}
#[test]
fn test_invalid_config_file() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("invalid.toml");
let mut file = File::create(&config_path).unwrap();
write!(file, "invalid toml content").unwrap();
assert_err_variant(SsgConfig::from_file(&config_path), "TomlError");
}
#[test]
fn test_from_matches() {
let matches = Cli::build().get_matches_from(vec!["ssg"]);
let config = SsgConfig::from_matches(&matches);
assert!(config.is_ok());
}
#[test]
fn test_config_builder_empty_required_fields() {
let config = SsgConfig::builder()
.site_name(String::new())
.site_title(String::new())
.build();
assert_err_variant(config, "ValidationError");
}
#[test]
fn test_config_file_not_found() {
let non_existent = Path::new("non_existent.toml");
assert_err_variant(SsgConfig::from_file(non_existent), "IoError");
}
#[test]
fn test_from_matches_with_config_file() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("config.toml");
let config_content = r#"
site_name = "from-file"
content_dir = "./examples/content"
output_dir = "./examples/public"
template_dir = "./examples/templates"
base_url = "http://example.com"
site_title = "File Site"
site_description = "From file"
language = "en-GB"
"#;
fs::write(&config_path, config_content).unwrap();
let cmd = Cli::build();
let matches = cmd.get_matches_from(vec![
"ssg",
"--config",
config_path.to_str().unwrap(),
]);
let config = SsgConfig::from_matches(&matches).unwrap();
assert_eq!(config.site_name, "from-file");
}
#[test]
fn test_override_with_cli_all_flags() {
let cmd = Cli::build();
let matches = cmd.get_matches_from(vec![
"ssg",
"--new",
"cli-site",
"--content",
"./examples/content",
"--output",
"./examples/public",
"--template",
"./examples/templates",
"--serve",
"./examples/public",
]);
let config = SsgConfig::from_matches(&matches).unwrap();
assert_eq!(config.site_name, "cli-site");
assert_eq!(config.content_dir, PathBuf::from("./examples/content"));
assert_eq!(config.output_dir, PathBuf::from("./examples/public"));
assert_eq!(config.template_dir, PathBuf::from("./examples/templates"));
assert!(config.serve_dir.is_some());
}
#[test]
fn test_override_with_watch_flag() {
let cmd = Cli::build();
let matches = cmd.get_matches_from(vec!["ssg", "--watch"]);
let config = SsgConfig::from_matches(&matches).unwrap();
assert!(!config.site_name.is_empty());
}
#[test]
fn test_validate_empty_url() {
let config = SsgConfig::builder()
.site_name("test".to_string())
.base_url(String::new())
.build();
assert!(config.is_ok());
}
#[test]
fn test_config_from_file_valid_toml() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("valid.toml");
let toml_content = r#"
site_name = "TestSite"
content_dir = "./examples/content"
output_dir = "./examples/public"
template_dir = "./examples/templates"
base_url = "http://test.example.com"
site_title = "Test Title"
site_description = "A test site"
language = "en-GB"
"#;
fs::write(&config_path, toml_content).unwrap();
let config = SsgConfig::from_file(&config_path).unwrap();
assert_eq!(config.site_name, "TestSite");
assert_eq!(config.site_title, "Test Title");
assert_eq!(config.base_url, "http://test.example.com");
}
#[test]
fn builder_sets_i18n() {
let i18n_cfg = crate::i18n::I18nConfig {
default_locale: "en".into(),
locales: vec!["en".into(), "fr".into()],
url_prefix: crate::i18n::UrlPrefixStrategy::SubPath,
};
let cfg = SsgConfig::builder()
.site_name("t".to_string())
.base_url("http://example.com".to_string())
.i18n(Some(i18n_cfg.clone()))
.build()
.unwrap();
assert!(cfg.i18n.is_some());
assert_eq!(cfg.i18n.as_ref().unwrap().default_locale, "en");
}
#[test]
fn builder_sets_cdn_prefix() {
let cfg = SsgConfig::builder()
.site_name("t".to_string())
.base_url("http://example.com".to_string())
.cdn_prefix(Some("https://cdn.example.com".into()))
.build()
.unwrap();
assert_eq!(cfg.cdn_prefix.as_deref(), Some("https://cdn.example.com"));
}
#[test]
fn builder_cdn_prefix_none_is_default() {
let cfg = SsgConfig::builder()
.site_name("t".to_string())
.base_url("http://example.com".to_string())
.cdn_prefix(None)
.build()
.unwrap();
assert!(cfg.cdn_prefix.is_none());
}
#[test]
fn security_section_absent_defaults_to_sha384() {
let config_str = r#"
site_name = "test"
content_dir = "./examples/content"
output_dir = "./examples/public"
template_dir = "./examples/templates"
base_url = "http://example.com"
site_title = "Test Site"
site_description = "Test Description"
language = "en-GB"
"#;
let cfg: SsgConfig = config_str.parse().unwrap();
assert_eq!(cfg.security.sri_algorithm, SriAlgorithm::Sha384);
}
#[test]
fn security_sri_algorithm_parses_all_enum_values() {
for (raw, expected) in [
("sha256", SriAlgorithm::Sha256),
("sha384", SriAlgorithm::Sha384),
("sha512", SriAlgorithm::Sha512),
] {
let config_str = format!(
r#"
site_name = "test"
content_dir = "./examples/content"
output_dir = "./examples/public"
template_dir = "./examples/templates"
base_url = "http://example.com"
site_title = "Test Site"
site_description = "Test Description"
language = "en-GB"
[security]
sri_algorithm = "{raw}"
"#
);
let cfg: SsgConfig = config_str.parse().unwrap();
assert_eq!(cfg.security.sri_algorithm, expected, "raw = {raw}");
}
}
#[test]
fn security_sri_algorithm_rejects_unknown_value() {
let config_str = r#"
site_name = "test"
content_dir = "./examples/content"
output_dir = "./examples/public"
template_dir = "./examples/templates"
base_url = "http://example.com"
site_title = "Test Site"
site_description = "Test Description"
language = "en-GB"
[security]
sri_algorithm = "md5"
"#;
let cfg: Result<SsgConfig, CliError> = config_str.parse();
assert_err_variant(cfg, "TomlError");
}
#[test]
fn from_matches_rejects_invalid_content_override() {
let matches =
Cli::build().get_matches_from(vec!["ssg", "--content", "bad<dir"]);
assert_err_variant(SsgConfig::from_matches(&matches), "InvalidPath");
}
#[test]
fn from_matches_propagates_missing_config_file_error() {
let matches = Cli::build().get_matches_from(vec![
"ssg",
"--config",
"/nonexistent/ssg-test-config.toml",
]);
assert_err_variant(SsgConfig::from_matches(&matches), "IoError");
}
#[test]
fn from_subcommand_matches_rejects_invalid_content_override() {
let (_inv, matches) =
Cli::parse_and_dispatch(["ssg", "build", "--content", "bad<dir"])
.unwrap();
let sub = matches.subcommand_matches("build").unwrap();
assert_err_variant(
SsgConfig::from_subcommand_matches(sub),
"InvalidPath",
);
}
#[test]
fn from_subcommand_matches_dev_without_serve_keeps_none() {
let (_inv, matches) = Cli::parse_and_dispatch(["ssg", "dev"]).unwrap();
let sub = matches.subcommand_matches("dev").unwrap();
let cfg = SsgConfig::from_subcommand_matches(sub).unwrap();
assert!(cfg.serve_dir.is_none());
}
#[test]
fn from_file_fails_when_path_is_a_directory() {
let dir = tempdir().unwrap();
assert_err_variant(SsgConfig::from_file(dir.path()), "IoError");
}
#[test]
fn from_file_propagates_validation_failure() {
let dir = tempdir().unwrap();
let path = dir.path().join("invalid-fields.toml");
fs::write(
&path,
r#"
site_name = ""
content_dir = "./examples/content"
output_dir = "./examples/public"
template_dir = "./examples/templates"
base_url = "http://example.com"
site_title = "T"
site_description = "D"
language = "en-GB"
"#,
)
.unwrap();
assert_err_variant(SsgConfig::from_file(&path), "ValidationError");
}
#[test]
fn from_str_propagates_validation_failure() {
let config_str = r#"
site_name = ""
content_dir = "./examples/content"
output_dir = "./examples/public"
template_dir = "./examples/templates"
base_url = "http://example.com"
site_title = "T"
site_description = "D"
language = "en-GB"
"#;
let cfg: Result<SsgConfig, CliError> = config_str.parse();
assert_err_variant(cfg, "ValidationError");
}
#[test]
fn validate_rejects_invalid_base_url() {
let cfg = SsgConfig::builder()
.site_name("t".to_string())
.base_url("ftp://example.com".to_string())
.build();
assert_err_variant(cfg, "InvalidUrl");
}
#[test]
fn validate_rejects_invalid_content_dir() {
let cfg = SsgConfig::builder()
.site_name("t".to_string())
.content_dir(PathBuf::from("bad<content"))
.build();
assert_err_variant(cfg, "InvalidPath");
}
#[test]
fn validate_rejects_invalid_output_dir() {
let cfg = SsgConfig::builder()
.site_name("t".to_string())
.output_dir(PathBuf::from("bad<output"))
.build();
assert_err_variant(cfg, "InvalidPath");
}
#[test]
fn validate_rejects_invalid_template_dir() {
let cfg = SsgConfig::builder()
.site_name("t".to_string())
.template_dir(PathBuf::from("bad<template"))
.build();
assert_err_variant(cfg, "InvalidPath");
}
#[test]
fn validate_rejects_invalid_serve_dir() {
let cfg = SsgConfig::builder()
.site_name("t".to_string())
.serve_dir(Some(PathBuf::from("bad<serve")))
.build();
assert_err_variant(cfg, "InvalidPath");
}
#[test]
fn builder_transitions_flag_round_trips() {
let on = SsgConfig::builder().transitions(true).build().unwrap();
assert!(on.transitions);
let off = SsgConfig::builder().transitions(false).build().unwrap();
assert!(!off.transitions);
}
#[test]
fn from_subcommand_matches_returns_defaults_when_no_overrides() {
let (_inv, matches) =
Cli::parse_and_dispatch(["ssg", "build"]).unwrap();
let sub = matches.subcommand_matches("build").unwrap();
let cfg = SsgConfig::from_subcommand_matches(sub).unwrap();
assert_eq!(cfg.content_dir, PathBuf::from("content"));
assert_eq!(cfg.output_dir, PathBuf::from("public"));
assert_eq!(cfg.template_dir, PathBuf::from("templates"));
assert!(cfg.serve_dir.is_none());
}
#[test]
fn from_subcommand_matches_applies_content_output_template_overrides() {
let (_inv, matches) = Cli::parse_and_dispatch([
"ssg",
"build",
"--content",
"/c",
"--output",
"/o",
"--template",
"/t",
])
.unwrap();
let sub = matches.subcommand_matches("build").unwrap();
let cfg = SsgConfig::from_subcommand_matches(sub).unwrap();
assert_eq!(cfg.content_dir, PathBuf::from("/c"));
assert_eq!(cfg.output_dir, PathBuf::from("/o"));
assert_eq!(cfg.template_dir, PathBuf::from("/t"));
}
#[test]
fn from_subcommand_matches_picks_up_serve_for_dev_subcommand() {
let (_inv, matches) =
Cli::parse_and_dispatch(["ssg", "dev", "--serve", "/srv"]).unwrap();
let sub = matches.subcommand_matches("dev").unwrap();
let cfg = SsgConfig::from_subcommand_matches(sub).unwrap();
assert_eq!(cfg.serve_dir, Some(PathBuf::from("/srv")));
}
#[test]
fn from_subcommand_matches_check_subcommand_has_no_serve() {
let (_inv, matches) =
Cli::parse_and_dispatch(["ssg", "check"]).unwrap();
let sub = matches.subcommand_matches("check").unwrap();
let cfg = SsgConfig::from_subcommand_matches(sub).unwrap();
assert!(cfg.serve_dir.is_none());
}
#[test]
fn from_subcommand_matches_loads_config_file_when_present() {
let dir = tempdir().unwrap();
let cfg_path = dir.path().join("c.toml");
fs::write(
&cfg_path,
r#"
site_name = "FromSub"
content_dir = "./examples/content"
output_dir = "./examples/public"
template_dir = "./examples/templates"
base_url = "http://sub.example.com"
site_title = "Sub Title"
site_description = "Sub Desc"
language = "en-GB"
"#,
)
.unwrap();
let (_inv, matches) = Cli::parse_and_dispatch([
"ssg",
"build",
"--config",
cfg_path.to_str().unwrap(),
])
.unwrap();
let sub = matches.subcommand_matches("build").unwrap();
let cfg = SsgConfig::from_subcommand_matches(sub).unwrap();
assert_eq!(cfg.site_name, "FromSub");
assert_eq!(cfg.base_url, "http://sub.example.com");
}
}