1use 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#[derive(Error, Debug)]
77pub enum Error {
78 #[error("Invalid site name: {0}")]
80 InvalidSiteName(String),
81
82 #[error("Invalid directory path '{path}': {details}")]
84 InvalidPath {
85 path: String,
87 details: String,
89 },
90
91 #[cfg(feature = "ssg")]
93 #[error("Invalid URL format: {0}")]
94 InvalidUrl(String),
95
96 #[cfg(feature = "ssg")]
98 #[error("Invalid language code '{0}': must be in format 'xx-XX'")]
99 InvalidLanguage(String),
100
101 #[error("Configuration file error: {0}")]
103 FileError(#[from] std::io::Error),
104
105 #[error("TOML parsing error: {0}")]
107 TomlError(#[from] toml::de::Error),
108
109 #[cfg(feature = "ssg")]
111 #[error("Server configuration error: {0}")]
112 ServerError(String),
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(deny_unknown_fields)]
120pub struct Config {
121 #[serde(default = "Uuid::new_v4")]
123 id: Uuid,
124
125 pub site_name: String,
127
128 #[serde(default = "default_site_title")]
130 pub site_title: String,
131
132 #[cfg(feature = "ssg")]
134 #[serde(default = "default_site_description")]
135 pub site_description: String,
136
137 #[cfg(feature = "ssg")]
139 #[serde(default = "default_language")]
140 pub language: String,
141
142 #[cfg(feature = "ssg")]
144 #[serde(default = "default_base_url")]
145 pub base_url: String,
146
147 #[cfg(feature = "ssg")]
149 #[serde(default = "default_content_dir")]
150 pub content_dir: PathBuf,
151
152 #[cfg(feature = "ssg")]
154 #[serde(default = "default_output_dir")]
155 pub output_dir: PathBuf,
156
157 #[cfg(feature = "ssg")]
159 #[serde(default = "default_template_dir")]
160 pub template_dir: PathBuf,
161
162 #[cfg(feature = "ssg")]
164 #[serde(default)]
165 pub serve_dir: Option<PathBuf>,
166
167 #[cfg(feature = "ssg")]
169 #[serde(default)]
170 pub server_enabled: bool,
171
172 #[cfg(feature = "ssg")]
174 #[serde(default = "default_port")]
175 pub server_port: u16,
176}
177
178fn 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 #[must_use]
262 pub fn builder() -> Builder {
263 Builder::default()
264 }
265
266 #[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 config.id = Uuid::new_v4();
306
307 config.validate()?;
309
310 Ok(config)
311 }
312
313 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 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 #[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(®ion)) =
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 #[cfg(feature = "ssg")]
398 #[must_use]
399 const fn is_valid_port(port: u16) -> bool {
400 port >= 1024
401 }
402
403 #[must_use]
405 pub const fn id(&self) -> Uuid {
406 self.id
407 }
408
409 #[must_use]
411 pub fn site_name(&self) -> &str {
412 &self.site_name
413 }
414
415 #[cfg(feature = "ssg")]
417 #[must_use]
418 pub const fn server_enabled(&self) -> bool {
419 self.server_enabled
420 }
421
422 #[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#[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 #[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 #[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 #[cfg(feature = "ssg")]
477 #[must_use]
478 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 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 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 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 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 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 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 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}