1mod cli;
35mod config;
36mod error;
37mod validation;
38
39pub use cli::Cli;
40pub use config::{SsgConfig, SsgConfigBuilder};
41pub use error::{CliError, LanguageCode};
42pub use validation::{is_valid_url, validate_url};
43
44use std::path::PathBuf;
45use std::sync::{Arc, OnceLock};
46
47pub const DEFAULT_PORT: u16 = 8000;
49pub const DEFAULT_HOST: &str = "127.0.0.1";
56
57#[must_use]
62pub fn resolve_host() -> String {
63 std::env::var("SSG_HOST")
64 .ok()
65 .filter(|v| !v.is_empty())
66 .unwrap_or_else(|| DEFAULT_HOST.to_string())
67}
68
69#[must_use]
71pub fn resolve_port() -> u16 {
72 std::env::var("SSG_PORT")
73 .ok()
74 .and_then(|v| v.parse::<u16>().ok())
75 .unwrap_or(DEFAULT_PORT)
76}
77
78pub const RESERVED_NAMES: &[&str] =
80 &["con", "aux", "nul", "prn", "com1", "lpt1"];
81pub const MAX_CONFIG_SIZE: usize = 1024 * 1024; pub const DEFAULT_SITE_NAME: &str = "MySsgSite";
86pub const DEFAULT_SITE_TITLE: &str = "My SSG Site";
88
89pub static DEFAULT_CONFIG: OnceLock<Arc<SsgConfig>> = OnceLock::new();
91
92pub fn default_config() -> &'static Arc<SsgConfig> {
94 DEFAULT_CONFIG.get_or_init(|| {
95 Arc::new(SsgConfig {
96 site_name: DEFAULT_SITE_NAME.to_string(),
97 content_dir: PathBuf::from("content"),
98 output_dir: PathBuf::from("public"),
99 template_dir: PathBuf::from("templates"),
100 serve_dir: None,
101 base_url: format!("http://{DEFAULT_HOST}:{DEFAULT_PORT}"),
102 site_title: DEFAULT_SITE_TITLE.to_string(),
103 site_description: "A site built with SSG".to_string(),
104 language: "en-GB".to_string(),
105 i18n: None,
106 })
107 })
108}
109
110const _: () = {
112 assert!(MAX_CONFIG_SIZE > 0);
113 assert!(MAX_CONFIG_SIZE <= 10 * 1024 * 1024); };
115
116#[cfg(test)]
117#[allow(clippy::unwrap_used, clippy::expect_used)]
118mod tests {
119 use super::*;
120
121 fn with_env<F: FnOnce()>(key: &str, value: Option<&str>, f: F) {
125 use std::sync::Mutex;
126 static ENV_LOCK: Mutex<()> = Mutex::new(());
127 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
128 let prev = std::env::var(key).ok();
129 match value {
130 Some(v) => std::env::set_var(key, v),
131 None => std::env::remove_var(key),
132 }
133 f();
134 match prev {
135 Some(v) => std::env::set_var(key, v),
136 None => std::env::remove_var(key),
137 }
138 }
139
140 #[test]
141 fn resolve_host_returns_default_when_env_unset() {
142 with_env("SSG_HOST", None, || {
143 assert_eq!(resolve_host(), DEFAULT_HOST);
144 });
145 }
146
147 #[test]
148 fn resolve_host_returns_env_value_when_set() {
149 with_env("SSG_HOST", Some("0.0.0.0"), || {
150 assert_eq!(resolve_host(), "0.0.0.0");
151 });
152 }
153
154 #[test]
155 fn resolve_host_returns_default_when_env_empty() {
156 with_env("SSG_HOST", Some(""), || {
159 assert_eq!(resolve_host(), DEFAULT_HOST);
160 });
161 }
162
163 #[test]
164 fn resolve_port_returns_default_when_env_unset() {
165 with_env("SSG_PORT", None, || {
166 assert_eq!(resolve_port(), DEFAULT_PORT);
167 });
168 }
169
170 #[test]
171 fn resolve_port_returns_env_value_when_set() {
172 with_env("SSG_PORT", Some("8080"), || {
173 assert_eq!(resolve_port(), 8080);
174 });
175 }
176
177 #[test]
178 fn resolve_port_returns_default_when_env_unparseable() {
179 with_env("SSG_PORT", Some("not-a-number"), || {
180 assert_eq!(resolve_port(), DEFAULT_PORT);
181 });
182 }
183
184 #[test]
185 fn default_config_returns_lazily_initialised_singleton() {
186 let a = default_config();
187 let b = default_config();
188 assert!(Arc::ptr_eq(a, b));
190 assert_eq!(a.site_name, DEFAULT_SITE_NAME);
191 assert_eq!(a.site_title, DEFAULT_SITE_TITLE);
192 assert_eq!(a.language, "en-GB");
193 assert_eq!(a.content_dir, PathBuf::from("content"));
194 assert_eq!(a.output_dir, PathBuf::from("public"));
195 assert_eq!(a.template_dir, PathBuf::from("templates"));
196 assert!(a.serve_dir.is_none());
197 assert!(a.i18n.is_none());
198 }
199
200 #[test]
201 fn default_config_base_url_uses_default_host_and_port() {
202 let cfg = default_config();
203 assert!(
204 cfg.base_url.contains(DEFAULT_HOST),
205 "base_url should embed DEFAULT_HOST: {}",
206 cfg.base_url
207 );
208 assert!(
209 cfg.base_url.contains(&DEFAULT_PORT.to_string()),
210 "base_url should embed DEFAULT_PORT: {}",
211 cfg.base_url
212 );
213 }
214
215 #[test]
216 fn reserved_names_are_lowercase_and_non_empty() {
217 assert!(!RESERVED_NAMES.is_empty());
218 for name in RESERVED_NAMES {
219 assert!(!name.is_empty(), "reserved name should be non-empty");
220 assert_eq!(
221 *name,
222 name.to_lowercase(),
223 "reserved name should be lowercase: {name}"
224 );
225 }
226 }
227
228 #[test]
229 fn max_config_size_is_one_megabyte() {
230 assert_eq!(MAX_CONFIG_SIZE, 1024 * 1024);
231 }
232}