ssg/cmd/mod.rs
1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! # Command Line Interface Module
5//!
6//! This module provides a secure and robust command-line interface (CLI) for the
7//! **Static Site Generator (SSG)**. It handles argument parsing, configuration management,
8//! and validation of user inputs to ensure that the static site generator operates
9//! reliably and securely.
10//!
11//! ## Key Features
12//! - Safe path handling (including symbolic link checks and canonicalization)
13//! - Input validation (URL, language, environment variables)
14//! - Secure configuration with size-limited config files
15//! - Builder pattern for convenient configuration construction
16//! - Error handling via `CliError`
17//!
18//! ## Example Usage
19//! ```rust,no_run
20//! use ssg::cmd::{Cli, SsgConfig};
21//!
22//! fn main() -> anyhow::Result<()> {
23//! let matches = Cli::build().get_matches();
24//!
25//! // Attempt to load configuration from command-line arguments
26//! let mut config = SsgConfig::from_matches(&matches)?;
27//!
28//! println!("Configuration loaded: {:?}", config);
29//! // Continue with application logic...
30//! Ok(())
31//! }
32//! ```
33
34pub mod audit;
35mod cli;
36pub mod completions;
37mod config;
38mod error;
39pub mod man;
40mod validation;
41
42pub use cli::{
43 Cli, CliInvocation, DEPLOY_TARGETS, LEGACY_DEPRECATION_WARNING, SUBCOMMANDS,
44};
45pub use config::{
46 EdgeHeadersConfig, ImageConfig, SecurityConfig, SriAlgorithm, SsgConfig,
47 SsgConfigBuilder,
48};
49pub use error::{CliError, LanguageCode};
50pub use validation::{is_valid_url, validate_url};
51
52use std::path::PathBuf;
53use std::sync::{Arc, OnceLock};
54
55/// Default port for the local development server.
56pub const DEFAULT_PORT: u16 = 8000;
57/// Default host for the local development server.
58///
59/// Loopback by default. WSL2 users whose Windows host can't reach the
60/// distro on `127.0.0.1` (and Codespaces / dev-containers users binding
61/// outside their network namespace) should set `SSG_HOST=0.0.0.0` and
62/// let [`resolve_host`] pick it up. The same applies to `SSG_PORT`.
63pub const DEFAULT_HOST: &str = "127.0.0.1";
64
65/// Resolve the dev-server host, preferring `$SSG_HOST` over [`DEFAULT_HOST`].
66///
67/// Returns the value of the `SSG_HOST` environment variable if set and
68/// non-empty; otherwise returns the compiled-in default.
69///
70/// # Examples
71///
72/// ```rust
73/// use ssg::cmd::{resolve_host, DEFAULT_HOST};
74///
75/// // With no env override the compiled-in default is returned.
76/// std::env::remove_var("SSG_HOST");
77/// assert_eq!(resolve_host(), DEFAULT_HOST);
78/// ```
79#[must_use]
80pub fn resolve_host() -> String {
81 std::env::var("SSG_HOST")
82 .ok()
83 .filter(|v| !v.is_empty())
84 .unwrap_or_else(|| DEFAULT_HOST.to_string())
85}
86
87/// Resolve the dev-server port, preferring `$SSG_PORT` over [`DEFAULT_PORT`].
88///
89/// # Examples
90///
91/// ```rust
92/// use ssg::cmd::{resolve_port, DEFAULT_PORT};
93///
94/// std::env::remove_var("SSG_PORT");
95/// assert_eq!(resolve_port(), DEFAULT_PORT);
96/// ```
97#[must_use]
98pub fn resolve_port() -> u16 {
99 std::env::var("SSG_PORT")
100 .ok()
101 .and_then(|v| v.parse::<u16>().ok())
102 .unwrap_or(DEFAULT_PORT)
103}
104
105/// Reserved names that cannot be used as paths on Windows systems.
106pub const RESERVED_NAMES: &[&str] =
107 &["con", "aux", "nul", "prn", "com1", "lpt1"];
108/// Maximum allowed size in bytes for config files.
109pub const MAX_CONFIG_SIZE: usize = 1024 * 1024; // 1MB limit
110
111/// Default site name for the configuration.
112///
113/// Used for the scaffold directory name (`ssg --new`), never rendered into a
114/// page. Contrast [`DEFAULT_SITE_TITLE`], which is.
115pub const DEFAULT_SITE_NAME: &str = "MySsgSite";
116
117/// Default site title for the configuration — deliberately empty.
118///
119/// This value **reaches rendered HTML**: the taxonomy plugin puts it in
120/// `site.title`, and `templates/tera/base.html` appends it to every page
121/// title as `<title>{page} — {site.title}</title>`.
122///
123/// It used to be `"My SSG Site"`. A site built without a config file therefore
124/// shipped that placeholder as its brand — on sebastienrousseau.com it reached
125/// 7,189 generated tag pages, each titled `Tag: <term> — My SSG Site`, live and
126/// indexable. Nothing caught it because the defaults were only ever asserted
127/// *equal to their own constant*; no test asked whether they escaped into
128/// output.
129///
130/// Empty is the safe default: `base.html` guards the suffix with
131/// `{% if site.title %}`, so an unconfigured build now renders `<title>{page}</title>`
132/// and brands nothing. Sites that want a suffix set `site_title` explicitly,
133/// and `ssg` warns when it falls back to defaults.
134///
135/// See `tests/no_placeholder_in_output.rs`, which fails if any placeholder
136/// constant appears anywhere in a rendered site.
137pub const DEFAULT_SITE_TITLE: &str = "";
138
139/// A static default configuration for the SSG site.
140pub static DEFAULT_CONFIG: OnceLock<Arc<SsgConfig>> = OnceLock::new();
141
142/// Returns a reference to the lazily-initialised default configuration.
143///
144/// # Examples
145///
146/// ```rust
147/// use ssg::cmd::{default_config, DEFAULT_SITE_NAME};
148///
149/// let cfg = default_config();
150/// assert_eq!(cfg.site_name, DEFAULT_SITE_NAME);
151/// ```
152pub fn default_config() -> &'static Arc<SsgConfig> {
153 DEFAULT_CONFIG.get_or_init(|| {
154 Arc::new(SsgConfig {
155 site_name: DEFAULT_SITE_NAME.to_string(),
156 listings: Vec::new(),
157 content_dir: PathBuf::from("content"),
158 output_dir: PathBuf::from("public"),
159 template_dir: PathBuf::from("templates"),
160 theme: None,
161 serve_dir: None,
162 base_url: format!("http://{DEFAULT_HOST}:{DEFAULT_PORT}"),
163 site_title: DEFAULT_SITE_TITLE.to_string(),
164 site_description: "A site built with SSG".to_string(),
165 language: "en-GB".to_string(),
166 #[cfg(feature = "i18n")]
167 i18n: None,
168 cdn_prefix: None,
169 og_image: None,
170 image: ImageConfig::default(),
171 edge_headers: EdgeHeadersConfig::default(),
172 agents: None,
173 transitions: false,
174 // Default: generate taxonomy pages, as before.
175 no_taxonomy_pages: false,
176 security: SecurityConfig::default(),
177 })
178 })
179}
180
181/// Const validation for compile-time checks.
182const _: () = {
183 assert!(MAX_CONFIG_SIZE > 0);
184 assert!(MAX_CONFIG_SIZE <= 10 * 1024 * 1024); // Max 10MB
185};
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 /// Mutex-protected env-var setter so concurrent tests don't race.
192 /// `cargo test` runs tests in parallel by default; without serialisation
193 /// the env-var assertions below would interleave nondeterministically.
194 fn with_env<F: FnOnce()>(key: &str, value: Option<&str>, f: F) {
195 use std::sync::Mutex;
196 static ENV_LOCK: Mutex<()> = Mutex::new(());
197 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
198 let prev = std::env::var(key).ok();
199 match value {
200 Some(v) => std::env::set_var(key, v),
201 None => std::env::remove_var(key),
202 }
203 f();
204 match prev {
205 Some(v) => std::env::set_var(key, v),
206 None => std::env::remove_var(key),
207 }
208 }
209
210 #[test]
211 fn resolve_host_returns_default_when_env_unset() {
212 with_env("SSG_HOST", None, || {
213 assert_eq!(resolve_host(), DEFAULT_HOST);
214 });
215 }
216
217 #[test]
218 fn resolve_host_returns_env_value_when_set() {
219 with_env("SSG_HOST", Some("0.0.0.0"), || {
220 assert_eq!(resolve_host(), "0.0.0.0");
221 });
222 }
223
224 #[test]
225 fn resolve_host_returns_default_when_env_empty() {
226 // Empty string should fall through to the default — matters for
227 // shells that export `SSG_HOST=` to "unset" without `unset`.
228 with_env("SSG_HOST", Some(""), || {
229 assert_eq!(resolve_host(), DEFAULT_HOST);
230 });
231 }
232
233 #[test]
234 fn resolve_port_returns_default_when_env_unset() {
235 with_env("SSG_PORT", None, || {
236 assert_eq!(resolve_port(), DEFAULT_PORT);
237 });
238 }
239
240 #[test]
241 fn resolve_port_returns_env_value_when_set() {
242 with_env("SSG_PORT", Some("8080"), || {
243 assert_eq!(resolve_port(), 8080);
244 });
245 }
246
247 #[test]
248 fn resolve_port_returns_default_when_env_unparseable() {
249 with_env("SSG_PORT", Some("not-a-number"), || {
250 assert_eq!(resolve_port(), DEFAULT_PORT);
251 });
252 }
253
254 #[cfg(feature = "i18n")]
255 #[test]
256 fn default_config_returns_lazily_initialised_singleton() {
257 let a = default_config();
258 let b = default_config();
259 // Same Arc pointer — confirms OnceLock is being reused.
260 assert!(Arc::ptr_eq(a, b));
261 assert_eq!(a.site_name, DEFAULT_SITE_NAME);
262 assert_eq!(a.site_title, DEFAULT_SITE_TITLE);
263 assert_eq!(a.language, "en-GB");
264 assert_eq!(a.content_dir, PathBuf::from("content"));
265 assert_eq!(a.output_dir, PathBuf::from("public"));
266 assert_eq!(a.template_dir, PathBuf::from("templates"));
267 assert!(a.serve_dir.is_none());
268 assert!(a.i18n.is_none());
269 }
270
271 #[test]
272 fn default_config_base_url_uses_default_host_and_port() {
273 // Plain conditions only: format-arg expressions in an assert
274 // message are evaluated lazily and would leave never-executed
275 // regions behind.
276 let cfg = default_config();
277 assert!(cfg.base_url.contains(DEFAULT_HOST));
278 assert!(cfg.base_url.contains(&DEFAULT_PORT.to_string()));
279 }
280
281 #[test]
282 fn with_env_restores_previous_value_and_survives_poisoning() {
283 const KEY: &str = "SSG_TEST_WITH_ENV_POISON";
284
285 // Poison the internal ENV_LOCK by panicking inside `f`.
286 let poisoned = std::panic::catch_unwind(|| {
287 with_env(KEY, Some("first"), || panic!("poison the env lock"));
288 });
289 assert!(poisoned.is_err(), "the panic must propagate");
290
291 // The panic skipped the restore, so KEY is still "first".
292 // This call must (a) recover from the poisoned lock and
293 // (b) restore the previous value afterwards.
294 with_env(KEY, Some("second"), || {
295 assert_eq!(std::env::var(KEY).ok().as_deref(), Some("second"));
296 });
297 assert_eq!(std::env::var(KEY).ok().as_deref(), Some("first"));
298
299 std::env::remove_var(KEY);
300 }
301
302 #[test]
303 fn reserved_names_are_lowercase_and_non_empty() {
304 assert!(!RESERVED_NAMES.is_empty());
305 for name in RESERVED_NAMES {
306 assert!(!name.is_empty(), "reserved name should be non-empty");
307 assert_eq!(
308 *name,
309 name.to_lowercase(),
310 "reserved name should be lowercase: {name}"
311 );
312 }
313 }
314
315 #[test]
316 fn max_config_size_is_one_megabyte() {
317 assert_eq!(MAX_CONFIG_SIZE, 1024 * 1024);
318 }
319}