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 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 #[must_use]
263 pub fn builder() -> Builder {
264 Builder::default()
265 }
266
267 #[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 config.id = Uuid::new_v4();
307
308 config.validate()?;
310
311 Ok(config)
312 }
313
314 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 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 #[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(®ion)) =
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 #[cfg(feature = "ssg")]
399 #[must_use]
400 const fn is_valid_port(port: u16) -> bool {
401 port >= 1024
402 }
403
404 #[must_use]
406 pub const fn id(&self) -> Uuid {
407 self.id
408 }
409
410 #[must_use]
412 pub fn site_name(&self) -> &str {
413 &self.site_name
414 }
415
416 #[cfg(feature = "ssg")]
418 #[must_use]
419 pub const fn server_enabled(&self) -> bool {
420 self.server_enabled
421 }
422
423 #[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#[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 #[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 #[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 #[cfg(feature = "ssg")]
478 #[must_use]
479 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 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 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 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 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 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 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 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 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 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 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}