Skip to main content

frontmatter_gen/
config.rs

1// Copyright © 2024 Shokunin Static Site Generator. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! # Configuration Module
5//!
6//! This module provides a robust and type-safe configuration system for the Static Site Generator.
7//! It handles validation, serialization, and secure management of all configuration settings.
8//!
9//! ## Features
10//!
11//! - Type-safe configuration management
12//! - Comprehensive validation of all settings
13//! - Secure path handling and normalization
14//! - Flexible Builder pattern for configuration creation
15//! - Serialization support via serde
16//! - Default values for optional settings
17//!
18//! ## Examples
19//!
20//! Basic usage (always available):
21//! ```rust
22//! use frontmatter_gen::config::Config;
23//!
24//! # fn main() -> anyhow::Result<()> {
25//! let config = Config::builder()
26//!     .site_name("My Blog")
27//!     .site_title("My Awesome Blog")
28//!     .build()?;
29//!
30//! assert_eq!(config.site_name(), "My Blog");
31//! # Ok(())
32//! # }
33//! ```
34//!
35//! With SSG features (requires "ssg" feature):
36//! ```rust,ignore
37//! use frontmatter_gen::config::Config;
38//!
39//! # fn main() -> anyhow::Result<()> {
40//! let config = Config::builder()
41//!     .site_name("My Blog")
42//!     .site_title("My Awesome Blog")
43//!     .content_dir("content")      // Requires "ssg" feature
44//!     .template_dir("templates")   // Requires "ssg" feature
45//!     .output_dir("public")        // Requires "ssg" feature
46//!     .build()?;
47//!
48//! assert_eq!(config.site_name(), "My Blog");
49//! # Ok(())
50//! # }
51//! ```
52//!
53//! To use SSG-specific functionality, enable the "ssg" feature in your Cargo.toml:
54//! ```toml
55//! [dependencies]
56//! frontmatter-gen = { version = "0.0.5", features = ["ssg"] }
57//! ```
58use std::fmt;
59#[cfg(feature = "ssg")]
60use std::path::{Path, PathBuf};
61
62#[cfg(feature = "ssg")]
63use anyhow::Context;
64use anyhow::Result;
65
66use serde::{Deserialize, Serialize};
67use thiserror::Error;
68#[cfg(feature = "ssg")]
69use url::Url;
70use uuid::Uuid;
71
72#[cfg(feature = "ssg")]
73use crate::utils::fs::validate_path_safety;
74
75/// Errors specific to configuration operations
76#[derive(Error, Debug)]
77pub enum Error {
78    /// Invalid site name provided
79    #[error("Invalid site name: {0}")]
80    InvalidSiteName(String),
81
82    /// Invalid directory path with detailed context
83    #[error("Invalid directory path '{path}': {details}")]
84    InvalidPath {
85        /// The path that was invalid
86        path: String,
87        /// Details about why the path was invalid
88        details: String,
89    },
90
91    /// Invalid URL format
92    #[cfg(feature = "ssg")]
93    #[error("Invalid URL format: {0}")]
94    InvalidUrl(String),
95
96    /// Invalid language code
97    #[cfg(feature = "ssg")]
98    #[error("Invalid language code '{0}': must be in format 'xx-XX'")]
99    InvalidLanguage(String),
100
101    /// Configuration file error
102    #[error("Configuration file error: {0}")]
103    FileError(#[from] std::io::Error),
104
105    /// TOML parsing error
106    #[error("TOML parsing error: {0}")]
107    TomlError(#[from] toml::de::Error),
108
109    /// Server configuration error
110    #[cfg(feature = "ssg")]
111    #[error("Server configuration error: {0}")]
112    ServerError(String),
113}
114
115/// Core configuration structure.
116///
117/// This structure defines the configuration options for the Static Site Generator.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(deny_unknown_fields)]
120pub struct Config {
121    /// Unique identifier for the configuration.
122    #[serde(default = "Uuid::new_v4")]
123    id: Uuid,
124
125    /// Name of the site.
126    pub site_name: String,
127
128    /// Title of the site, displayed in the browser's title bar.
129    #[serde(default = "default_site_title")]
130    pub site_title: String,
131
132    /// Description of the site.
133    #[cfg(feature = "ssg")]
134    #[serde(default = "default_site_description")]
135    pub site_description: String,
136
137    /// Language of the site (e.g., "en" for English).
138    #[cfg(feature = "ssg")]
139    #[serde(default = "default_language")]
140    pub language: String,
141
142    /// Base URL of the site.
143    #[cfg(feature = "ssg")]
144    #[serde(default = "default_base_url")]
145    pub base_url: String,
146
147    /// Path to the directory containing content files.
148    #[cfg(feature = "ssg")]
149    #[serde(default = "default_content_dir")]
150    pub content_dir: PathBuf,
151
152    /// Path to the directory where the generated output will be stored.
153    #[cfg(feature = "ssg")]
154    #[serde(default = "default_output_dir")]
155    pub output_dir: PathBuf,
156
157    /// Path to the directory containing templates.
158    #[cfg(feature = "ssg")]
159    #[serde(default = "default_template_dir")]
160    pub template_dir: PathBuf,
161
162    /// Optional directory to serve during development.
163    #[cfg(feature = "ssg")]
164    #[serde(default)]
165    pub serve_dir: Option<PathBuf>,
166
167    /// Flag to enable or disable the development server.
168    #[cfg(feature = "ssg")]
169    #[serde(default)]
170    pub server_enabled: bool,
171
172    /// Port for the development server.
173    #[cfg(feature = "ssg")]
174    #[serde(default = "default_port")]
175    pub server_port: u16,
176}
177
178// Default value functions for serde
179fn default_site_title() -> String {
180    "My Shokunin Site".to_string()
181}
182
183#[cfg(feature = "ssg")]
184fn default_site_description() -> String {
185    "A site built with Shokunin".to_string()
186}
187
188#[cfg(feature = "ssg")]
189fn default_language() -> String {
190    "en-GB".to_string()
191}
192
193#[cfg(feature = "ssg")]
194fn default_base_url() -> String {
195    "http://localhost:8000".to_string()
196}
197
198#[cfg(feature = "ssg")]
199fn default_content_dir() -> PathBuf {
200    PathBuf::from("content")
201}
202
203#[cfg(feature = "ssg")]
204fn default_output_dir() -> PathBuf {
205    PathBuf::from("public")
206}
207
208#[cfg(feature = "ssg")]
209fn default_template_dir() -> PathBuf {
210    PathBuf::from("templates")
211}
212
213#[cfg(feature = "ssg")]
214const fn default_port() -> u16 {
215    8000
216}
217
218impl fmt::Display for Config {
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        write!(f, "Site: {} ({})", self.site_name, self.site_title)?;
221
222        #[cfg(feature = "ssg")]
223        return write!(
224            f,
225            "\nContent: {}\nOutput: {}\nTemplates: {}",
226            self.content_dir.display(),
227            self.output_dir.display(),
228            self.template_dir.display()
229        );
230
231        #[cfg(not(feature = "ssg"))]
232        Ok(())
233    }
234}
235
236impl Config {
237    /// Creates a new `Builder` instance for fluent configuration creation
238    ///
239    /// # Examples
240    ///
241    /// Basic usage (always available):
242    /// ```rust
243    /// use frontmatter_gen::config::Config;
244    ///
245    /// let config = Config::builder()
246    ///     .site_name("My Site")
247    ///     .build()
248    ///     .unwrap();
249    /// ```
250    ///
251    /// With SSG features (requires "ssg" feature):
252    /// ```rust,ignore
253    /// use frontmatter_gen::config::Config;
254    ///
255    /// let config = Config::builder()
256    ///     .site_name("My Site")
257    ///     .content_dir("content")  // Only available with "ssg" feature
258    ///     .template_dir("templates")  // Only available with "ssg" feature
259    ///     .build()
260    ///     .unwrap();
261    /// ```
262    #[must_use]
263    pub fn builder() -> Builder {
264        Builder::default()
265    }
266
267    /// Loads configuration from a TOML file
268    ///
269    /// # Arguments
270    ///
271    /// * `path` - Path to the TOML configuration file
272    ///
273    /// # Returns
274    ///
275    /// Returns a Result containing the loaded Config or an error
276    ///
277    /// # Errors
278    ///
279    /// Will return an error if:
280    /// - File cannot be read
281    /// - TOML parsing fails
282    /// - Configuration validation fails
283    ///
284    /// # Examples
285    ///
286    /// ```no_run
287    /// use frontmatter_gen::config::Config;
288    /// use std::path::Path;
289    ///
290    /// let config = Config::from_file(Path::new("config.toml")).unwrap();
291    /// ```
292    #[cfg(feature = "ssg")]
293    pub fn from_file(path: &Path) -> Result<Self> {
294        let content =
295            std::fs::read_to_string(path).with_context(|| {
296                format!(
297                    "Failed to read config file: {}",
298                    path.display()
299                )
300            })?;
301
302        let mut config: Self = toml::from_str(&content)
303            .context("Failed to parse TOML configuration")?;
304
305        // Ensure we have a unique ID
306        config.id = Uuid::new_v4();
307
308        // Validate the loaded configuration
309        config.validate()?;
310
311        Ok(config)
312    }
313
314    /// Validates the configuration settings
315    ///
316    /// # Returns
317    ///
318    /// Returns Ok(()) if validation passes, or an error if validation fails
319    ///
320    /// # Errors
321    ///
322    /// Will return an error if:
323    /// - Required fields are empty
324    /// - Paths are invalid or unsafe
325    /// - URLs are malformed
326    /// - Language code format is invalid
327    pub fn validate(&self) -> Result<()> {
328        if self.site_name.trim().is_empty() {
329            return Err(Error::InvalidSiteName(
330                "Site name cannot be empty".to_string(),
331            )
332            .into());
333        }
334
335        #[cfg(feature = "ssg")]
336        {
337            // SSG-specific validation
338            self.validate_path(&self.content_dir, "content_dir")?;
339            self.validate_path(&self.output_dir, "output_dir")?;
340            self.validate_path(&self.template_dir, "template_dir")?;
341
342            if let Some(serve_dir) = &self.serve_dir {
343                self.validate_path(serve_dir, "serve_dir")?;
344            }
345
346            let _ = Url::parse(&self.base_url).map_err(|_| {
347                Error::InvalidUrl(self.base_url.clone())
348            })?;
349
350            if !self.is_valid_language_code(&self.language) {
351                return Err(Error::InvalidLanguage(
352                    self.language.clone(),
353                )
354                .into());
355            }
356
357            if self.server_enabled
358                && !Self::is_valid_port(self.server_port)
359            {
360                return Err(Error::ServerError(format!(
361                    "Invalid port number: {}",
362                    self.server_port
363                ))
364                .into());
365            }
366        }
367
368        Ok(())
369    }
370
371    /// Validates a path for safety and accessibility
372    #[cfg(feature = "ssg")]
373    #[allow(clippy::unused_self)]
374    fn validate_path(&self, path: &Path, name: &str) -> Result<()> {
375        validate_path_safety(path).with_context(|| {
376            format!("Invalid {} path: {}", name, path.display())
377        })
378    }
379
380    #[cfg(feature = "ssg")]
381    #[allow(clippy::unused_self)]
382    #[must_use]
383    fn is_valid_language_code(&self, code: &str) -> bool {
384        let parts: Vec<&str> = code.split('-').collect();
385        if let (Some(&lang), Some(&region)) =
386            (parts.first(), parts.get(1))
387        {
388            lang.len() == 2
389                && region.len() == 2
390                && lang.chars().all(|c| c.is_ascii_lowercase())
391                && region.chars().all(|c| c.is_ascii_uppercase())
392        } else {
393            false
394        }
395    }
396
397    /// Checks if a port number is valid
398    #[cfg(feature = "ssg")]
399    #[must_use]
400    const fn is_valid_port(port: u16) -> bool {
401        port >= 1024
402    }
403
404    /// Gets the unique identifier for this configuration
405    #[must_use]
406    pub const fn id(&self) -> Uuid {
407        self.id
408    }
409
410    /// Gets the site name
411    #[must_use]
412    pub fn site_name(&self) -> &str {
413        &self.site_name
414    }
415
416    /// Gets whether the development server is enabled
417    #[cfg(feature = "ssg")]
418    #[must_use]
419    pub const fn server_enabled(&self) -> bool {
420        self.server_enabled
421    }
422
423    /// Gets the server port if the server is enabled
424    #[cfg(feature = "ssg")]
425    #[must_use]
426    pub const fn server_port(&self) -> Option<u16> {
427        if self.server_enabled {
428            Some(self.server_port)
429        } else {
430            None
431        }
432    }
433}
434
435/// Builder for creating Config instances
436#[derive(Default, Debug)]
437pub struct Builder {
438    site_name: Option<String>,
439    site_title: Option<String>,
440    #[cfg(feature = "ssg")]
441    site_description: Option<String>,
442    #[cfg(feature = "ssg")]
443    language: Option<String>,
444    #[cfg(feature = "ssg")]
445    base_url: Option<String>,
446    #[cfg(feature = "ssg")]
447    content_dir: Option<PathBuf>,
448    #[cfg(feature = "ssg")]
449    output_dir: Option<PathBuf>,
450    #[cfg(feature = "ssg")]
451    template_dir: Option<PathBuf>,
452    #[cfg(feature = "ssg")]
453    serve_dir: Option<PathBuf>,
454    #[cfg(feature = "ssg")]
455    server_enabled: bool,
456    #[cfg(feature = "ssg")]
457    server_port: Option<u16>,
458}
459
460impl Builder {
461    // Core builder methods
462    /// Sets the site name
463    #[must_use]
464    pub fn site_name<S: Into<String>>(mut self, name: S) -> Self {
465        self.site_name = Some(name.into());
466        self
467    }
468
469    /// Sets the site title
470    #[must_use]
471    pub fn site_title<S: Into<String>>(mut self, title: S) -> Self {
472        self.site_title = Some(title.into());
473        self
474    }
475
476    // SSG-specific builder methods
477    #[cfg(feature = "ssg")]
478    #[must_use]
479    /// Sets the site description
480    pub fn site_description<S: Into<String>>(
481        mut self,
482        desc: S,
483    ) -> Self {
484        self.site_description = Some(desc.into());
485        self
486    }
487
488    /// Sets the language code
489    #[cfg(feature = "ssg")]
490    #[must_use]
491    pub fn language<S: Into<String>>(mut self, lang: S) -> Self {
492        self.language = Some(lang.into());
493        self
494    }
495
496    /// Sets the base URL
497    #[cfg(feature = "ssg")]
498    #[must_use]
499    pub fn base_url<S: Into<String>>(mut self, url: S) -> Self {
500        self.base_url = Some(url.into());
501        self
502    }
503
504    /// Sets the content directory
505    #[cfg(feature = "ssg")]
506    #[must_use]
507    pub fn content_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
508        self.content_dir = Some(path.into());
509        self
510    }
511
512    /// Sets the output directory
513    #[cfg(feature = "ssg")]
514    #[must_use]
515    pub fn output_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
516        self.output_dir = Some(path.into());
517        self
518    }
519
520    /// Sets the template directory
521    #[cfg(feature = "ssg")]
522    #[must_use]
523    pub fn template_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
524        self.template_dir = Some(path.into());
525        self
526    }
527
528    /// Sets the serve directory
529    #[cfg(feature = "ssg")]
530    #[must_use]
531    pub fn serve_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
532        self.serve_dir = Some(path.into());
533        self
534    }
535
536    /// Enables or disables the development server
537    #[cfg(feature = "ssg")]
538    #[must_use]
539    pub const fn server_enabled(mut self, enabled: bool) -> Self {
540        self.server_enabled = enabled;
541        self
542    }
543
544    /// Sets the server port
545    #[cfg(feature = "ssg")]
546    #[must_use]
547    pub const fn server_port(mut self, port: u16) -> Self {
548        self.server_port = Some(port);
549        self
550    }
551
552    /// Builds the Config instance
553    ///
554    /// # Returns
555    ///
556    /// Returns a Result containing the built Config or an error
557    ///
558    /// # Errors
559    ///
560    /// Will return an error if:
561    /// - Required fields are missing
562    /// - Validation fails
563    pub fn build(self) -> Result<Config> {
564        let config = Config {
565            id: Uuid::new_v4(),
566            site_name: self.site_name.unwrap_or_default(),
567            site_title: self
568                .site_title
569                .unwrap_or_else(default_site_title),
570            #[cfg(feature = "ssg")]
571            site_description: self
572                .site_description
573                .unwrap_or_else(default_site_description),
574            #[cfg(feature = "ssg")]
575            language: self.language.unwrap_or_else(default_language),
576            #[cfg(feature = "ssg")]
577            base_url: self.base_url.unwrap_or_else(default_base_url),
578            #[cfg(feature = "ssg")]
579            content_dir: self
580                .content_dir
581                .unwrap_or_else(default_content_dir),
582            #[cfg(feature = "ssg")]
583            output_dir: self
584                .output_dir
585                .unwrap_or_else(default_output_dir),
586            #[cfg(feature = "ssg")]
587            template_dir: self
588                .template_dir
589                .unwrap_or_else(default_template_dir),
590            #[cfg(feature = "ssg")]
591            serve_dir: self.serve_dir,
592            #[cfg(feature = "ssg")]
593            server_enabled: self.server_enabled,
594            #[cfg(feature = "ssg")]
595            server_port: self.server_port.unwrap_or_else(default_port),
596        };
597
598        config.validate()?;
599        Ok(config)
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    #[cfg(feature = "ssg")]
607    use tempfile::tempdir;
608
609    /// Tests for default value functions
610    mod default_values_tests {
611        use super::*;
612
613        #[test]
614        fn test_default_site_title() {
615            assert_eq!(default_site_title(), "My Shokunin Site");
616        }
617    }
618
619    // SSG-specific tests
620    #[cfg(feature = "ssg")]
621    mod ssg_tests {
622        use crate::config::default_base_url;
623        use crate::config::default_content_dir;
624        use crate::config::default_language;
625        use crate::config::default_output_dir;
626        use crate::config::default_site_description;
627        use crate::config::default_template_dir;
628        use crate::config::PathBuf;
629        #[test]
630        fn test_default_site_description() {
631            assert_eq!(
632                default_site_description(),
633                "A site built with Shokunin"
634            );
635        }
636
637        #[test]
638        fn test_default_language() {
639            assert_eq!(default_language(), "en-GB");
640        }
641
642        #[test]
643        fn test_default_base_url() {
644            assert_eq!(default_base_url(), "http://localhost:8000");
645        }
646
647        #[test]
648        fn test_default_content_dir() {
649            assert_eq!(default_content_dir(), PathBuf::from("content"));
650        }
651
652        #[test]
653        fn test_default_output_dir() {
654            assert_eq!(default_output_dir(), PathBuf::from("public"));
655        }
656
657        #[test]
658        fn test_default_template_dir() {
659            assert_eq!(
660                default_template_dir(),
661                PathBuf::from("templates")
662            );
663        }
664    }
665
666    /// Tests for the `Builder` functionality
667    mod builder_tests {
668        use super::*;
669
670        #[test]
671        fn test_builder_initialization() {
672            let builder = Config::builder();
673            assert_eq!(builder.site_name, None);
674            assert_eq!(builder.site_title, None);
675            #[cfg(feature = "ssg")]
676            assert_eq!(builder.site_description, None);
677            #[cfg(feature = "ssg")]
678            assert_eq!(builder.language, None);
679            #[cfg(feature = "ssg")]
680            assert_eq!(builder.base_url, None);
681            #[cfg(feature = "ssg")]
682            assert_eq!(builder.content_dir, None);
683            #[cfg(feature = "ssg")]
684            assert_eq!(builder.output_dir, None);
685            #[cfg(feature = "ssg")]
686            assert_eq!(builder.template_dir, None);
687            #[cfg(feature = "ssg")]
688            assert_eq!(builder.serve_dir, None);
689            #[cfg(feature = "ssg")]
690            assert!(!builder.server_enabled);
691            #[cfg(feature = "ssg")]
692            assert_eq!(builder.server_port, None);
693        }
694
695        #[test]
696        fn test_builder_defaults_applied() {
697            let config = Config::builder()
698                .site_name("Test Site")
699                .build()
700                .unwrap();
701
702            assert_eq!(config.site_title, default_site_title());
703            #[cfg(feature = "ssg")]
704            assert_eq!(
705                config.site_description,
706                default_site_description()
707            );
708            #[cfg(feature = "ssg")]
709            assert_eq!(config.language, default_language());
710            #[cfg(feature = "ssg")]
711            assert_eq!(config.base_url, default_base_url());
712            #[cfg(feature = "ssg")]
713            assert_eq!(config.content_dir, default_content_dir());
714            #[cfg(feature = "ssg")]
715            assert_eq!(config.output_dir, default_output_dir());
716            #[cfg(feature = "ssg")]
717            assert_eq!(config.template_dir, default_template_dir());
718            #[cfg(feature = "ssg")]
719            assert_eq!(config.server_port, default_port());
720            #[cfg(feature = "ssg")]
721            assert!(!config.server_enabled);
722            #[cfg(feature = "ssg")]
723            assert!(config.serve_dir.is_none());
724        }
725
726        #[test]
727        fn test_builder_missing_site_name() {
728            let result = Config::builder().build();
729            assert!(
730                result.is_err(),
731                "Builder should fail without site_name"
732            );
733        }
734
735        #[test]
736        fn test_builder_empty_values() {
737            let result =
738                Config::builder().site_name("").site_title("").build();
739            assert!(
740                result.is_err(),
741                "Empty values should fail validation"
742            );
743        }
744
745        #[test]
746        fn test_unique_id_generation() -> Result<()> {
747            let config1 =
748                Config::builder().site_name("Site 1").build()?;
749            let config2 =
750                Config::builder().site_name("Site 2").build()?;
751            assert_ne!(
752                config1.id(),
753                config2.id(),
754                "IDs should be unique"
755            );
756            Ok(())
757        }
758
759        #[test]
760        fn test_builder_long_values() {
761            let long_string = "a".repeat(256);
762            let result = Config::builder()
763                .site_name(&long_string)
764                .site_title(&long_string)
765                .build();
766            assert!(
767                result.is_ok(),
768                "Long values should not cause validation errors"
769            );
770        }
771    }
772
773    /// Tests for configuration validation
774    mod validation_tests {
775        use super::*;
776
777        #[test]
778        fn test_empty_site_name() {
779            let result = Config::builder().site_name("").build();
780            assert!(
781                result.is_err(),
782                "Empty site name should fail validation"
783            );
784        }
785
786        #[cfg(feature = "ssg")]
787        #[test]
788        fn test_empty_site_name_ssg() {
789            let result = Config::builder()
790                .site_name("")
791                .content_dir("content")
792                .build();
793            assert!(
794                result.is_err(),
795                "Empty site name should fail validation"
796            );
797        }
798
799        #[cfg(feature = "ssg")]
800        #[test]
801        fn test_invalid_url_format() {
802            let invalid_urls = vec![
803                "not-a-url",
804                "http://",
805                "://invalid",
806                "http//missing-colon",
807            ];
808            for url in invalid_urls {
809                let result = Config::builder()
810                    .site_name("Test Site")
811                    .base_url(url)
812                    .build();
813                assert!(
814                    result.is_err(),
815                    "URL '{}' should fail validation",
816                    url
817                );
818            }
819        }
820
821        #[cfg(feature = "ssg")]
822        #[test]
823        fn test_validate_path_safety_mocked() {
824            let path = PathBuf::from("valid/path");
825            let result = Config::builder()
826                .site_name("Test Site")
827                .content_dir(path)
828                .build();
829            assert!(
830                result.is_ok(),
831                "Valid path should pass validation"
832            );
833        }
834    }
835
836    /// Tests for `Error` variants
837    mod config_error_tests {
838        use super::*;
839
840        #[test]
841        fn test_config_error_display() {
842            let error =
843                Error::InvalidSiteName("Empty name".to_string());
844            assert_eq!(
845                format!("{}", error),
846                "Invalid site name: Empty name"
847            );
848        }
849
850        #[test]
851        fn test_invalid_path_error() {
852            let error = Error::InvalidPath {
853                path: "invalid/path".to_string(),
854                details: "Unsafe path detected".to_string(),
855            };
856            assert_eq!(
857                format!("{}", error),
858                "Invalid directory path 'invalid/path': Unsafe path detected"
859            );
860        }
861
862        #[test]
863        fn test_file_error_conversion() {
864            let io_error = std::io::Error::new(
865                std::io::ErrorKind::NotFound,
866                "File not found",
867            );
868            let error: Error = io_error.into();
869            assert_eq!(
870                format!("{}", error),
871                "Configuration file error: File not found"
872            );
873        }
874    }
875
876    /// Tests for helper methods
877    mod helper_method_tests {
878        #[cfg(feature = "ssg")]
879        use super::*;
880
881        #[cfg(feature = "ssg")]
882        #[test]
883        fn test_is_valid_language_code() {
884            let config =
885                Config::builder().site_name("Test").build().unwrap();
886            assert!(config.is_valid_language_code("en-US"));
887            assert!(!config.is_valid_language_code("invalid-code"));
888        }
889
890        #[cfg(feature = "ssg")]
891        #[test]
892        fn test_is_valid_port() {
893            assert!(Config::is_valid_port(1024));
894            assert!(!Config::is_valid_port(1023));
895        }
896    }
897
898    /// Tests for serialization and deserialization
899    mod serialization_tests {
900        use super::*;
901
902        #[test]
903        fn test_serialization_roundtrip() -> Result<()> {
904            let original = Config::builder()
905                .site_name("Test Site")
906                .site_title("Roundtrip Test")
907                .build()?;
908
909            let serialized = toml::to_string(&original)?;
910            let deserialized: Config = toml::from_str(&serialized)?;
911
912            assert_eq!(original.site_name, deserialized.site_name);
913            assert_eq!(original.site_title, deserialized.site_title);
914            assert_eq!(original.id(), deserialized.id());
915            Ok(())
916        }
917    }
918
919    /// Tests for file operations
920    mod file_tests {
921        #[cfg(feature = "ssg")]
922        use super::*;
923
924        #[cfg(feature = "ssg")]
925        #[test]
926        fn test_missing_config_file() {
927            let result =
928                Config::from_file(Path::new("nonexistent.toml"));
929            assert!(
930                result.is_err(),
931                "Missing file should fail to load"
932            );
933        }
934
935        #[cfg(feature = "ssg")]
936        #[test]
937        fn test_invalid_toml_file() -> Result<()> {
938            let dir = tempdir()?;
939            let config_path = dir.path().join("invalid_config.toml");
940
941            std::fs::write(&config_path, "invalid_toml_syntax")?;
942
943            let result = Config::from_file(&config_path);
944            assert!(result.is_err(), "Invalid TOML syntax should fail");
945            Ok(())
946        }
947    }
948
949    /// Miscellaneous utility tests
950    mod utility_tests {
951        use super::*;
952
953        #[cfg(feature = "ssg")]
954        #[test]
955        fn test_config_display_format() {
956            let config = Config::builder()
957                .site_name("Test Site")
958                .site_title("Display Title")
959                .content_dir("test_content")
960                .output_dir("test_output")
961                .template_dir("test_templates")
962                .build()
963                .unwrap();
964
965            let display = format!("{}", config);
966            assert!(display.contains("Site: Test Site (Display Title)"));
967            assert!(display.contains("Content: test_content"));
968            assert!(display.contains("Output: test_output"));
969            assert!(display.contains("Templates: test_templates"));
970        }
971
972        #[test]
973        fn test_clone_retains_all_fields() -> Result<()> {
974            let original = Config::builder()
975                .site_name("Original")
976                .site_title("Clone Test")
977                .build()?;
978
979            let cloned = original.clone();
980
981            assert_eq!(original.site_name, cloned.site_name);
982            assert_eq!(original.site_title, cloned.site_title);
983            assert_eq!(original.id(), cloned.id());
984            Ok(())
985        }
986
987        #[cfg(feature = "ssg")]
988        #[test]
989        fn test_is_valid_language_code_safe() {
990            let config =
991                Config::builder().site_name("Test").build().unwrap();
992
993            assert!(config.is_valid_language_code("en-US"));
994            assert!(config.is_valid_language_code("fr-FR"));
995            assert!(!config.is_valid_language_code("invalid-code"));
996            assert!(!config.is_valid_language_code("en"));
997            assert!(!config.is_valid_language_code(""));
998            assert!(!config.is_valid_language_code("e-US"));
999            assert!(!config.is_valid_language_code("en-Us"));
1000        }
1001    }
1002}
1003
1004#[cfg(all(test, feature = "ssg"))]
1005mod exhaustive_config_tests {
1006    //! Every builder setter and every rejection in `validate`.
1007    //!
1008    //! Configuration validation is where a typo becomes a broken site
1009    //! rather than an error, so each rule gets a case that violates it
1010    //! and one that satisfies it.
1011
1012    use super::*;
1013
1014    fn valid_builder() -> Builder {
1015        Config::builder()
1016            .site_name("site")
1017            .site_title("title")
1018            .site_description("description")
1019            .language("en-GB")
1020            .base_url("https://example.com")
1021            .content_dir("content")
1022            .output_dir("public")
1023            .template_dir("templates")
1024    }
1025
1026    #[test]
1027    fn every_builder_setter_reaches_the_config() {
1028        let config = valid_builder()
1029            .serve_dir("serve")
1030            .build()
1031            .expect("valid configuration");
1032
1033        assert_eq!(config.site_name, "site");
1034        assert_eq!(config.site_title, "title");
1035        assert_eq!(config.site_description, "description");
1036        assert_eq!(config.language, "en-GB");
1037        assert_eq!(config.base_url, "https://example.com");
1038        assert_eq!(config.content_dir, PathBuf::from("content"));
1039        assert_eq!(config.output_dir, PathBuf::from("public"));
1040        assert_eq!(config.template_dir, PathBuf::from("templates"));
1041        assert_eq!(config.serve_dir, Some(PathBuf::from("serve")));
1042    }
1043
1044    #[test]
1045    fn serve_dir_is_optional() {
1046        let config =
1047            valid_builder().build().expect("valid configuration");
1048        assert!(config.serve_dir.is_none());
1049    }
1050
1051    #[test]
1052    fn rejects_a_malformed_base_url() {
1053        let err = valid_builder()
1054            .base_url("not a url")
1055            .build()
1056            .expect_err("a base URL that does not parse is rejected");
1057        assert!(
1058            err.to_string().to_lowercase().contains("url"),
1059            "{err}"
1060        );
1061    }
1062
1063    #[test]
1064    fn language_must_be_lowercase_lang_uppercase_region() {
1065        for good in ["en-GB", "fr-FR", "pt-BR"] {
1066            assert!(
1067                valid_builder().language(good).build().is_ok(),
1068                "{good} should be accepted"
1069            );
1070        }
1071        for bad in ["en", "EN-gb", "en_GB", "eng-GBR", "", "en-gb"] {
1072            assert!(
1073                valid_builder().language(bad).build().is_err(),
1074                "{bad} should be rejected"
1075            );
1076        }
1077    }
1078
1079    #[test]
1080    fn rejects_unsafe_directory_paths() {
1081        assert!(valid_builder()
1082            .content_dir("../escape")
1083            .build()
1084            .is_err());
1085        assert!(valid_builder()
1086            .output_dir("out\\win")
1087            .build()
1088            .is_err());
1089        assert!(valid_builder()
1090            .template_dir("../../up")
1091            .build()
1092            .is_err());
1093        assert!(valid_builder().serve_dir("../serve").build().is_err());
1094    }
1095
1096    #[test]
1097    fn accessors_expose_the_configured_values() {
1098        let config =
1099            valid_builder().build().expect("valid configuration");
1100        assert_eq!(config.site_name(), "site");
1101        // Every config gets its own identifier.
1102        let other =
1103            valid_builder().build().expect("valid configuration");
1104        assert_ne!(config.id(), other.id());
1105    }
1106
1107    #[test]
1108    fn the_server_port_is_reported_only_when_the_server_is_enabled() {
1109        let off = valid_builder().build().expect("valid configuration");
1110        assert!(!off.server_enabled());
1111        assert_eq!(off.server_port(), None, "disabled means no port");
1112
1113        let on = valid_builder()
1114            .server_enabled(true)
1115            .server_port(8080)
1116            .build()
1117            .expect("valid configuration");
1118        assert!(on.server_enabled());
1119        assert_eq!(on.server_port(), Some(8080));
1120    }
1121
1122    #[test]
1123    fn a_privileged_port_is_rejected_when_the_server_is_enabled() {
1124        // Ports below 1024 need elevated privileges; the check only
1125        // applies when the server is actually turned on.
1126        assert!(valid_builder()
1127            .server_enabled(true)
1128            .server_port(80)
1129            .build()
1130            .is_err());
1131        assert!(valid_builder()
1132            .server_enabled(false)
1133            .server_port(80)
1134            .build()
1135            .is_ok());
1136    }
1137
1138    #[test]
1139    fn display_names_the_site_and_its_directories() {
1140        let config =
1141            valid_builder().build().expect("valid configuration");
1142        let shown = config.to_string();
1143        assert!(shown.contains("site"), "{shown}");
1144    }
1145
1146    #[test]
1147    fn from_file_reads_a_toml_configuration() {
1148        let dir = tempfile::tempdir().expect("tempdir");
1149        let path = dir.path().join("frontmatter.toml");
1150        std::fs::write(
1151            &path,
1152            br#"
1153site_name = "from file"
1154site_title = "title"
1155site_description = "description"
1156language = "en-GB"
1157base_url = "https://example.com"
1158content_dir = "content"
1159output_dir = "public"
1160template_dir = "templates"
1161"#,
1162        )
1163        .expect("write config");
1164
1165        let config = Config::from_file(&path).expect("parse config");
1166        assert_eq!(config.site_name, "from file");
1167    }
1168
1169    #[test]
1170    fn from_file_reports_a_missing_or_malformed_file() {
1171        assert!(Config::from_file(Path::new("does-not-exist.toml"))
1172            .is_err());
1173
1174        let dir = tempfile::tempdir().expect("tempdir");
1175        let path = dir.path().join("broken.toml");
1176        std::fs::write(&path, b"site_name = ").expect("write");
1177        assert!(Config::from_file(&path).is_err());
1178    }
1179}