mod cli;
mod config;
mod error;
mod validation;
pub use cli::Cli;
pub use config::{SsgConfig, SsgConfigBuilder};
pub use error::{CliError, LanguageCode};
pub use validation::{is_valid_url, validate_url};
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
pub const DEFAULT_PORT: u16 = 8000;
pub const DEFAULT_HOST: &str = "127.0.0.1";
#[must_use]
pub fn resolve_host() -> String {
std::env::var("SSG_HOST")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_HOST.to_string())
}
#[must_use]
pub fn resolve_port() -> u16 {
std::env::var("SSG_PORT")
.ok()
.and_then(|v| v.parse::<u16>().ok())
.unwrap_or(DEFAULT_PORT)
}
pub const RESERVED_NAMES: &[&str] =
&["con", "aux", "nul", "prn", "com1", "lpt1"];
pub const MAX_CONFIG_SIZE: usize = 1024 * 1024;
pub const DEFAULT_SITE_NAME: &str = "MySsgSite";
pub const DEFAULT_SITE_TITLE: &str = "My SSG Site";
pub static DEFAULT_CONFIG: OnceLock<Arc<SsgConfig>> = OnceLock::new();
pub fn default_config() -> &'static Arc<SsgConfig> {
DEFAULT_CONFIG.get_or_init(|| {
Arc::new(SsgConfig {
site_name: DEFAULT_SITE_NAME.to_string(),
content_dir: PathBuf::from("content"),
output_dir: PathBuf::from("public"),
template_dir: PathBuf::from("templates"),
serve_dir: None,
base_url: format!("http://{DEFAULT_HOST}:{DEFAULT_PORT}"),
site_title: DEFAULT_SITE_TITLE.to_string(),
site_description: "A site built with SSG".to_string(),
language: "en-GB".to_string(),
i18n: None,
})
})
}
const _: () = {
assert!(MAX_CONFIG_SIZE > 0);
assert!(MAX_CONFIG_SIZE <= 10 * 1024 * 1024); };
#[cfg(test)]
mod tests {
use super::*;
fn with_env<F: FnOnce()>(key: &str, value: Option<&str>, f: F) {
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let prev = std::env::var(key).ok();
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
f();
match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
#[test]
fn resolve_host_returns_default_when_env_unset() {
with_env("SSG_HOST", None, || {
assert_eq!(resolve_host(), DEFAULT_HOST);
});
}
#[test]
fn resolve_host_returns_env_value_when_set() {
with_env("SSG_HOST", Some("0.0.0.0"), || {
assert_eq!(resolve_host(), "0.0.0.0");
});
}
#[test]
fn resolve_host_returns_default_when_env_empty() {
with_env("SSG_HOST", Some(""), || {
assert_eq!(resolve_host(), DEFAULT_HOST);
});
}
#[test]
fn resolve_port_returns_default_when_env_unset() {
with_env("SSG_PORT", None, || {
assert_eq!(resolve_port(), DEFAULT_PORT);
});
}
#[test]
fn resolve_port_returns_env_value_when_set() {
with_env("SSG_PORT", Some("8080"), || {
assert_eq!(resolve_port(), 8080);
});
}
#[test]
fn resolve_port_returns_default_when_env_unparseable() {
with_env("SSG_PORT", Some("not-a-number"), || {
assert_eq!(resolve_port(), DEFAULT_PORT);
});
}
#[test]
fn default_config_returns_lazily_initialised_singleton() {
let a = default_config();
let b = default_config();
assert!(Arc::ptr_eq(a, b));
assert_eq!(a.site_name, DEFAULT_SITE_NAME);
assert_eq!(a.site_title, DEFAULT_SITE_TITLE);
assert_eq!(a.language, "en-GB");
assert_eq!(a.content_dir, PathBuf::from("content"));
assert_eq!(a.output_dir, PathBuf::from("public"));
assert_eq!(a.template_dir, PathBuf::from("templates"));
assert!(a.serve_dir.is_none());
assert!(a.i18n.is_none());
}
#[test]
fn default_config_base_url_uses_default_host_and_port() {
let cfg = default_config();
assert!(
cfg.base_url.contains(DEFAULT_HOST),
"base_url should embed DEFAULT_HOST: {}",
cfg.base_url
);
assert!(
cfg.base_url.contains(&DEFAULT_PORT.to_string()),
"base_url should embed DEFAULT_PORT: {}",
cfg.base_url
);
}
#[test]
fn reserved_names_are_lowercase_and_non_empty() {
assert!(!RESERVED_NAMES.is_empty());
for name in RESERVED_NAMES {
assert!(!name.is_empty(), "reserved name should be non-empty");
assert_eq!(
*name,
name.to_lowercase(),
"reserved name should be lowercase: {name}"
);
}
}
#[test]
fn max_config_size_is_one_megabyte() {
assert_eq!(MAX_CONFIG_SIZE, 1024 * 1024);
}
}