Skip to main content

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
34mod 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
47/// Default port for the local development server.
48pub const DEFAULT_PORT: u16 = 8000;
49/// Default host for the local development server.
50///
51/// Loopback by default. WSL2 users whose Windows host can't reach the
52/// distro on `127.0.0.1` (and Codespaces / dev-containers users binding
53/// outside their network namespace) should set `SSG_HOST=0.0.0.0` and
54/// let [`resolve_host`] pick it up. The same applies to `SSG_PORT`.
55pub const DEFAULT_HOST: &str = "127.0.0.1";
56
57/// Resolve the dev-server host, preferring `$SSG_HOST` over [`DEFAULT_HOST`].
58///
59/// Returns the value of the `SSG_HOST` environment variable if set and
60/// non-empty; otherwise returns the compiled-in default.
61#[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/// Resolve the dev-server port, preferring `$SSG_PORT` over [`DEFAULT_PORT`].
70#[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
78/// Reserved names that cannot be used as paths on Windows systems.
79pub const RESERVED_NAMES: &[&str] =
80    &["con", "aux", "nul", "prn", "com1", "lpt1"];
81/// Maximum allowed size in bytes for config files.
82pub const MAX_CONFIG_SIZE: usize = 1024 * 1024; // 1MB limit
83
84/// Default site name for the configuration.
85pub const DEFAULT_SITE_NAME: &str = "MySsgSite";
86/// Default site title for the configuration.
87pub const DEFAULT_SITE_TITLE: &str = "My SSG Site";
88
89/// A static default configuration for the SSG site.
90pub static DEFAULT_CONFIG: OnceLock<Arc<SsgConfig>> = OnceLock::new();
91
92/// Returns a reference to the lazily-initialised default configuration.
93pub 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
110/// Const validation for compile-time checks.
111const _: () = {
112    assert!(MAX_CONFIG_SIZE > 0);
113    assert!(MAX_CONFIG_SIZE <= 10 * 1024 * 1024); // Max 10MB
114};
115
116#[cfg(test)]
117#[allow(clippy::unwrap_used, clippy::expect_used)]
118mod tests {
119    use super::*;
120
121    /// Mutex-protected env-var setter so concurrent tests don't race.
122    /// `cargo test` runs tests in parallel by default; without serialisation
123    /// the env-var assertions below would interleave nondeterministically.
124    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        // Empty string should fall through to the default — matters for
157        // shells that export `SSG_HOST=` to "unset" without `unset`.
158        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        // Same Arc pointer — confirms OnceLock is being reused.
189        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}