1use confique::Config;
75use confique::Layer;
76use confique::meta::Meta;
77use serde::{Deserialize, Serialize};
78use std::fs;
79use std::path::{Path, PathBuf};
80use std::sync::Arc;
81use thiserror::Error;
82
83#[derive(Error, Debug)]
84pub enum ConfigError {
85 #[error("IO error: {0}")]
86 Io(#[from] std::io::Error),
87 #[error("failed to parse {}: {source}", path.display())]
93 Toml {
94 path: PathBuf,
95 #[source]
96 source: Box<toml::de::Error>,
97 source_text: String,
98 },
99 #[error("config error: {0}")]
103 Confique(#[from] confique::Error),
104 #[error("Config validation error: {0}")]
105 Validation(String),
106}
107
108impl ConfigError {
109 pub fn to_clapfig_error(&self) -> Option<clapfig::error::ClapfigError> {
115 match self {
116 ConfigError::Toml {
117 path,
118 source,
119 source_text,
120 } => Some(clapfig::error::ClapfigError::ParseError {
121 path: path.clone(),
122 source: source.clone(),
123 source_text: Some(Arc::from(source_text.as_str())),
124 }),
125 _ => None,
126 }
127 }
128}
129
130#[derive(Config, Debug, Clone, Serialize, PartialEq)]
145#[config(layer_attr(derive(Clone)))]
146#[config(layer_attr(serde(deny_unknown_fields)))]
147pub struct SiteConfig {
148 #[config(default = "Gallery")]
150 pub site_title: String,
151
152 #[config(default = "assets")]
156 pub assets_dir: String,
157
158 #[config(default = "site")]
161 pub site_description_file: String,
162
163 #[config(nested)]
165 pub colors: ColorConfig,
166
167 #[config(nested)]
169 pub thumbnails: ThumbnailsConfig,
170
171 #[config(nested)]
173 pub full_index: FullIndexConfig,
174
175 #[config(nested)]
177 pub images: ImagesConfig,
178
179 #[config(nested)]
181 pub theme: ThemeConfig,
182
183 #[config(nested)]
185 pub font: FontConfig,
186
187 #[config(nested)]
189 pub processing: ProcessingConfig,
190}
191
192impl Default for SiteConfig {
193 fn default() -> Self {
197 let layer = <SiteConfig as Config>::Layer::default_values();
198 SiteConfig::from_layer(layer).expect("confique defaults must satisfy the SiteConfig schema")
199 }
200}
201
202impl<'de> Deserialize<'de> for SiteConfig {
203 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
212 where
213 D: serde::Deserializer<'de>,
214 {
215 let layer = SiteConfigLayer::deserialize(deserializer)?;
216 let merged = layer.with_fallback(SiteConfigLayer::default_values());
217 SiteConfig::from_layer(merged).map_err(serde::de::Error::custom)
218 }
219}
220
221impl SiteConfig {
222 pub fn validate(&self) -> Result<(), ConfigError> {
229 if self.images.quality > 100 {
230 return Err(ConfigError::Validation(
231 "images.quality must be 0-100".into(),
232 ));
233 }
234 if self.thumbnails.aspect_ratio[0] == 0 || self.thumbnails.aspect_ratio[1] == 0 {
235 return Err(ConfigError::Validation(
236 "thumbnails.aspect_ratio values must be non-zero".into(),
237 ));
238 }
239 if self.full_index.thumb_ratio[0] == 0 || self.full_index.thumb_ratio[1] == 0 {
240 return Err(ConfigError::Validation(
241 "full_index.thumb_ratio values must be non-zero".into(),
242 ));
243 }
244 if self.full_index.thumb_size == 0 {
245 return Err(ConfigError::Validation(
246 "full_index.thumb_size must be non-zero".into(),
247 ));
248 }
249 if self.images.sizes.is_empty() {
250 return Err(ConfigError::Validation(
251 "images.sizes must not be empty".into(),
252 ));
253 }
254 Ok(())
255 }
256}
257
258#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
264#[config(layer_attr(derive(Clone)))]
265#[config(layer_attr(serde(deny_unknown_fields)))]
266pub struct ThumbnailsConfig {
267 #[config(default = [4, 5])]
269 pub aspect_ratio: [u32; 2],
270 #[config(default = 400)]
272 pub size: u32,
273}
274
275#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
286#[config(layer_attr(derive(Clone)))]
287#[config(layer_attr(serde(deny_unknown_fields)))]
288pub struct FullIndexConfig {
289 #[config(default = false)]
291 pub generates: bool,
292 #[config(default = false)]
294 pub show_link: bool,
295 #[config(default = [4, 5])]
297 pub thumb_ratio: [u32; 2],
298 #[config(default = 400)]
300 pub thumb_size: u32,
301 #[config(default = "0.2rem")]
303 pub thumb_gap: String,
304}
305
306#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
312#[config(layer_attr(derive(Clone)))]
313#[config(layer_attr(serde(deny_unknown_fields)))]
314pub struct ImagesConfig {
315 #[config(default = [800, 1400, 2080])]
318 pub sizes: Vec<u32>,
319 #[config(default = 90)]
321 pub quality: u32,
322}
323
324#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
335#[config(layer_attr(derive(Clone)))]
336#[config(layer_attr(serde(deny_unknown_fields)))]
337pub struct MatX {
338 #[config(default = "3vw")]
340 pub size: String,
341 #[config(default = "1rem")]
343 pub min: String,
344 #[config(default = "2.5rem")]
346 pub max: String,
347}
348
349#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
351#[config(layer_attr(derive(Clone)))]
352#[config(layer_attr(serde(deny_unknown_fields)))]
353pub struct MatY {
354 #[config(default = "6vw")]
356 pub size: String,
357 #[config(default = "2rem")]
359 pub min: String,
360 #[config(default = "5rem")]
362 pub max: String,
363}
364
365fn clamp_to_css(size: &str, min: &str, max: &str) -> String {
367 format!("clamp({}, {}, {})", min, size, max)
368}
369
370impl MatX {
371 pub fn to_css(&self) -> String {
372 clamp_to_css(&self.size, &self.min, &self.max)
373 }
374}
375
376impl MatY {
377 pub fn to_css(&self) -> String {
378 clamp_to_css(&self.size, &self.min, &self.max)
379 }
380}
381
382#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
384#[config(layer_attr(derive(Clone)))]
385#[config(layer_attr(serde(deny_unknown_fields)))]
386pub struct ThemeConfig {
387 #[config(nested)]
389 pub mat_x: MatX,
390 #[config(nested)]
392 pub mat_y: MatY,
393 #[config(default = "0.2rem")]
395 pub thumbnail_gap: String,
396 #[config(default = "2rem")]
398 pub grid_padding: String,
399}
400
401#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
408#[config(layer_attr(derive(Clone)))]
409#[config(layer_attr(serde(deny_unknown_fields)))]
410pub struct ColorConfig {
411 #[config(nested)]
413 pub light: LightColors,
414 #[config(nested)]
416 pub dark: DarkColors,
417}
418
419#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
421#[config(layer_attr(derive(Clone)))]
422#[config(layer_attr(serde(deny_unknown_fields)))]
423pub struct LightColors {
424 #[config(default = "#ffffff")]
426 pub background: String,
427 #[config(default = "#111111")]
429 pub text: String,
430 #[config(default = "#666666")]
432 pub text_muted: String,
433 #[config(default = "#e0e0e0")]
435 pub border: String,
436 #[config(default = "#e0e0e0")]
438 pub separator: String,
439 #[config(default = "#333333")]
441 pub link: String,
442 #[config(default = "#000000")]
444 pub link_hover: String,
445}
446
447#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
449#[config(layer_attr(derive(Clone)))]
450#[config(layer_attr(serde(deny_unknown_fields)))]
451pub struct DarkColors {
452 #[config(default = "#000000")]
454 pub background: String,
455 #[config(default = "#fafafa")]
457 pub text: String,
458 #[config(default = "#999999")]
460 pub text_muted: String,
461 #[config(default = "#333333")]
463 pub border: String,
464 #[config(default = "#333333")]
466 pub separator: String,
467 #[config(default = "#cccccc")]
469 pub link: String,
470 #[config(default = "#ffffff")]
472 pub link_hover: String,
473}
474
475#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
481#[serde(rename_all = "lowercase")]
482pub enum FontType {
483 #[default]
484 Sans,
485 Serif,
486}
487
488#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
510#[config(layer_attr(derive(Clone)))]
511#[config(layer_attr(serde(deny_unknown_fields)))]
512pub struct FontConfig {
513 #[config(default = "Noto Sans")]
515 pub font: String,
516 #[config(default = "600")]
518 pub weight: String,
519 #[config(default = "sans")]
521 pub font_type: FontType,
522 pub source: Option<String>,
527}
528
529impl FontConfig {
530 pub fn is_local(&self) -> bool {
532 self.source.is_some()
533 }
534
535 pub fn stylesheet_url(&self) -> Option<String> {
538 if self.is_local() {
539 return None;
540 }
541 let family = self.font.replace(' ', "+");
542 Some(format!(
543 "https://fonts.googleapis.com/css2?family={}:wght@{}&display=swap",
544 family, self.weight
545 ))
546 }
547
548 pub fn font_face_css(&self) -> Option<String> {
551 let src = self.source.as_ref()?;
552 let format = font_format_from_extension(src);
553 Some(format!(
554 r#"@font-face {{
555 font-family: "{}";
556 src: url("/{}") format("{}");
557 font-weight: {};
558 font-display: swap;
559}}"#,
560 self.font, src, format, self.weight
561 ))
562 }
563
564 pub fn font_family_css(&self) -> String {
566 let fallbacks = match self.font_type {
567 FontType::Serif => r#"Georgia, "Times New Roman", serif"#,
568 FontType::Sans => "Helvetica, Verdana, sans-serif",
569 };
570 format!(r#""{}", {}"#, self.font, fallbacks)
571 }
572}
573
574fn font_format_from_extension(path: &str) -> &'static str {
576 match path.rsplit('.').next().map(|e| e.to_lowercase()).as_deref() {
577 Some("woff2") => "woff2",
578 Some("woff") => "woff",
579 Some("ttf") => "truetype",
580 Some("otf") => "opentype",
581 _ => "woff2", }
583}
584
585#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
591#[config(layer_attr(derive(Clone)))]
592#[config(layer_attr(serde(deny_unknown_fields)))]
593pub struct ProcessingConfig {
594 pub max_processes: Option<usize>,
598}
599
600pub fn effective_threads(config: &ProcessingConfig) -> usize {
605 let cores = std::thread::available_parallelism()
606 .map(|n| n.get())
607 .unwrap_or(1);
608 config.max_processes.map(|n| n.min(cores)).unwrap_or(cores)
609}
610
611pub type SiteConfigLayer = <SiteConfig as Config>::Layer;
621
622pub fn load_layer(dir: &Path) -> Result<Option<SiteConfigLayer>, ConfigError> {
629 let config_path = dir.join("config.toml");
630 if !config_path.exists() {
631 return Ok(None);
632 }
633 let content = fs::read_to_string(&config_path)?;
634 let layer: SiteConfigLayer = toml::from_str(&content).map_err(|e| ConfigError::Toml {
635 path: config_path.clone(),
636 source: Box::new(e),
637 source_text: content,
638 })?;
639 Ok(Some(layer))
640}
641
642pub fn load_config(dir: &Path) -> Result<SiteConfig, ConfigError> {
648 let user = load_layer(dir)?.unwrap_or_else(SiteConfigLayer::empty);
649 let merged = user.with_fallback(SiteConfigLayer::default_values());
650 let config = SiteConfig::from_layer(merged)?;
651 config.validate()?;
652 Ok(config)
653}
654
655pub fn finalize_layer(layer: SiteConfigLayer) -> Result<SiteConfig, ConfigError> {
659 let merged = layer.with_fallback(SiteConfigLayer::default_values());
660 let config = SiteConfig::from_layer(merged)?;
661 config.validate()?;
662 Ok(config)
663}
664
665pub fn site_config_meta() -> &'static Meta {
669 &<SiteConfig as Config>::META
670}
671
672pub fn generate_color_css(colors: &ColorConfig) -> String {
684 format!(
685 r#":root {{
686 --color-bg: {light_bg};
687 --color-text: {light_text};
688 --color-text-muted: {light_text_muted};
689 --color-border: {light_border};
690 --color-link: {light_link};
691 --color-link-hover: {light_link_hover};
692 --color-separator: {light_separator};
693}}
694
695@media (prefers-color-scheme: dark) {{
696 :root {{
697 --color-bg: {dark_bg};
698 --color-text: {dark_text};
699 --color-text-muted: {dark_text_muted};
700 --color-border: {dark_border};
701 --color-link: {dark_link};
702 --color-link-hover: {dark_link_hover};
703 --color-separator: {dark_separator};
704 }}
705}}"#,
706 light_bg = colors.light.background,
707 light_text = colors.light.text,
708 light_text_muted = colors.light.text_muted,
709 light_border = colors.light.border,
710 light_separator = colors.light.separator,
711 light_link = colors.light.link,
712 light_link_hover = colors.light.link_hover,
713 dark_bg = colors.dark.background,
714 dark_text = colors.dark.text,
715 dark_text_muted = colors.dark.text_muted,
716 dark_border = colors.dark.border,
717 dark_separator = colors.dark.separator,
718 dark_link = colors.dark.link,
719 dark_link_hover = colors.dark.link_hover,
720 )
721}
722
723pub fn generate_theme_css(theme: &ThemeConfig) -> String {
725 format!(
726 r#":root {{
727 --mat-x: {mat_x};
728 --mat-y: {mat_y};
729 --thumbnail-gap: {thumbnail_gap};
730 --grid-padding: {grid_padding};
731}}"#,
732 mat_x = theme.mat_x.to_css(),
733 mat_y = theme.mat_y.to_css(),
734 thumbnail_gap = theme.thumbnail_gap,
735 grid_padding = theme.grid_padding,
736 )
737}
738
739pub fn generate_font_css(font: &FontConfig) -> String {
743 let vars = format!(
744 r#":root {{
745 --font-family: {family};
746 --font-weight: {weight};
747}}"#,
748 family = font.font_family_css(),
749 weight = font.weight,
750 );
751 match font.font_face_css() {
752 Some(face) => format!("{}\n\n{}", face, vars),
753 None => vars,
754 }
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760 use tempfile::TempDir;
761
762 fn write_config(dir: &Path, body: &str) {
763 fs::write(dir.join("config.toml"), body).unwrap();
764 }
765
766 #[test]
769 fn default_config_has_colors() {
770 let config = SiteConfig::default();
771 assert_eq!(config.colors.light.background, "#ffffff");
772 assert_eq!(config.colors.dark.background, "#000000");
773 }
774
775 #[test]
776 fn default_config_has_site_title() {
777 let config = SiteConfig::default();
778 assert_eq!(config.site_title, "Gallery");
779 }
780
781 #[test]
782 fn default_config_has_image_settings() {
783 let config = SiteConfig::default();
784 assert_eq!(config.thumbnails.aspect_ratio, [4, 5]);
785 assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
786 assert_eq!(config.images.quality, 90);
787 assert_eq!(config.theme.mat_x.to_css(), "clamp(1rem, 3vw, 2.5rem)");
788 assert_eq!(config.theme.mat_y.to_css(), "clamp(2rem, 6vw, 5rem)");
789 }
790
791 #[test]
792 fn default_full_index_is_off() {
793 let config = SiteConfig::default();
794 assert!(!config.full_index.generates);
795 assert!(!config.full_index.show_link);
796 assert_eq!(config.full_index.thumb_ratio, [4, 5]);
797 assert_eq!(config.full_index.thumb_size, 400);
798 assert_eq!(config.full_index.thumb_gap, "0.2rem");
799 }
800
801 #[test]
802 fn default_thumbnail_gap_and_grid_padding() {
803 let config = SiteConfig::default();
804 assert_eq!(config.theme.thumbnail_gap, "0.2rem");
805 assert_eq!(config.theme.grid_padding, "2rem");
806 }
807
808 #[test]
809 fn default_assets_dir() {
810 let config = SiteConfig::default();
811 assert_eq!(config.assets_dir, "assets");
812 }
813
814 #[test]
815 fn default_site_description_file() {
816 let config = SiteConfig::default();
817 assert_eq!(config.site_description_file, "site");
818 }
819
820 #[test]
821 fn default_processing_config() {
822 let config = SiteConfig::default();
823 assert_eq!(config.processing.max_processes, None);
824 }
825
826 #[test]
829 fn parse_custom_site_title() {
830 let tmp = TempDir::new().unwrap();
831 write_config(tmp.path(), r#"site_title = "My Portfolio""#);
832 let config = load_config(tmp.path()).unwrap();
833 assert_eq!(config.site_title, "My Portfolio");
834 }
835
836 #[test]
837 fn parse_partial_colors_only() {
838 let tmp = TempDir::new().unwrap();
839 write_config(
840 tmp.path(),
841 r##"
842[colors.light]
843background = "#fafafa"
844"##,
845 );
846 let config = load_config(tmp.path()).unwrap();
847 assert_eq!(config.colors.light.background, "#fafafa");
849 assert_eq!(config.colors.light.text, "#111111");
851 assert_eq!(config.colors.dark.background, "#000000");
852 assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
854 }
855
856 #[test]
857 fn parse_image_settings() {
858 let tmp = TempDir::new().unwrap();
859 write_config(
860 tmp.path(),
861 r##"
862[thumbnails]
863aspect_ratio = [1, 1]
864
865[images]
866sizes = [400, 800]
867quality = 85
868"##,
869 );
870 let config = load_config(tmp.path()).unwrap();
871 assert_eq!(config.thumbnails.aspect_ratio, [1, 1]);
872 assert_eq!(config.images.sizes, vec![400, 800]);
873 assert_eq!(config.images.quality, 85);
874 assert_eq!(config.colors.light.background, "#ffffff");
875 }
876
877 #[test]
878 fn parse_full_index_settings() {
879 let tmp = TempDir::new().unwrap();
880 write_config(
881 tmp.path(),
882 r##"
883[full_index]
884generates = true
885show_link = true
886thumb_ratio = [4, 4]
887thumb_size = 1000
888thumb_gap = "0.5rem"
889"##,
890 );
891 let config = load_config(tmp.path()).unwrap();
892 assert!(config.full_index.generates);
893 assert!(config.full_index.show_link);
894 assert_eq!(config.full_index.thumb_ratio, [4, 4]);
895 assert_eq!(config.full_index.thumb_size, 1000);
896 assert_eq!(config.full_index.thumb_gap, "0.5rem");
897 }
898
899 #[test]
900 fn full_index_partial_preserves_defaults() {
901 let tmp = TempDir::new().unwrap();
902 write_config(
903 tmp.path(),
904 r##"
905[full_index]
906generates = true
907"##,
908 );
909 let config = load_config(tmp.path()).unwrap();
910 assert!(config.full_index.generates);
911 assert!(!config.full_index.show_link);
912 assert_eq!(config.full_index.thumb_ratio, [4, 5]);
913 assert_eq!(config.full_index.thumb_size, 400);
914 }
915
916 #[test]
917 fn parse_partial_theme_mat_x_only() {
918 let tmp = TempDir::new().unwrap();
919 write_config(
920 tmp.path(),
921 r#"
922[theme.mat_x]
923size = "5vw"
924"#,
925 );
926 let config = load_config(tmp.path()).unwrap();
927 assert_eq!(config.theme.mat_x.size, "5vw");
929 assert_eq!(config.theme.mat_x.min, "1rem");
931 assert_eq!(config.theme.mat_x.max, "2.5rem");
932 assert_eq!(config.theme.mat_y.size, "6vw");
934 assert_eq!(config.theme.mat_y.min, "2rem");
935 assert_eq!(config.theme.mat_y.max, "5rem");
936 assert_eq!(config.theme.thumbnail_gap, "0.2rem");
938 assert_eq!(config.theme.grid_padding, "2rem");
939 }
940
941 #[test]
942 fn parse_partial_colors_light_keeps_dark_defaults() {
943 let tmp = TempDir::new().unwrap();
944 write_config(
945 tmp.path(),
946 r##"
947[colors.light]
948background = "#fafafa"
949text = "#222222"
950"##,
951 );
952 let config = load_config(tmp.path()).unwrap();
953 assert_eq!(config.colors.light.background, "#fafafa");
954 assert_eq!(config.colors.light.text, "#222222");
955 assert_eq!(config.colors.light.text_muted, "#666666");
957 assert_eq!(config.colors.light.border, "#e0e0e0");
958 assert_eq!(config.colors.light.link, "#333333");
959 assert_eq!(config.colors.light.link_hover, "#000000");
960 assert_eq!(config.colors.dark.background, "#000000");
962 assert_eq!(config.colors.dark.text, "#fafafa");
963 }
964
965 #[test]
966 fn parse_partial_font_weight_only() {
967 let tmp = TempDir::new().unwrap();
968 write_config(
969 tmp.path(),
970 r#"
971[font]
972weight = "300"
973"#,
974 );
975 let config = load_config(tmp.path()).unwrap();
976 assert_eq!(config.font.weight, "300");
977 assert_eq!(config.font.font, "Noto Sans");
978 assert_eq!(config.font.font_type, FontType::Sans);
979 }
980
981 #[test]
982 fn parse_thumbnail_gap_and_grid_padding() {
983 let tmp = TempDir::new().unwrap();
984 write_config(
985 tmp.path(),
986 r#"
987[theme]
988thumbnail_gap = "0.5rem"
989grid_padding = "1rem"
990"#,
991 );
992 let config = load_config(tmp.path()).unwrap();
993 assert_eq!(config.theme.thumbnail_gap, "0.5rem");
994 assert_eq!(config.theme.grid_padding, "1rem");
995 }
996
997 #[test]
998 fn parse_processing_config() {
999 let tmp = TempDir::new().unwrap();
1000 write_config(tmp.path(), "[processing]\nmax_processes = 4\n");
1001 let config = load_config(tmp.path()).unwrap();
1002 assert_eq!(config.processing.max_processes, Some(4));
1003 }
1004
1005 #[test]
1006 fn parse_config_without_processing_uses_default() {
1007 let tmp = TempDir::new().unwrap();
1008 write_config(
1009 tmp.path(),
1010 r##"
1011[colors.light]
1012background = "#fafafa"
1013"##,
1014 );
1015 let config = load_config(tmp.path()).unwrap();
1016 assert_eq!(config.processing.max_processes, None);
1017 }
1018
1019 #[test]
1020 fn parse_custom_assets_dir() {
1021 let tmp = TempDir::new().unwrap();
1022 write_config(tmp.path(), r#"assets_dir = "site-assets""#);
1023 let config = load_config(tmp.path()).unwrap();
1024 assert_eq!(config.assets_dir, "site-assets");
1025 }
1026
1027 #[test]
1028 fn parse_custom_site_description_file() {
1029 let tmp = TempDir::new().unwrap();
1030 write_config(tmp.path(), r#"site_description_file = "intro""#);
1031 let config = load_config(tmp.path()).unwrap();
1032 assert_eq!(config.site_description_file, "intro");
1033 }
1034
1035 #[test]
1036 fn parse_multiple_sections_independently() {
1037 let tmp = TempDir::new().unwrap();
1038 write_config(
1039 tmp.path(),
1040 r##"
1041[colors.dark]
1042background = "#1a1a1a"
1043
1044[font]
1045font = "Lora"
1046font_type = "serif"
1047"##,
1048 );
1049 let config = load_config(tmp.path()).unwrap();
1050 assert_eq!(config.colors.dark.background, "#1a1a1a");
1051 assert_eq!(config.colors.dark.text, "#fafafa");
1052 assert_eq!(config.colors.light.background, "#ffffff");
1053 assert_eq!(config.font.font, "Lora");
1054 assert_eq!(config.font.font_type, FontType::Serif);
1055 assert_eq!(config.font.weight, "600");
1056 assert_eq!(config.images.quality, 90);
1057 assert_eq!(config.thumbnails.aspect_ratio, [4, 5]);
1058 assert_eq!(config.theme.mat_x.size, "3vw");
1059 }
1060
1061 #[test]
1064 fn toml_error_carries_path_and_source_text() {
1065 let tmp = TempDir::new().unwrap();
1066 write_config(tmp.path(), "[theme]\nthumbnail_gap = 0.2rem\n");
1069 let err = load_config(tmp.path()).unwrap_err();
1070 match &err {
1071 ConfigError::Toml {
1072 path,
1073 source_text,
1074 source,
1075 } => {
1076 assert!(path.ends_with("config.toml"));
1077 assert!(source_text.contains("thumbnail_gap"));
1078 assert!(source.span().is_some());
1079 }
1080 other => panic!("expected Toml variant, got {:?}", other),
1081 }
1082 }
1083
1084 #[test]
1085 fn to_clapfig_error_wraps_parse_failure() {
1086 let tmp = TempDir::new().unwrap();
1087 write_config(tmp.path(), "[theme]\nthumbnail_gap = 0.2rem\n");
1088 let err = load_config(tmp.path()).unwrap_err();
1089 let clap_err = err
1090 .to_clapfig_error()
1091 .expect("parse errors should be convertible to ClapfigError");
1092 let (path, parse_err, source_text) = clap_err
1093 .parse_error()
1094 .expect("ClapfigError should be a ParseError");
1095 assert!(path.ends_with("config.toml"));
1096 assert!(parse_err.span().is_some());
1097 assert!(source_text.unwrap().contains("thumbnail_gap"));
1098 }
1099
1100 #[test]
1101 fn to_clapfig_error_is_none_for_validation_failure() {
1102 let err = ConfigError::Validation("quality out of range".into());
1103 assert!(err.to_clapfig_error().is_none());
1104 }
1105
1106 #[test]
1107 fn clapfig_render_plain_includes_path_and_snippet() {
1108 let tmp = TempDir::new().unwrap();
1109 write_config(tmp.path(), "[theme]\nthumbnail_gap = 0.2rem\n");
1110 let err = load_config(tmp.path()).unwrap_err();
1111 let clap_err = err.to_clapfig_error().unwrap();
1112 let out = clapfig::render::render_plain(&clap_err);
1113 assert!(out.contains("config.toml"), "missing path in {out}");
1114 assert!(
1115 out.contains("thumbnail_gap"),
1116 "missing source snippet in {out}"
1117 );
1118 assert!(out.contains('^'), "missing caret in {out}");
1119 }
1120
1121 #[test]
1124 fn load_config_returns_default_when_no_file() {
1125 let tmp = TempDir::new().unwrap();
1126 let config = load_config(tmp.path()).unwrap();
1127 assert_eq!(config.colors.light.background, "#ffffff");
1128 assert_eq!(config.colors.dark.background, "#000000");
1129 }
1130
1131 #[test]
1132 fn load_config_reads_file() {
1133 let tmp = TempDir::new().unwrap();
1134 write_config(
1135 tmp.path(),
1136 r##"
1137[colors.light]
1138background = "#123456"
1139text = "#abcdef"
1140"##,
1141 );
1142 let config = load_config(tmp.path()).unwrap();
1143 assert_eq!(config.colors.light.background, "#123456");
1144 assert_eq!(config.colors.light.text, "#abcdef");
1145 assert_eq!(config.colors.dark.background, "#000000");
1146 }
1147
1148 #[test]
1149 fn load_config_invalid_toml_is_error() {
1150 let tmp = TempDir::new().unwrap();
1151 write_config(tmp.path(), "this is not valid toml [[[");
1152 let result = load_config(tmp.path());
1153 assert!(matches!(result, Err(ConfigError::Toml { .. })));
1154 }
1155
1156 #[test]
1157 fn load_config_unknown_keys_is_error() {
1158 let tmp = TempDir::new().unwrap();
1159 write_config(tmp.path(), "unknown_key = \"foo\"\n");
1160 let result = load_config(tmp.path());
1161 assert!(result.is_err());
1162 }
1163
1164 #[test]
1165 fn load_config_validates_values() {
1166 let tmp = TempDir::new().unwrap();
1167 write_config(tmp.path(), "[images]\nquality = 200\n");
1168 let result = load_config(tmp.path());
1169 assert!(matches!(result, Err(ConfigError::Validation(_))));
1170 }
1171
1172 #[test]
1175 fn validate_quality_boundary_ok() {
1176 let mut config = SiteConfig::default();
1177 config.images.quality = 100;
1178 assert!(config.validate().is_ok());
1179 config.images.quality = 0;
1180 assert!(config.validate().is_ok());
1181 }
1182
1183 #[test]
1184 fn validate_quality_too_high() {
1185 let mut config = SiteConfig::default();
1186 config.images.quality = 101;
1187 let err = config.validate().unwrap_err();
1188 assert!(err.to_string().contains("quality"));
1189 }
1190
1191 #[test]
1192 fn validate_aspect_ratio_zero() {
1193 let mut config = SiteConfig::default();
1194 config.thumbnails.aspect_ratio = [0, 5];
1195 assert!(config.validate().is_err());
1196 config.thumbnails.aspect_ratio = [4, 0];
1197 assert!(config.validate().is_err());
1198 }
1199
1200 #[test]
1201 fn full_index_validation_rejects_zero_ratio() {
1202 let mut config = SiteConfig::default();
1203 config.full_index.thumb_ratio = [0, 1];
1204 assert!(config.validate().is_err());
1205 }
1206
1207 #[test]
1208 fn validate_sizes_empty() {
1209 let mut config = SiteConfig::default();
1210 config.images.sizes = vec![];
1211 assert!(config.validate().is_err());
1212 }
1213
1214 #[test]
1215 fn validate_default_config_passes() {
1216 let config = SiteConfig::default();
1217 assert!(config.validate().is_ok());
1218 }
1219
1220 #[test]
1223 fn effective_threads_auto() {
1224 let config = ProcessingConfig {
1225 max_processes: None,
1226 };
1227 let threads = effective_threads(&config);
1228 let cores = std::thread::available_parallelism()
1229 .map(|n| n.get())
1230 .unwrap_or(1);
1231 assert_eq!(threads, cores);
1232 }
1233
1234 #[test]
1235 fn effective_threads_clamped_to_cores() {
1236 let config = ProcessingConfig {
1237 max_processes: Some(99999),
1238 };
1239 let threads = effective_threads(&config);
1240 let cores = std::thread::available_parallelism()
1241 .map(|n| n.get())
1242 .unwrap_or(1);
1243 assert_eq!(threads, cores);
1244 }
1245
1246 #[test]
1247 fn effective_threads_user_constrains_down() {
1248 let config = ProcessingConfig {
1249 max_processes: Some(1),
1250 };
1251 assert_eq!(effective_threads(&config), 1);
1252 }
1253
1254 #[test]
1257 fn generate_css_uses_config_colors() {
1258 let mut config = SiteConfig::default();
1259 config.colors.light.background = "#f0f0f0".to_string();
1260 config.colors.dark.background = "#1a1a1a".to_string();
1261 let css = generate_color_css(&config.colors);
1262 assert!(css.contains("--color-bg: #f0f0f0"));
1263 assert!(css.contains("--color-bg: #1a1a1a"));
1264 }
1265
1266 #[test]
1267 fn generate_css_includes_all_variables() {
1268 let config = SiteConfig::default();
1269 let css = generate_color_css(&config.colors);
1270 assert!(css.contains("--color-bg:"));
1271 assert!(css.contains("--color-text:"));
1272 assert!(css.contains("--color-text-muted:"));
1273 assert!(css.contains("--color-border:"));
1274 assert!(css.contains("--color-link:"));
1275 assert!(css.contains("--color-link-hover:"));
1276 }
1277
1278 #[test]
1279 fn generate_css_includes_dark_mode_media_query() {
1280 let config = SiteConfig::default();
1281 let css = generate_color_css(&config.colors);
1282 assert!(css.contains("@media (prefers-color-scheme: dark)"));
1283 }
1284
1285 #[test]
1286 fn mat_x_to_css() {
1287 let config = SiteConfig::default();
1288 assert_eq!(config.theme.mat_x.to_css(), "clamp(1rem, 3vw, 2.5rem)");
1289 }
1290
1291 #[test]
1292 fn generate_theme_css_includes_mat_variables() {
1293 let config = SiteConfig::default();
1294 let css = generate_theme_css(&config.theme);
1295 assert!(css.contains("--mat-x: clamp(1rem, 3vw, 2.5rem)"));
1296 assert!(css.contains("--mat-y: clamp(2rem, 6vw, 5rem)"));
1297 assert!(css.contains("--thumbnail-gap: 0.2rem"));
1298 assert!(css.contains("--grid-padding: 2rem"));
1299 }
1300
1301 #[test]
1304 fn default_font_is_google() {
1305 let config = SiteConfig::default();
1306 assert!(!config.font.is_local());
1307 assert!(config.font.stylesheet_url().is_some());
1308 assert!(config.font.font_face_css().is_none());
1309 }
1310
1311 #[test]
1312 fn local_font_has_no_stylesheet_url() {
1313 let mut config = SiteConfig::default();
1314 config.font.source = Some("fonts/MyFont.woff2".to_string());
1315 assert!(config.font.is_local());
1316 assert!(config.font.stylesheet_url().is_none());
1317 }
1318
1319 #[test]
1320 fn local_font_generates_font_face_css() {
1321 let mut config = SiteConfig::default();
1322 config.font.font = "My Custom Font".to_string();
1323 config.font.weight = "400".to_string();
1324 config.font.font_type = FontType::Sans;
1325 config.font.source = Some("fonts/MyFont.woff2".to_string());
1326 let css = config.font.font_face_css().unwrap();
1327 assert!(css.contains("@font-face"));
1328 assert!(css.contains(r#"font-family: "My Custom Font""#));
1329 assert!(css.contains(r#"url("/fonts/MyFont.woff2")"#));
1330 assert!(css.contains(r#"format("woff2")"#));
1331 assert!(css.contains("font-weight: 400"));
1332 assert!(css.contains("font-display: swap"));
1333 }
1334
1335 #[test]
1336 fn parse_font_with_source() {
1337 let tmp = TempDir::new().unwrap();
1338 write_config(
1339 tmp.path(),
1340 r#"
1341[font]
1342font = "My Font"
1343weight = "400"
1344source = "fonts/myfont.woff2"
1345"#,
1346 );
1347 let config = load_config(tmp.path()).unwrap();
1348 assert_eq!(config.font.font, "My Font");
1349 assert_eq!(config.font.source.as_deref(), Some("fonts/myfont.woff2"));
1350 assert!(config.font.is_local());
1351 }
1352
1353 #[test]
1354 fn parse_font_source_preserves_other_fields() {
1355 let tmp = TempDir::new().unwrap();
1356 write_config(
1357 tmp.path(),
1358 r#"
1359[font]
1360source = "fonts/custom.woff2"
1361"#,
1362 );
1363 let config = load_config(tmp.path()).unwrap();
1364 assert_eq!(config.font.font, "Noto Sans");
1365 assert_eq!(config.font.weight, "600");
1366 assert_eq!(config.font.source.as_deref(), Some("fonts/custom.woff2"));
1367 }
1368
1369 #[test]
1370 fn font_format_detection() {
1371 assert_eq!(font_format_from_extension("font.woff2"), "woff2");
1372 assert_eq!(font_format_from_extension("font.woff"), "woff");
1373 assert_eq!(font_format_from_extension("font.ttf"), "truetype");
1374 assert_eq!(font_format_from_extension("font.otf"), "opentype");
1375 assert_eq!(font_format_from_extension("font.unknown"), "woff2");
1376 }
1377
1378 #[test]
1379 fn generate_font_css_includes_font_face_for_local() {
1380 let mut config = SiteConfig::default();
1381 config.font.font = "Local Font".to_string();
1382 config.font.weight = "700".to_string();
1383 config.font.font_type = FontType::Serif;
1384 config.font.source = Some("fonts/local.woff2".to_string());
1385 let css = generate_font_css(&config.font);
1386 assert!(css.contains("@font-face"));
1387 assert!(css.contains("--font-family:"));
1388 assert!(css.contains("--font-weight: 700"));
1389 }
1390
1391 #[test]
1392 fn generate_font_css_no_font_face_for_google() {
1393 let config = SiteConfig::default();
1394 let css = generate_font_css(&config.font);
1395 assert!(!css.contains("@font-face"));
1396 assert!(css.contains("--font-family:"));
1397 }
1398
1399 #[test]
1402 fn unknown_key_rejected_via_load_config() {
1403 let tmp = TempDir::new().unwrap();
1404 write_config(
1405 tmp.path(),
1406 r#"
1407[images]
1408qualty = 90
1409"#,
1410 );
1411 let result = load_config(tmp.path());
1412 assert!(result.is_err());
1413 }
1414}