Skip to main content

ssg/cmd/
config.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! SSG site configuration and builder.
5
6use super::error::CliError;
7use super::validation::{validate_path_safety, validate_url};
8use super::{default_config, MAX_CONFIG_SIZE};
9use clap::ArgMatches;
10use log::{debug, error, info};
11use serde::{Deserialize, Serialize};
12use std::{
13    fs,
14    path::{Path, PathBuf},
15    str::FromStr,
16};
17
18/// Core configuration for the static site generator.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SsgConfig {
21    /// Name of the site.
22    pub site_name: String,
23    /// Directory containing content files.
24    pub content_dir: PathBuf,
25    /// Directory for generated output files.
26    pub output_dir: PathBuf,
27    /// Directory containing template files.
28    pub template_dir: PathBuf,
29    /// Optional directory for development server files.
30    pub serve_dir: Option<PathBuf>,
31    /// Base URL of the site.
32    pub base_url: String,
33    /// Title of the site.
34    pub site_title: String,
35    /// Description of the site.
36    pub site_description: String,
37    /// Language code for the site.
38    pub language: String,
39    /// Optional i18n configuration for multi-locale sites.
40    #[serde(default)]
41    pub i18n: Option<crate::i18n::I18nConfig>,
42}
43
44impl Default for SsgConfig {
45    fn default() -> Self {
46        default_config().as_ref().clone()
47    }
48}
49
50impl SsgConfig {
51    /// Applies command-line arguments to override defaults.
52    fn override_with_cli(
53        mut self,
54        matches: &ArgMatches,
55    ) -> Result<Self, CliError> {
56        // If `-n/--new` was used
57        if let Some(site_name) = matches.get_one::<String>("new") {
58            self.site_name.clone_from(site_name);
59        }
60
61        // If `-c/--content` was used
62        if let Some(content_dir) = matches.get_one::<PathBuf>("content") {
63            self.content_dir.clone_from(content_dir);
64        }
65
66        // If `-o/--output` was used
67        if let Some(output_dir) = matches.get_one::<PathBuf>("output") {
68            self.output_dir.clone_from(output_dir);
69        }
70
71        // If `-t/--template` was used
72        if let Some(template_dir) = matches.get_one::<PathBuf>("template") {
73            self.template_dir.clone_from(template_dir);
74        }
75
76        // If `-s/--serve` was used
77        if let Some(serve_dir) = matches.get_one::<PathBuf>("serve") {
78            self.serve_dir = Some(serve_dir.clone());
79        }
80
81        // `--watch` flag is handled by the caller (run() in lib.rs)
82
83        // Re-validate after overriding
84        self.validate()?;
85        Ok(self)
86    }
87    /// Creates a configuration by merging the default values with any command-line arguments.
88    ///
89    /// # Arguments
90    /// * `matches` - Parsed command-line arguments from Clap.
91    ///
92    /// # Errors
93    /// Returns a [`CliError`] if:
94    /// - A path fails validation (e.g., directory traversal or symlink).
95    /// - A URL is malformed.
96    /// - The language is incorrectly formatted.
97    ///
98    /// # Examples
99    /// ```rust,ignore
100    /// let matches = cli.build().get_matches();
101    /// let config = SsgConfig::from_matches(&matches)?;
102    /// ```
103    pub fn from_matches(matches: &ArgMatches) -> Result<Self, CliError> {
104        if let Some(config_path) = matches.get_one::<PathBuf>("config") {
105            let loaded_config = Self::from_file(config_path)?;
106            return Ok(loaded_config);
107        }
108
109        // 1) Start with defaults
110        let config = Self::default();
111
112        // 2) Override them with CLI flags
113        let config = config.override_with_cli(matches)?;
114
115        // 3) Return the result
116        Ok(config)
117    }
118    /// Loads configuration from a TOML file, enforcing a maximum file size limit.
119    ///
120    /// # Arguments
121    /// * `path` - The path of the TOML file to be read.
122    ///
123    /// # Errors
124    /// Returns a [`CliError`] if:
125    /// - The file cannot be read or exceeds `MAX_CONFIG_SIZE`.
126    /// - The file is malformed TOML.
127    /// - Any fields fail validation afterward.
128    ///
129    /// # Examples
130    /// ```rust,ignore
131    /// let config = SsgConfig::from_file(Path::new("config.toml"))?;
132    /// ```
133    pub fn from_file(path: &Path) -> Result<Self, CliError> {
134        let metadata = fs::metadata(path)?;
135        if metadata.len() > MAX_CONFIG_SIZE as u64 {
136            return Err(CliError::ValidationError(format!(
137                "Config file too large (max {MAX_CONFIG_SIZE} bytes)"
138            )));
139        }
140
141        let content = fs::read_to_string(path)?;
142        let config: Self = toml::from_str(&content)?;
143        config.validate()?;
144        Ok(config)
145    }
146
147    /// Creates a new `SsgConfig` instance from a TOML file.
148    pub fn validate(&self) -> Result<(), CliError> {
149        debug!("Validating config: {self:?}");
150
151        if self.site_name.trim().is_empty() {
152            error!("site_name cannot be empty");
153            return Err(CliError::ValidationError(
154                "site_name cannot be empty".into(),
155            ));
156        }
157
158        if !self.base_url.is_empty() {
159            validate_url(&self.base_url)?;
160        }
161
162        validate_path_safety(&self.content_dir, "content_dir")?;
163        validate_path_safety(&self.output_dir, "output_dir")?;
164        validate_path_safety(&self.template_dir, "template_dir")?;
165        if let Some(ref serve_dir) = self.serve_dir {
166            validate_path_safety(serve_dir, "serve_dir")?;
167        }
168
169        info!("Config validation successful");
170        Ok(())
171    }
172
173    /// Creates a new `SsgConfig` instance from a TOML file.
174    #[must_use]
175    pub fn builder() -> SsgConfigBuilder {
176        SsgConfigBuilder::default()
177    }
178}
179
180impl FromStr for SsgConfig {
181    type Err = CliError;
182
183    fn from_str(s: &str) -> Result<Self, Self::Err> {
184        let config: Self = toml::from_str(s)?;
185        config.validate()?;
186        Ok(config)
187    }
188}
189
190/// Builder for `SsgConfig`.
191#[derive(Debug, Clone, Default)]
192pub struct SsgConfigBuilder {
193    config: SsgConfig,
194}
195
196/// # Examples
197/// ```
198/// use ssg::cmd::SsgConfig;
199/// let config = SsgConfig::builder()
200///     .site_name("My Site".to_string())
201///     .base_url("http://example.com".to_string())
202///     .build()
203///     .unwrap();
204/// ```
205impl SsgConfigBuilder {
206    /// Sets the site name for the configuration.
207    #[must_use]
208    pub fn site_name(mut self, name: String) -> Self {
209        self.config.site_name = name;
210        self
211    }
212    /// Sets the base URL for the configuration.
213    #[must_use]
214    pub fn base_url(mut self, url: String) -> Self {
215        self.config.base_url = url;
216        self
217    }
218    /// Sets the content directory for the configuration.
219    #[must_use]
220    pub fn content_dir(mut self, dir: PathBuf) -> Self {
221        self.config.content_dir = dir;
222        self
223    }
224    /// Sets the output directory for the configuration.
225    #[must_use]
226    pub fn output_dir(mut self, dir: PathBuf) -> Self {
227        self.config.output_dir = dir;
228        self
229    }
230    /// Sets the template directory for the configuration.
231    #[must_use]
232    pub fn template_dir(mut self, dir: PathBuf) -> Self {
233        self.config.template_dir = dir;
234        self
235    }
236    /// Sets the optional development server directory for the configuration.
237    #[must_use]
238    pub fn serve_dir(mut self, dir: Option<PathBuf>) -> Self {
239        self.config.serve_dir = dir;
240        self
241    }
242    /// Sets the site title for the configuration.
243    #[must_use]
244    pub fn site_title(mut self, title: String) -> Self {
245        self.config.site_title = title;
246        self
247    }
248    /// Sets the site description for the configuration.
249    #[must_use]
250    pub fn site_description(mut self, desc: String) -> Self {
251        self.config.site_description = desc;
252        self
253    }
254    /// Sets the language code for the configuration.
255    #[must_use]
256    pub fn language(mut self, lang: String) -> Self {
257        self.config.language = lang;
258        self
259    }
260    /// Sets the i18n configuration.
261    #[must_use]
262    pub fn i18n(mut self, i18n: Option<crate::i18n::I18nConfig>) -> Self {
263        self.config.i18n = i18n;
264        self
265    }
266    /// Builds the final `SsgConfig` instance.
267    pub fn build(self) -> Result<SsgConfig, CliError> {
268        self.config.validate()?;
269        Ok(self.config)
270    }
271}
272
273#[cfg(test)]
274#[allow(clippy::unwrap_used, clippy::expect_used)]
275mod tests {
276    use super::*;
277    use crate::cmd::Cli;
278    use std::fs::File;
279    use std::io::Write;
280    use tempfile::tempdir;
281
282    #[test]
283    fn test_config_validation() {
284        let config = SsgConfig::builder().site_name(String::new()).build();
285        assert!(matches!(config, Err(CliError::ValidationError(_))));
286    }
287
288    #[test]
289    fn test_config_file_size_limit() {
290        let temp_dir = tempdir().unwrap();
291        let config_path = temp_dir.path().join("large.toml");
292        let mut file = File::create(&config_path).unwrap();
293
294        write!(file, "{}", "x".repeat(MAX_CONFIG_SIZE + 1)).unwrap();
295
296        assert!(matches!(
297            SsgConfig::from_file(&config_path),
298            Err(CliError::ValidationError(_))
299        ));
300    }
301
302    #[test]
303    fn test_config_from_str() {
304        let config_str = r#"
305    site_name = "test"
306    content_dir = "./examples/content"
307    output_dir = "./examples/public"
308    template_dir = "./examples/templates"
309    base_url = "http://example.com"
310    site_title = "Test Site"
311    site_description = "Test Description"
312    language = "en-GB"
313    "#;
314
315        let config: Result<SsgConfig, _> = config_str.parse();
316        assert!(config.is_ok());
317    }
318
319    #[test]
320    fn test_config_builder_all_fields() {
321        let temp_dir = tempdir().unwrap();
322        let serve_dir = temp_dir.path().join("serve");
323
324        fs::create_dir_all(&serve_dir).unwrap();
325
326        let config = SsgConfig::builder()
327            .site_name("test".to_string())
328            .base_url("http://example.com".to_string())
329            .content_dir(PathBuf::from("./examples/content"))
330            .output_dir(PathBuf::from("./examples/public"))
331            .template_dir(PathBuf::from("./examples/templates"))
332            .serve_dir(Some(serve_dir))
333            .site_title("Test Site".to_string())
334            .site_description("Test Desc".to_string())
335            .language("en-GB".to_string())
336            .build();
337
338        assert!(config.is_ok());
339    }
340
341    #[test]
342    fn test_invalid_config_file() {
343        let temp_dir = tempdir().unwrap();
344        let config_path = temp_dir.path().join("invalid.toml");
345        let mut file = File::create(&config_path).unwrap();
346        write!(file, "invalid toml content").unwrap();
347
348        assert!(matches!(
349            SsgConfig::from_file(&config_path),
350            Err(CliError::TomlError(_))
351        ));
352    }
353
354    #[test]
355    fn test_from_matches() {
356        let matches = Cli::build().get_matches_from(vec!["ssg"]);
357        let config = SsgConfig::from_matches(&matches);
358        assert!(config.is_ok());
359    }
360
361    #[test]
362    fn test_config_builder_empty_required_fields() {
363        let config = SsgConfig::builder()
364            .site_name(String::new())
365            .site_title(String::new())
366            .build();
367        assert!(matches!(config, Err(CliError::ValidationError(_))));
368    }
369
370    #[test]
371    fn test_config_file_not_found() {
372        let non_existent = Path::new("non_existent.toml");
373        assert!(matches!(
374            SsgConfig::from_file(non_existent),
375            Err(CliError::IoError(_))
376        ));
377    }
378
379    #[test]
380    fn test_from_matches_with_config_file() {
381        let temp_dir = tempdir().unwrap();
382        let config_path = temp_dir.path().join("config.toml");
383        let config_content = r#"
384site_name = "from-file"
385content_dir = "./examples/content"
386output_dir = "./examples/public"
387template_dir = "./examples/templates"
388base_url = "http://example.com"
389site_title = "File Site"
390site_description = "From file"
391language = "en-GB"
392"#;
393        fs::write(&config_path, config_content).unwrap();
394
395        let cmd = Cli::build();
396        let matches = cmd.get_matches_from(vec![
397            "ssg",
398            "--config",
399            config_path.to_str().unwrap(),
400        ]);
401        let config = SsgConfig::from_matches(&matches).unwrap();
402        assert_eq!(config.site_name, "from-file");
403    }
404
405    #[test]
406    fn test_override_with_cli_all_flags() {
407        let cmd = Cli::build();
408        let matches = cmd.get_matches_from(vec![
409            "ssg",
410            "--new",
411            "cli-site",
412            "--content",
413            "./examples/content",
414            "--output",
415            "./examples/public",
416            "--template",
417            "./examples/templates",
418            "--serve",
419            "./examples/public",
420        ]);
421        let config = SsgConfig::from_matches(&matches).unwrap();
422        assert_eq!(config.site_name, "cli-site");
423        assert_eq!(config.content_dir, PathBuf::from("./examples/content"));
424        assert_eq!(config.output_dir, PathBuf::from("./examples/public"));
425        assert_eq!(config.template_dir, PathBuf::from("./examples/templates"));
426        assert!(config.serve_dir.is_some());
427    }
428
429    #[test]
430    fn test_override_with_watch_flag() {
431        let cmd = Cli::build();
432        let matches = cmd.get_matches_from(vec!["ssg", "--watch"]);
433        let config = SsgConfig::from_matches(&matches).unwrap();
434        assert!(!config.site_name.is_empty());
435    }
436
437    #[test]
438    fn test_validate_empty_url() {
439        let config = SsgConfig::builder()
440            .site_name("test".to_string())
441            .base_url(String::new())
442            .build();
443        assert!(config.is_ok());
444    }
445
446    // -----------------------------------------------------------------
447    // SsgConfig::from_file -- valid TOML
448    // -----------------------------------------------------------------
449
450    #[test]
451    fn test_config_from_file_valid_toml() {
452        let temp_dir = tempdir().unwrap();
453        let config_path = temp_dir.path().join("valid.toml");
454        let toml_content = r#"
455site_name = "TestSite"
456content_dir = "./examples/content"
457output_dir = "./examples/public"
458template_dir = "./examples/templates"
459base_url = "http://test.example.com"
460site_title = "Test Title"
461site_description = "A test site"
462language = "en-GB"
463"#;
464        fs::write(&config_path, toml_content).unwrap();
465
466        let config = SsgConfig::from_file(&config_path).unwrap();
467        assert_eq!(config.site_name, "TestSite");
468        assert_eq!(config.site_title, "Test Title");
469        assert_eq!(config.base_url, "http://test.example.com");
470    }
471}