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        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        Ok(())
232    }
233}
234
235impl Config {
236    /// Creates a new `Builder` instance for fluent configuration creation
237    ///
238    /// # Examples
239    ///
240    /// Basic usage (always available):
241    /// ```rust
242    /// use frontmatter_gen::config::Config;
243    ///
244    /// let config = Config::builder()
245    ///     .site_name("My Site")
246    ///     .build()
247    ///     .unwrap();
248    /// ```
249    ///
250    /// With SSG features (requires "ssg" feature):
251    /// ```rust,ignore
252    /// use frontmatter_gen::config::Config;
253    ///
254    /// let config = Config::builder()
255    ///     .site_name("My Site")
256    ///     .content_dir("content")  // Only available with "ssg" feature
257    ///     .template_dir("templates")  // Only available with "ssg" feature
258    ///     .build()
259    ///     .unwrap();
260    /// ```
261    #[must_use]
262    pub fn builder() -> Builder {
263        Builder::default()
264    }
265
266    /// Loads configuration from a TOML file
267    ///
268    /// # Arguments
269    ///
270    /// * `path` - Path to the TOML configuration file
271    ///
272    /// # Returns
273    ///
274    /// Returns a Result containing the loaded Config or an error
275    ///
276    /// # Errors
277    ///
278    /// Will return an error if:
279    /// - File cannot be read
280    /// - TOML parsing fails
281    /// - Configuration validation fails
282    ///
283    /// # Examples
284    ///
285    /// ```no_run
286    /// use frontmatter_gen::config::Config;
287    /// use std::path::Path;
288    ///
289    /// let config = Config::from_file(Path::new("config.toml")).unwrap();
290    /// ```
291    #[cfg(feature = "ssg")]
292    pub fn from_file(path: &Path) -> Result<Self> {
293        let content =
294            std::fs::read_to_string(path).with_context(|| {
295                format!(
296                    "Failed to read config file: {}",
297                    path.display()
298                )
299            })?;
300
301        let mut config: Self = toml::from_str(&content)
302            .context("Failed to parse TOML configuration")?;
303
304        // Ensure we have a unique ID
305        config.id = Uuid::new_v4();
306
307        // Validate the loaded configuration
308        config.validate()?;
309
310        Ok(config)
311    }
312
313    /// Validates the configuration settings
314    ///
315    /// # Returns
316    ///
317    /// Returns Ok(()) if validation passes, or an error if validation fails
318    ///
319    /// # Errors
320    ///
321    /// Will return an error if:
322    /// - Required fields are empty
323    /// - Paths are invalid or unsafe
324    /// - URLs are malformed
325    /// - Language code format is invalid
326    pub fn validate(&self) -> Result<()> {
327        if self.site_name.trim().is_empty() {
328            return Err(Error::InvalidSiteName(
329                "Site name cannot be empty".to_string(),
330            )
331            .into());
332        }
333
334        #[cfg(feature = "ssg")]
335        {
336            // SSG-specific validation
337            self.validate_path(&self.content_dir, "content_dir")?;
338            self.validate_path(&self.output_dir, "output_dir")?;
339            self.validate_path(&self.template_dir, "template_dir")?;
340
341            if let Some(serve_dir) = &self.serve_dir {
342                self.validate_path(serve_dir, "serve_dir")?;
343            }
344
345            let _ = Url::parse(&self.base_url).map_err(|_| {
346                Error::InvalidUrl(self.base_url.clone())
347            })?;
348
349            if !self.is_valid_language_code(&self.language) {
350                return Err(Error::InvalidLanguage(
351                    self.language.clone(),
352                )
353                .into());
354            }
355
356            if self.server_enabled
357                && !Self::is_valid_port(self.server_port)
358            {
359                return Err(Error::ServerError(format!(
360                    "Invalid port number: {}",
361                    self.server_port
362                ))
363                .into());
364            }
365        }
366
367        Ok(())
368    }
369
370    /// Validates a path for safety and accessibility
371    #[cfg(feature = "ssg")]
372    #[allow(clippy::unused_self)]
373    fn validate_path(&self, path: &Path, name: &str) -> Result<()> {
374        validate_path_safety(path).with_context(|| {
375            format!("Invalid {} path: {}", name, path.display())
376        })
377    }
378
379    #[cfg(feature = "ssg")]
380    #[allow(clippy::unused_self)]
381    #[must_use]
382    fn is_valid_language_code(&self, code: &str) -> bool {
383        let parts: Vec<&str> = code.split('-').collect();
384        if let (Some(&lang), Some(&region)) =
385            (parts.first(), parts.get(1))
386        {
387            lang.len() == 2
388                && region.len() == 2
389                && lang.chars().all(|c| c.is_ascii_lowercase())
390                && region.chars().all(|c| c.is_ascii_uppercase())
391        } else {
392            false
393        }
394    }
395
396    /// Checks if a port number is valid
397    #[cfg(feature = "ssg")]
398    #[must_use]
399    const fn is_valid_port(port: u16) -> bool {
400        port >= 1024
401    }
402
403    /// Gets the unique identifier for this configuration
404    #[must_use]
405    pub const fn id(&self) -> Uuid {
406        self.id
407    }
408
409    /// Gets the site name
410    #[must_use]
411    pub fn site_name(&self) -> &str {
412        &self.site_name
413    }
414
415    /// Gets whether the development server is enabled
416    #[cfg(feature = "ssg")]
417    #[must_use]
418    pub const fn server_enabled(&self) -> bool {
419        self.server_enabled
420    }
421
422    /// Gets the server port if the server is enabled
423    #[cfg(feature = "ssg")]
424    #[must_use]
425    pub const fn server_port(&self) -> Option<u16> {
426        if self.server_enabled {
427            Some(self.server_port)
428        } else {
429            None
430        }
431    }
432}
433
434/// Builder for creating Config instances
435#[derive(Default, Debug)]
436pub struct Builder {
437    site_name: Option<String>,
438    site_title: Option<String>,
439    #[cfg(feature = "ssg")]
440    site_description: Option<String>,
441    #[cfg(feature = "ssg")]
442    language: Option<String>,
443    #[cfg(feature = "ssg")]
444    base_url: Option<String>,
445    #[cfg(feature = "ssg")]
446    content_dir: Option<PathBuf>,
447    #[cfg(feature = "ssg")]
448    output_dir: Option<PathBuf>,
449    #[cfg(feature = "ssg")]
450    template_dir: Option<PathBuf>,
451    #[cfg(feature = "ssg")]
452    serve_dir: Option<PathBuf>,
453    #[cfg(feature = "ssg")]
454    server_enabled: bool,
455    #[cfg(feature = "ssg")]
456    server_port: Option<u16>,
457}
458
459impl Builder {
460    // Core builder methods
461    /// Sets the site name
462    #[must_use]
463    pub fn site_name<S: Into<String>>(mut self, name: S) -> Self {
464        self.site_name = Some(name.into());
465        self
466    }
467
468    /// Sets the site title
469    #[must_use]
470    pub fn site_title<S: Into<String>>(mut self, title: S) -> Self {
471        self.site_title = Some(title.into());
472        self
473    }
474
475    // SSG-specific builder methods
476    #[cfg(feature = "ssg")]
477    #[must_use]
478    /// Sets the site description
479    pub fn site_description<S: Into<String>>(
480        mut self,
481        desc: S,
482    ) -> Self {
483        self.site_description = Some(desc.into());
484        self
485    }
486
487    /// Sets the language code
488    #[cfg(feature = "ssg")]
489    #[must_use]
490    pub fn language<S: Into<String>>(mut self, lang: S) -> Self {
491        self.language = Some(lang.into());
492        self
493    }
494
495    /// Sets the base URL
496    #[cfg(feature = "ssg")]
497    #[must_use]
498    pub fn base_url<S: Into<String>>(mut self, url: S) -> Self {
499        self.base_url = Some(url.into());
500        self
501    }
502
503    /// Sets the content directory
504    #[cfg(feature = "ssg")]
505    #[must_use]
506    pub fn content_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
507        self.content_dir = Some(path.into());
508        self
509    }
510
511    /// Sets the output directory
512    #[cfg(feature = "ssg")]
513    #[must_use]
514    pub fn output_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
515        self.output_dir = Some(path.into());
516        self
517    }
518
519    /// Sets the template directory
520    #[cfg(feature = "ssg")]
521    #[must_use]
522    pub fn template_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
523        self.template_dir = Some(path.into());
524        self
525    }
526
527    /// Sets the serve directory
528    #[cfg(feature = "ssg")]
529    #[must_use]
530    pub fn serve_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
531        self.serve_dir = Some(path.into());
532        self
533    }
534
535    /// Enables or disables the development server
536    #[cfg(feature = "ssg")]
537    #[must_use]
538    pub const fn server_enabled(mut self, enabled: bool) -> Self {
539        self.server_enabled = enabled;
540        self
541    }
542
543    /// Sets the server port
544    #[cfg(feature = "ssg")]
545    #[must_use]
546    pub const fn server_port(mut self, port: u16) -> Self {
547        self.server_port = Some(port);
548        self
549    }
550
551    /// Builds the Config instance
552    ///
553    /// # Returns
554    ///
555    /// Returns a Result containing the built Config or an error
556    ///
557    /// # Errors
558    ///
559    /// Will return an error if:
560    /// - Required fields are missing
561    /// - Validation fails
562    pub fn build(self) -> Result<Config> {
563        let config = Config {
564            id: Uuid::new_v4(),
565            site_name: self.site_name.unwrap_or_default(),
566            site_title: self
567                .site_title
568                .unwrap_or_else(default_site_title),
569            #[cfg(feature = "ssg")]
570            site_description: self
571                .site_description
572                .unwrap_or_else(default_site_description),
573            #[cfg(feature = "ssg")]
574            language: self.language.unwrap_or_else(default_language),
575            #[cfg(feature = "ssg")]
576            base_url: self.base_url.unwrap_or_else(default_base_url),
577            #[cfg(feature = "ssg")]
578            content_dir: self
579                .content_dir
580                .unwrap_or_else(default_content_dir),
581            #[cfg(feature = "ssg")]
582            output_dir: self
583                .output_dir
584                .unwrap_or_else(default_output_dir),
585            #[cfg(feature = "ssg")]
586            template_dir: self
587                .template_dir
588                .unwrap_or_else(default_template_dir),
589            #[cfg(feature = "ssg")]
590            serve_dir: self.serve_dir,
591            #[cfg(feature = "ssg")]
592            server_enabled: self.server_enabled,
593            #[cfg(feature = "ssg")]
594            server_port: self.server_port.unwrap_or_else(default_port),
595        };
596
597        config.validate()?;
598        Ok(config)
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    #[cfg(feature = "ssg")]
606    use tempfile::tempdir;
607
608    /// Tests for default value functions
609    mod default_values_tests {
610        use super::*;
611
612        #[test]
613        fn test_default_site_title() {
614            assert_eq!(default_site_title(), "My Shokunin Site");
615        }
616    }
617
618    // SSG-specific tests
619    #[cfg(feature = "ssg")]
620    mod ssg_tests {
621        use crate::config::default_base_url;
622        use crate::config::default_content_dir;
623        use crate::config::default_language;
624        use crate::config::default_output_dir;
625        use crate::config::default_site_description;
626        use crate::config::default_template_dir;
627        use crate::config::PathBuf;
628        #[test]
629        fn test_default_site_description() {
630            assert_eq!(
631                default_site_description(),
632                "A site built with Shokunin"
633            );
634        }
635
636        #[test]
637        fn test_default_language() {
638            assert_eq!(default_language(), "en-GB");
639        }
640
641        #[test]
642        fn test_default_base_url() {
643            assert_eq!(default_base_url(), "http://localhost:8000");
644        }
645
646        #[test]
647        fn test_default_content_dir() {
648            assert_eq!(default_content_dir(), PathBuf::from("content"));
649        }
650
651        #[test]
652        fn test_default_output_dir() {
653            assert_eq!(default_output_dir(), PathBuf::from("public"));
654        }
655
656        #[test]
657        fn test_default_template_dir() {
658            assert_eq!(
659                default_template_dir(),
660                PathBuf::from("templates")
661            );
662        }
663    }
664
665    /// Tests for the `Builder` functionality
666    mod builder_tests {
667        use super::*;
668
669        #[test]
670        fn test_builder_initialization() {
671            let builder = Config::builder();
672            assert_eq!(builder.site_name, None);
673            assert_eq!(builder.site_title, None);
674            #[cfg(feature = "ssg")]
675            assert_eq!(builder.site_description, None);
676            #[cfg(feature = "ssg")]
677            assert_eq!(builder.language, None);
678            #[cfg(feature = "ssg")]
679            assert_eq!(builder.base_url, None);
680            #[cfg(feature = "ssg")]
681            assert_eq!(builder.content_dir, None);
682            #[cfg(feature = "ssg")]
683            assert_eq!(builder.output_dir, None);
684            #[cfg(feature = "ssg")]
685            assert_eq!(builder.template_dir, None);
686            #[cfg(feature = "ssg")]
687            assert_eq!(builder.serve_dir, None);
688            #[cfg(feature = "ssg")]
689            assert!(!builder.server_enabled);
690            #[cfg(feature = "ssg")]
691            assert_eq!(builder.server_port, None);
692        }
693
694        #[test]
695        fn test_builder_defaults_applied() {
696            let config = Config::builder()
697                .site_name("Test Site")
698                .build()
699                .unwrap();
700
701            assert_eq!(config.site_title, default_site_title());
702            #[cfg(feature = "ssg")]
703            assert_eq!(
704                config.site_description,
705                default_site_description()
706            );
707            #[cfg(feature = "ssg")]
708            assert_eq!(config.language, default_language());
709            #[cfg(feature = "ssg")]
710            assert_eq!(config.base_url, default_base_url());
711            #[cfg(feature = "ssg")]
712            assert_eq!(config.content_dir, default_content_dir());
713            #[cfg(feature = "ssg")]
714            assert_eq!(config.output_dir, default_output_dir());
715            #[cfg(feature = "ssg")]
716            assert_eq!(config.template_dir, default_template_dir());
717            #[cfg(feature = "ssg")]
718            assert_eq!(config.server_port, default_port());
719            #[cfg(feature = "ssg")]
720            assert!(!config.server_enabled);
721            #[cfg(feature = "ssg")]
722            assert!(config.serve_dir.is_none());
723        }
724
725        #[test]
726        fn test_builder_missing_site_name() {
727            let result = Config::builder().build();
728            assert!(
729                result.is_err(),
730                "Builder should fail without site_name"
731            );
732        }
733
734        #[test]
735        fn test_builder_empty_values() {
736            let result =
737                Config::builder().site_name("").site_title("").build();
738            assert!(
739                result.is_err(),
740                "Empty values should fail validation"
741            );
742        }
743
744        #[test]
745        fn test_unique_id_generation() -> Result<()> {
746            let config1 =
747                Config::builder().site_name("Site 1").build()?;
748            let config2 =
749                Config::builder().site_name("Site 2").build()?;
750            assert_ne!(
751                config1.id(),
752                config2.id(),
753                "IDs should be unique"
754            );
755            Ok(())
756        }
757
758        #[test]
759        fn test_builder_long_values() {
760            let long_string = "a".repeat(256);
761            let result = Config::builder()
762                .site_name(&long_string)
763                .site_title(&long_string)
764                .build();
765            assert!(
766                result.is_ok(),
767                "Long values should not cause validation errors"
768            );
769        }
770    }
771
772    /// Tests for configuration validation
773    mod validation_tests {
774        use super::*;
775
776        #[test]
777        fn test_empty_site_name() {
778            let result = Config::builder().site_name("").build();
779            assert!(
780                result.is_err(),
781                "Empty site name should fail validation"
782            );
783        }
784
785        #[cfg(feature = "ssg")]
786        #[test]
787        fn test_empty_site_name_ssg() {
788            let result = Config::builder()
789                .site_name("")
790                .content_dir("content")
791                .build();
792            assert!(
793                result.is_err(),
794                "Empty site name should fail validation"
795            );
796        }
797
798        #[cfg(feature = "ssg")]
799        #[test]
800        fn test_invalid_url_format() {
801            let invalid_urls = vec![
802                "not-a-url",
803                "http://",
804                "://invalid",
805                "http//missing-colon",
806            ];
807            for url in invalid_urls {
808                let result = Config::builder()
809                    .site_name("Test Site")
810                    .base_url(url)
811                    .build();
812                assert!(
813                    result.is_err(),
814                    "URL '{}' should fail validation",
815                    url
816                );
817            }
818        }
819
820        #[cfg(feature = "ssg")]
821        #[test]
822        fn test_validate_path_safety_mocked() {
823            let path = PathBuf::from("valid/path");
824            let result = Config::builder()
825                .site_name("Test Site")
826                .content_dir(path)
827                .build();
828            assert!(
829                result.is_ok(),
830                "Valid path should pass validation"
831            );
832        }
833    }
834
835    /// Tests for `Error` variants
836    mod config_error_tests {
837        use super::*;
838
839        #[test]
840        fn test_config_error_display() {
841            let error =
842                Error::InvalidSiteName("Empty name".to_string());
843            assert_eq!(
844                format!("{}", error),
845                "Invalid site name: Empty name"
846            );
847        }
848
849        #[test]
850        fn test_invalid_path_error() {
851            let error = Error::InvalidPath {
852                path: "invalid/path".to_string(),
853                details: "Unsafe path detected".to_string(),
854            };
855            assert_eq!(
856                format!("{}", error),
857                "Invalid directory path 'invalid/path': Unsafe path detected"
858            );
859        }
860
861        #[test]
862        fn test_file_error_conversion() {
863            let io_error = std::io::Error::new(
864                std::io::ErrorKind::NotFound,
865                "File not found",
866            );
867            let error: Error = io_error.into();
868            assert_eq!(
869                format!("{}", error),
870                "Configuration file error: File not found"
871            );
872        }
873    }
874
875    /// Tests for helper methods
876    mod helper_method_tests {
877        #[cfg(feature = "ssg")]
878        use super::*;
879
880        #[cfg(feature = "ssg")]
881        #[test]
882        fn test_is_valid_language_code() {
883            let config =
884                Config::builder().site_name("Test").build().unwrap();
885            assert!(config.is_valid_language_code("en-US"));
886            assert!(!config.is_valid_language_code("invalid-code"));
887        }
888
889        #[cfg(feature = "ssg")]
890        #[test]
891        fn test_is_valid_port() {
892            assert!(Config::is_valid_port(1024));
893            assert!(!Config::is_valid_port(1023));
894        }
895    }
896
897    /// Tests for serialization and deserialization
898    mod serialization_tests {
899        use super::*;
900
901        #[test]
902        fn test_serialization_roundtrip() -> Result<()> {
903            let original = Config::builder()
904                .site_name("Test Site")
905                .site_title("Roundtrip Test")
906                .build()?;
907
908            let serialized = toml::to_string(&original)?;
909            let deserialized: Config = toml::from_str(&serialized)?;
910
911            assert_eq!(original.site_name, deserialized.site_name);
912            assert_eq!(original.site_title, deserialized.site_title);
913            assert_eq!(original.id(), deserialized.id());
914            Ok(())
915        }
916    }
917
918    /// Tests for file operations
919    mod file_tests {
920        #[cfg(feature = "ssg")]
921        use super::*;
922
923        #[cfg(feature = "ssg")]
924        #[test]
925        fn test_missing_config_file() {
926            let result =
927                Config::from_file(Path::new("nonexistent.toml"));
928            assert!(
929                result.is_err(),
930                "Missing file should fail to load"
931            );
932        }
933
934        #[cfg(feature = "ssg")]
935        #[test]
936        fn test_invalid_toml_file() -> Result<()> {
937            let dir = tempdir()?;
938            let config_path = dir.path().join("invalid_config.toml");
939
940            std::fs::write(&config_path, "invalid_toml_syntax")?;
941
942            let result = Config::from_file(&config_path);
943            assert!(result.is_err(), "Invalid TOML syntax should fail");
944            Ok(())
945        }
946    }
947
948    /// Miscellaneous utility tests
949    mod utility_tests {
950        use super::*;
951
952        #[cfg(feature = "ssg")]
953        #[test]
954        fn test_config_display_format() {
955            let config = Config::builder()
956                .site_name("Test Site")
957                .site_title("Display Title")
958                .content_dir("test_content")
959                .output_dir("test_output")
960                .template_dir("test_templates")
961                .build()
962                .unwrap();
963
964            let display = format!("{}", config);
965            assert!(display.contains("Site: Test Site (Display Title)"));
966            assert!(display.contains("Content: test_content"));
967            assert!(display.contains("Output: test_output"));
968            assert!(display.contains("Templates: test_templates"));
969        }
970
971        #[test]
972        fn test_clone_retains_all_fields() -> Result<()> {
973            let original = Config::builder()
974                .site_name("Original")
975                .site_title("Clone Test")
976                .build()?;
977
978            let cloned = original.clone();
979
980            assert_eq!(original.site_name, cloned.site_name);
981            assert_eq!(original.site_title, cloned.site_title);
982            assert_eq!(original.id(), cloned.id());
983            Ok(())
984        }
985
986        #[cfg(feature = "ssg")]
987        #[test]
988        fn test_is_valid_language_code_safe() {
989            let config =
990                Config::builder().site_name("Test").build().unwrap();
991
992            assert!(config.is_valid_language_code("en-US"));
993            assert!(config.is_valid_language_code("fr-FR"));
994            assert!(!config.is_valid_language_code("invalid-code"));
995            assert!(!config.is_valid_language_code("en"));
996            assert!(!config.is_valid_language_code(""));
997            assert!(!config.is_valid_language_code("e-US"));
998            assert!(!config.is_valid_language_code("en-Us"));
999        }
1000    }
1001}