1use serde::{Deserialize, Serialize};
95use std::fs;
96use std::path::Path;
97use thiserror::Error;
98
99#[derive(Error, Debug)]
100pub enum ConfigError {
101 #[error("IO error: {0}")]
102 Io(#[from] std::io::Error),
103 #[error("TOML parse error: {0}")]
104 Toml(#[from] toml::de::Error),
105 #[error("Config validation error: {0}")]
106 Validation(String),
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
114#[serde(default, deny_unknown_fields)]
115pub struct SiteConfig {
116 #[serde(default = "default_site_title")]
118 pub site_title: String,
119 #[serde(default = "default_assets_dir")]
122 pub assets_dir: String,
123 #[serde(default = "default_site_description_file")]
126 pub site_description_file: String,
127 pub colors: ColorConfig,
129 pub thumbnails: ThumbnailsConfig,
131 pub images: ImagesConfig,
133 pub theme: ThemeConfig,
135 pub font: FontConfig,
137 pub processing: ProcessingConfig,
139}
140
141#[derive(Debug, Clone, Default, Deserialize)]
143#[serde(deny_unknown_fields)]
144pub struct PartialSiteConfig {
145 pub site_title: Option<String>,
146 pub assets_dir: Option<String>,
147 pub site_description_file: Option<String>,
148 pub colors: Option<PartialColorConfig>,
149 pub thumbnails: Option<PartialThumbnailsConfig>,
150 pub images: Option<PartialImagesConfig>,
151 pub theme: Option<PartialThemeConfig>,
152 pub font: Option<PartialFontConfig>,
153 pub processing: Option<PartialProcessingConfig>,
154}
155
156fn default_site_title() -> String {
157 "Gallery".to_string()
158}
159
160fn default_assets_dir() -> String {
161 "assets".to_string()
162}
163
164fn default_site_description_file() -> String {
165 "site".to_string()
166}
167
168impl Default for SiteConfig {
169 fn default() -> Self {
170 Self {
171 site_title: default_site_title(),
172 assets_dir: default_assets_dir(),
173 site_description_file: default_site_description_file(),
174 colors: ColorConfig::default(),
175 thumbnails: ThumbnailsConfig::default(),
176 images: ImagesConfig::default(),
177 theme: ThemeConfig::default(),
178 font: FontConfig::default(),
179 processing: ProcessingConfig::default(),
180 }
181 }
182}
183
184impl SiteConfig {
185 pub fn validate(&self) -> Result<(), ConfigError> {
187 if self.images.quality > 100 {
188 return Err(ConfigError::Validation(
189 "images.quality must be 0-100".into(),
190 ));
191 }
192 if self.thumbnails.aspect_ratio[0] == 0 || self.thumbnails.aspect_ratio[1] == 0 {
193 return Err(ConfigError::Validation(
194 "thumbnails.aspect_ratio values must be non-zero".into(),
195 ));
196 }
197 if self.images.sizes.is_empty() {
198 return Err(ConfigError::Validation(
199 "images.sizes must not be empty".into(),
200 ));
201 }
202 Ok(())
203 }
204
205 pub fn merge(mut self, other: PartialSiteConfig) -> Self {
207 if let Some(st) = other.site_title {
208 self.site_title = st;
209 }
210 if let Some(ad) = other.assets_dir {
211 self.assets_dir = ad;
212 }
213 if let Some(sd) = other.site_description_file {
214 self.site_description_file = sd;
215 }
216 if let Some(c) = other.colors {
217 self.colors = self.colors.merge(c);
218 }
219 if let Some(t) = other.thumbnails {
220 self.thumbnails = self.thumbnails.merge(t);
221 }
222 if let Some(i) = other.images {
223 self.images = self.images.merge(i);
224 }
225 if let Some(t) = other.theme {
226 self.theme = self.theme.merge(t);
227 }
228 if let Some(f) = other.font {
229 self.font = self.font.merge(f);
230 }
231 if let Some(p) = other.processing {
232 self.processing = self.processing.merge(p);
233 }
234 self
235 }
236}
237
238#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
240#[serde(default, deny_unknown_fields)]
241pub struct ProcessingConfig {
242 pub max_processes: Option<usize>,
246}
247
248#[derive(Debug, Clone, Default, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct PartialProcessingConfig {
251 pub max_processes: Option<usize>,
252}
253
254impl ProcessingConfig {
255 pub fn merge(mut self, other: PartialProcessingConfig) -> Self {
256 if other.max_processes.is_some() {
257 self.max_processes = other.max_processes;
258 }
259 self
260 }
261}
262
263#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
265#[serde(rename_all = "lowercase")]
266pub enum FontType {
267 #[default]
268 Sans,
269 Serif,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
294#[serde(default, deny_unknown_fields)]
295pub struct FontConfig {
296 pub font: String,
298 pub weight: String,
300 pub font_type: FontType,
302 #[serde(skip_serializing_if = "Option::is_none")]
306 pub source: Option<String>,
307}
308
309#[derive(Debug, Clone, Default, Deserialize)]
310#[serde(deny_unknown_fields)]
311pub struct PartialFontConfig {
312 pub font: Option<String>,
313 pub weight: Option<String>,
314 pub font_type: Option<FontType>,
315 pub source: Option<String>,
316}
317
318impl Default for FontConfig {
319 fn default() -> Self {
320 Self {
321 font: "Noto Sans".to_string(),
322 weight: "600".to_string(),
323 font_type: FontType::Sans,
324 source: None,
325 }
326 }
327}
328
329impl FontConfig {
330 pub fn merge(mut self, other: PartialFontConfig) -> Self {
331 if let Some(f) = other.font {
332 self.font = f;
333 }
334 if let Some(w) = other.weight {
335 self.weight = w;
336 }
337 if let Some(t) = other.font_type {
338 self.font_type = t;
339 }
340 if other.source.is_some() {
341 self.source = other.source;
342 }
343 self
344 }
345
346 pub fn is_local(&self) -> bool {
348 self.source.is_some()
349 }
350
351 pub fn stylesheet_url(&self) -> Option<String> {
354 if self.is_local() {
355 return None;
356 }
357 let family = self.font.replace(' ', "+");
358 Some(format!(
359 "https://fonts.googleapis.com/css2?family={}:wght@{}&display=swap",
360 family, self.weight
361 ))
362 }
363
364 pub fn font_face_css(&self) -> Option<String> {
367 let src = self.source.as_ref()?;
368 let format = font_format_from_extension(src);
369 Some(format!(
370 r#"@font-face {{
371 font-family: "{}";
372 src: url("/{}") format("{}");
373 font-weight: {};
374 font-display: swap;
375}}"#,
376 self.font, src, format, self.weight
377 ))
378 }
379
380 pub fn font_family_css(&self) -> String {
382 let fallbacks = match self.font_type {
383 FontType::Serif => r#"Georgia, "Times New Roman", serif"#,
384 FontType::Sans => "Helvetica, Verdana, sans-serif",
385 };
386 format!(r#""{}", {}"#, self.font, fallbacks)
387 }
388}
389
390fn font_format_from_extension(path: &str) -> &'static str {
392 match path.rsplit('.').next().map(|e| e.to_lowercase()).as_deref() {
393 Some("woff2") => "woff2",
394 Some("woff") => "woff",
395 Some("ttf") => "truetype",
396 Some("otf") => "opentype",
397 _ => "woff2", }
399}
400
401pub fn effective_threads(config: &ProcessingConfig) -> usize {
406 let cores = std::thread::available_parallelism()
407 .map(|n| n.get())
408 .unwrap_or(1);
409 config.max_processes.map(|n| n.min(cores)).unwrap_or(cores)
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
414#[serde(default, deny_unknown_fields)]
415pub struct ThumbnailsConfig {
416 pub aspect_ratio: [u32; 2],
418 pub size: u32,
420}
421
422#[derive(Debug, Clone, Default, Deserialize)]
423#[serde(deny_unknown_fields)]
424pub struct PartialThumbnailsConfig {
425 pub aspect_ratio: Option<[u32; 2]>,
426 pub size: Option<u32>,
427}
428
429impl ThumbnailsConfig {
430 pub fn merge(mut self, other: PartialThumbnailsConfig) -> Self {
431 if let Some(ar) = other.aspect_ratio {
432 self.aspect_ratio = ar;
433 }
434 if let Some(s) = other.size {
435 self.size = s;
436 }
437 self
438 }
439}
440
441impl Default for ThumbnailsConfig {
442 fn default() -> Self {
443 Self {
444 aspect_ratio: [4, 5],
445 size: 400,
446 }
447 }
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
452#[serde(default, deny_unknown_fields)]
453pub struct ImagesConfig {
454 pub sizes: Vec<u32>,
456 pub quality: u32,
458}
459
460#[derive(Debug, Clone, Default, Deserialize)]
461#[serde(deny_unknown_fields)]
462pub struct PartialImagesConfig {
463 pub sizes: Option<Vec<u32>>,
464 pub quality: Option<u32>,
465}
466
467impl ImagesConfig {
468 pub fn merge(mut self, other: PartialImagesConfig) -> Self {
469 if let Some(s) = other.sizes {
470 self.sizes = s;
471 }
472 if let Some(q) = other.quality {
473 self.quality = q;
474 }
475 self
476 }
477}
478
479impl Default for ImagesConfig {
480 fn default() -> Self {
481 Self {
482 sizes: vec![800, 1400, 2080],
483 quality: 90,
484 }
485 }
486}
487
488#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
496#[serde(deny_unknown_fields)]
497pub struct ClampSize {
498 pub size: String,
500 pub min: String,
502 pub max: String,
504}
505
506#[derive(Debug, Clone, Default, Deserialize)]
507#[serde(deny_unknown_fields)]
508pub struct PartialClampSize {
509 pub size: Option<String>,
510 pub min: Option<String>,
511 pub max: Option<String>,
512}
513
514impl ClampSize {
515 pub fn merge(mut self, other: PartialClampSize) -> Self {
516 if let Some(s) = other.size {
517 self.size = s;
518 }
519 if let Some(m) = other.min {
520 self.min = m;
521 }
522 if let Some(m) = other.max {
523 self.max = m;
524 }
525 self
526 }
527}
528
529impl ClampSize {
530 pub fn to_css(&self) -> String {
532 format!("clamp({}, {}, {})", self.min, self.size, self.max)
533 }
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
538#[serde(default, deny_unknown_fields)]
539pub struct ThemeConfig {
540 pub mat_x: ClampSize,
542 pub mat_y: ClampSize,
544 pub thumbnail_gap: String,
546 pub grid_padding: String,
548}
549
550#[derive(Debug, Clone, Default, Deserialize)]
551#[serde(deny_unknown_fields)]
552pub struct PartialThemeConfig {
553 pub mat_x: Option<PartialClampSize>,
554 pub mat_y: Option<PartialClampSize>,
555 pub thumbnail_gap: Option<String>,
556 pub grid_padding: Option<String>,
557}
558
559impl ThemeConfig {
560 pub fn merge(mut self, other: PartialThemeConfig) -> Self {
561 if let Some(x) = other.mat_x {
562 self.mat_x = self.mat_x.merge(x);
563 }
564 if let Some(y) = other.mat_y {
565 self.mat_y = self.mat_y.merge(y);
566 }
567 if let Some(g) = other.thumbnail_gap {
568 self.thumbnail_gap = g;
569 }
570 if let Some(p) = other.grid_padding {
571 self.grid_padding = p;
572 }
573 self
574 }
575}
576
577impl Default for ThemeConfig {
578 fn default() -> Self {
579 Self {
580 mat_x: ClampSize {
581 size: "3vw".to_string(),
582 min: "1rem".to_string(),
583 max: "2.5rem".to_string(),
584 },
585 mat_y: ClampSize {
586 size: "6vw".to_string(),
587 min: "2rem".to_string(),
588 max: "5rem".to_string(),
589 },
590 thumbnail_gap: "0.2rem".to_string(),
591 grid_padding: "2rem".to_string(),
592 }
593 }
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
598#[serde(default, deny_unknown_fields)]
599pub struct ColorConfig {
600 pub light: ColorScheme,
602 pub dark: ColorScheme,
604}
605
606#[derive(Debug, Clone, Default, Deserialize)]
607#[serde(deny_unknown_fields)]
608pub struct PartialColorConfig {
609 pub light: Option<PartialColorScheme>,
610 pub dark: Option<PartialColorScheme>,
611}
612
613impl ColorConfig {
614 pub fn merge(mut self, other: PartialColorConfig) -> Self {
615 if let Some(l) = other.light {
616 self.light = self.light.merge(l);
617 }
618 if let Some(d) = other.dark {
619 self.dark = self.dark.merge(d);
620 }
621 self
622 }
623}
624
625impl Default for ColorConfig {
626 fn default() -> Self {
627 Self {
628 light: ColorScheme::default_light(),
629 dark: ColorScheme::default_dark(),
630 }
631 }
632}
633
634#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
636#[serde(default, deny_unknown_fields)]
637pub struct ColorScheme {
638 pub background: String,
640 pub text: String,
642 pub text_muted: String,
644 pub border: String,
646 pub separator: String,
648 pub link: String,
650 pub link_hover: String,
652}
653
654#[derive(Debug, Clone, Default, Deserialize)]
655#[serde(deny_unknown_fields)]
656pub struct PartialColorScheme {
657 pub background: Option<String>,
658 pub text: Option<String>,
659 pub text_muted: Option<String>,
660 pub border: Option<String>,
661 pub separator: Option<String>,
662 pub link: Option<String>,
663 pub link_hover: Option<String>,
664}
665
666impl ColorScheme {
667 pub fn merge(mut self, other: PartialColorScheme) -> Self {
668 if let Some(v) = other.background {
669 self.background = v;
670 }
671 if let Some(v) = other.text {
672 self.text = v;
673 }
674 if let Some(v) = other.text_muted {
675 self.text_muted = v;
676 }
677 if let Some(v) = other.border {
678 self.border = v;
679 }
680 if let Some(v) = other.separator {
681 self.separator = v;
682 }
683 if let Some(v) = other.link {
684 self.link = v;
685 }
686 if let Some(v) = other.link_hover {
687 self.link_hover = v;
688 }
689 self
690 }
691}
692
693impl ColorScheme {
694 pub fn default_light() -> Self {
695 Self {
696 background: "#ffffff".to_string(),
697 text: "#111111".to_string(),
698 text_muted: "#666666".to_string(),
699 border: "#e0e0e0".to_string(),
700 separator: "#e0e0e0".to_string(),
701 link: "#333333".to_string(),
702 link_hover: "#000000".to_string(),
703 }
704 }
705
706 pub fn default_dark() -> Self {
707 Self {
708 background: "#000000".to_string(),
709 text: "#fafafa".to_string(),
710 text_muted: "#999999".to_string(),
711 border: "#333333".to_string(),
712 separator: "#333333".to_string(),
713 link: "#cccccc".to_string(),
714 link_hover: "#ffffff".to_string(),
715 }
716 }
717}
718
719impl Default for ColorScheme {
720 fn default() -> Self {
721 Self::default_light()
722 }
723}
724
725pub fn load_partial_config(path: &Path) -> Result<Option<PartialSiteConfig>, ConfigError> {
734 let config_path = path.join("config.toml");
735 if !config_path.exists() {
736 return Ok(None);
737 }
738 let content = fs::read_to_string(&config_path)?;
739 let partial: PartialSiteConfig = toml::from_str(&content)?;
740 Ok(Some(partial))
741}
742
743pub fn load_config(root: &Path) -> Result<SiteConfig, ConfigError> {
745 let base = SiteConfig::default();
746 let partial = load_partial_config(root)?;
747 if let Some(p) = partial {
748 let merged = base.merge(p);
749 merged.validate()?;
750 Ok(merged)
751 } else {
752 Ok(base)
753 }
754}
755
756pub fn stock_config_toml() -> &'static str {
760 r##"# Simple Gal Configuration
761# ========================
762# All settings are optional. Remove or comment out any you don't need.
763# Values shown below are the defaults.
764#
765# Config files can be placed at any level of the directory tree:
766# content/config.toml -> root (overrides stock defaults)
767# content/020-Travel/config.toml -> group (overrides root)
768# content/020-Travel/010-Japan/config.toml -> gallery (overrides group)
769#
770# Each level only needs the keys it wants to override.
771# Unknown keys will cause an error.
772
773# Site title shown in breadcrumbs and the browser tab for the home page.
774site_title = "Gallery"
775
776# Directory for static assets (favicon, fonts, etc.), relative to content root.
777# Contents are copied verbatim to the output root during generation.
778# If the directory doesn't exist, it is silently skipped.
779assets_dir = "assets"
780
781# Stem of the site description file in the content root.
782# If site.md or site.txt exists, its content is rendered on the index page.
783# site_description_file = "site"
784
785# ---------------------------------------------------------------------------
786# Thumbnail generation
787# ---------------------------------------------------------------------------
788[thumbnails]
789# Aspect ratio as [width, height] for thumbnail crops.
790# Common choices: [1, 1] for square, [4, 5] for portrait, [3, 2] for landscape.
791aspect_ratio = [4, 5]
792
793# Short-edge size in pixels for generated thumbnails.
794size = 400
795
796# ---------------------------------------------------------------------------
797# Responsive image generation
798# ---------------------------------------------------------------------------
799[images]
800# Pixel widths (longer edge) to generate for responsive <picture> elements.
801sizes = [800, 1400, 2080]
802
803# AVIF encoding quality (0 = worst, 100 = best).
804quality = 90
805
806# ---------------------------------------------------------------------------
807# Theme / layout
808# ---------------------------------------------------------------------------
809[theme]
810# Gap between thumbnails in album and image grids (CSS value).
811thumbnail_gap = "0.2rem"
812
813# Padding around the thumbnail grid container (CSS value).
814grid_padding = "2rem"
815
816# Horizontal mat around images, as CSS clamp(min, size, max).
817# See docs/dev/photo-page-layout.md for the layout spec.
818[theme.mat_x]
819size = "3vw"
820min = "1rem"
821max = "2.5rem"
822
823# Vertical mat around images, as CSS clamp(min, size, max).
824[theme.mat_y]
825size = "6vw"
826min = "2rem"
827max = "5rem"
828
829# ---------------------------------------------------------------------------
830# Colors - Light mode (prefers-color-scheme: light)
831# ---------------------------------------------------------------------------
832[colors.light]
833background = "#ffffff"
834text = "#111111"
835text_muted = "#666666" # Nav, breadcrumbs, captions
836border = "#e0e0e0"
837separator = "#e0e0e0" # Header underline, nav menu divider
838link = "#333333"
839link_hover = "#000000"
840
841# ---------------------------------------------------------------------------
842# Colors - Dark mode (prefers-color-scheme: dark)
843# ---------------------------------------------------------------------------
844[colors.dark]
845background = "#000000"
846text = "#fafafa"
847text_muted = "#999999"
848border = "#333333"
849separator = "#333333" # Header underline, nav menu divider
850link = "#cccccc"
851link_hover = "#ffffff"
852
853# ---------------------------------------------------------------------------
854# Font
855# ---------------------------------------------------------------------------
856[font]
857# Google Fonts family name.
858font = "Noto Sans"
859
860# Font weight to load from Google Fonts.
861weight = "600"
862
863# Font category: "sans" or "serif". Determines fallback fonts in the CSS stack.
864# sans -> Helvetica, Verdana, sans-serif
865# serif -> Georgia, "Times New Roman", serif
866font_type = "sans"
867
868# Local font file path, relative to the site root (e.g. "fonts/MyFont.woff2").
869# When set, generates @font-face CSS instead of loading from Google Fonts.
870# Place the font file in your assets directory so it gets copied to the output.
871# Supported formats: .woff2, .woff, .ttf, .otf
872# source = "fonts/MyFont.woff2"
873
874# ---------------------------------------------------------------------------
875# Processing
876# ---------------------------------------------------------------------------
877[processing]
878# Maximum parallel image-processing workers.
879# Omit or comment out to auto-detect (= number of CPU cores).
880# max_processes = 4
881
882# ---------------------------------------------------------------------------
883# Custom CSS & HTML Snippets
884# ---------------------------------------------------------------------------
885# Drop any of these files into your assets/ directory to inject custom content.
886# No configuration needed — the files are detected automatically.
887#
888# assets/custom.css → <link rel="stylesheet"> after main styles (CSS overrides)
889# assets/head.html → raw HTML at the end of <head> (analytics, meta tags)
890# assets/body-end.html → raw HTML before </body> (tracking scripts, widgets)
891"##
892}
893
894pub fn generate_color_css(colors: &ColorConfig) -> String {
902 format!(
903 r#":root {{
904 --color-bg: {light_bg};
905 --color-text: {light_text};
906 --color-text-muted: {light_text_muted};
907 --color-border: {light_border};
908 --color-link: {light_link};
909 --color-link-hover: {light_link_hover};
910 --color-separator: {light_separator};
911}}
912
913@media (prefers-color-scheme: dark) {{
914 :root {{
915 --color-bg: {dark_bg};
916 --color-text: {dark_text};
917 --color-text-muted: {dark_text_muted};
918 --color-border: {dark_border};
919 --color-link: {dark_link};
920 --color-link-hover: {dark_link_hover};
921 --color-separator: {dark_separator};
922 }}
923}}"#,
924 light_bg = colors.light.background,
925 light_text = colors.light.text,
926 light_text_muted = colors.light.text_muted,
927 light_border = colors.light.border,
928 light_separator = colors.light.separator,
929 light_link = colors.light.link,
930 light_link_hover = colors.light.link_hover,
931 dark_bg = colors.dark.background,
932 dark_text = colors.dark.text,
933 dark_text_muted = colors.dark.text_muted,
934 dark_border = colors.dark.border,
935 dark_separator = colors.dark.separator,
936 dark_link = colors.dark.link,
937 dark_link_hover = colors.dark.link_hover,
938 )
939}
940
941pub fn generate_theme_css(theme: &ThemeConfig) -> String {
943 format!(
944 r#":root {{
945 --mat-x: {mat_x};
946 --mat-y: {mat_y};
947 --thumbnail-gap: {thumbnail_gap};
948 --grid-padding: {grid_padding};
949}}"#,
950 mat_x = theme.mat_x.to_css(),
951 mat_y = theme.mat_y.to_css(),
952 thumbnail_gap = theme.thumbnail_gap,
953 grid_padding = theme.grid_padding,
954 )
955}
956
957pub fn generate_font_css(font: &FontConfig) -> String {
961 let vars = format!(
962 r#":root {{
963 --font-family: {family};
964 --font-weight: {weight};
965}}"#,
966 family = font.font_family_css(),
967 weight = font.weight,
968 );
969 match font.font_face_css() {
970 Some(face) => format!("{}\n\n{}", face, vars),
971 None => vars,
972 }
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978 use tempfile::TempDir;
979
980 #[test]
981 fn default_config_has_colors() {
982 let config = SiteConfig::default();
983 assert_eq!(config.colors.light.background, "#ffffff");
984 assert_eq!(config.colors.dark.background, "#000000");
985 }
986
987 #[test]
988 fn default_config_has_site_title() {
989 let config = SiteConfig::default();
990 assert_eq!(config.site_title, "Gallery");
991 }
992
993 #[test]
994 fn parse_custom_site_title() {
995 let toml = r#"site_title = "My Portfolio""#;
996 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
997 let config = SiteConfig::default().merge(partial);
998 assert_eq!(config.site_title, "My Portfolio");
999 }
1000
1001 #[test]
1002 fn default_config_has_image_settings() {
1003 let config = SiteConfig::default();
1004 assert_eq!(config.thumbnails.aspect_ratio, [4, 5]);
1005 assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
1006 assert_eq!(config.images.quality, 90);
1007 assert_eq!(config.theme.mat_x.to_css(), "clamp(1rem, 3vw, 2.5rem)");
1008 assert_eq!(config.theme.mat_y.to_css(), "clamp(2rem, 6vw, 5rem)");
1009 }
1010
1011 #[test]
1012 fn parse_partial_config() {
1013 let toml = r##"
1014[colors.light]
1015background = "#fafafa"
1016"##;
1017 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1018 let config = SiteConfig::default().merge(partial);
1019
1020 assert_eq!(config.colors.light.background, "#fafafa");
1022 assert_eq!(config.colors.light.text, "#111111");
1024 assert_eq!(config.colors.dark.background, "#000000");
1025 assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
1027 }
1028
1029 #[test]
1030 fn parse_image_settings() {
1031 let toml = r##"
1032[thumbnails]
1033aspect_ratio = [1, 1]
1034
1035[images]
1036sizes = [400, 800]
1037quality = 85
1038"##;
1039 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1040 let config = SiteConfig::default().merge(partial);
1041
1042 assert_eq!(config.thumbnails.aspect_ratio, [1, 1]);
1043 assert_eq!(config.images.sizes, vec![400, 800]);
1044 assert_eq!(config.images.quality, 85);
1045 assert_eq!(config.colors.light.background, "#ffffff");
1047 }
1048
1049 #[test]
1050 fn generate_css_uses_config_colors() {
1051 let mut colors = ColorConfig::default();
1052 colors.light.background = "#f0f0f0".to_string();
1053 colors.dark.background = "#1a1a1a".to_string();
1054
1055 let css = generate_color_css(&colors);
1056 assert!(css.contains("--color-bg: #f0f0f0"));
1057 assert!(css.contains("--color-bg: #1a1a1a"));
1058 }
1059
1060 #[test]
1065 fn load_config_returns_default_when_no_file() {
1066 let tmp = TempDir::new().unwrap();
1067 let config = load_config(tmp.path()).unwrap();
1068
1069 assert_eq!(config.colors.light.background, "#ffffff");
1070 assert_eq!(config.colors.dark.background, "#000000");
1071 }
1072
1073 #[test]
1074 fn load_config_reads_file() {
1075 let tmp = TempDir::new().unwrap();
1076 let config_path = tmp.path().join("config.toml");
1077
1078 fs::write(
1079 &config_path,
1080 r##"
1081[colors.light]
1082background = "#123456"
1083text = "#abcdef"
1084"##,
1085 )
1086 .unwrap();
1087
1088 let config = load_config(tmp.path()).unwrap();
1089 assert_eq!(config.colors.light.background, "#123456");
1090 assert_eq!(config.colors.light.text, "#abcdef");
1091 assert_eq!(config.colors.dark.background, "#000000");
1093 }
1094
1095 #[test]
1096 fn load_config_full_config() {
1097 let tmp = TempDir::new().unwrap();
1098 let config_path = tmp.path().join("config.toml");
1099
1100 fs::write(
1101 &config_path,
1102 r##"
1103[colors.light]
1104background = "#fff"
1105text = "#000"
1106text_muted = "#666"
1107border = "#ccc"
1108link = "#00f"
1109link_hover = "#f00"
1110
1111[colors.dark]
1112background = "#111"
1113text = "#eee"
1114text_muted = "#888"
1115border = "#444"
1116link = "#88f"
1117link_hover = "#f88"
1118"##,
1119 )
1120 .unwrap();
1121
1122 let config = load_config(tmp.path()).unwrap();
1123
1124 assert_eq!(config.colors.light.background, "#fff");
1126 assert_eq!(config.colors.light.text, "#000");
1127 assert_eq!(config.colors.light.link, "#00f");
1128
1129 assert_eq!(config.colors.dark.background, "#111");
1131 assert_eq!(config.colors.dark.text, "#eee");
1132 assert_eq!(config.colors.dark.link, "#88f");
1133 }
1134
1135 #[test]
1136 fn load_config_invalid_toml_is_error() {
1137 let tmp = TempDir::new().unwrap();
1138 let config_path = tmp.path().join("config.toml");
1139
1140 fs::write(&config_path, "this is not valid toml [[[").unwrap();
1141
1142 let result = load_config(tmp.path());
1143 assert!(matches!(result, Err(ConfigError::Toml(_))));
1144 }
1145
1146 #[test]
1147 fn load_config_unknown_keys_is_error() {
1148 let tmp = TempDir::new().unwrap();
1149 let config_path = tmp.path().join("config.toml");
1150
1151 fs::write(
1153 &config_path,
1154 r#"
1155 unknown_key = "foo"
1156 "#,
1157 )
1158 .unwrap();
1159
1160 let result = load_config(tmp.path());
1161 assert!(matches!(result, Err(ConfigError::Toml(_))));
1162 }
1163
1164 #[test]
1169 fn generate_css_includes_all_variables() {
1170 let colors = ColorConfig::default();
1171 let css = generate_color_css(&colors);
1172
1173 assert!(css.contains("--color-bg:"));
1175 assert!(css.contains("--color-text:"));
1176 assert!(css.contains("--color-text-muted:"));
1177 assert!(css.contains("--color-border:"));
1178 assert!(css.contains("--color-link:"));
1179 assert!(css.contains("--color-link-hover:"));
1180 }
1181
1182 #[test]
1183 fn generate_css_includes_dark_mode_media_query() {
1184 let colors = ColorConfig::default();
1185 let css = generate_color_css(&colors);
1186
1187 assert!(css.contains("@media (prefers-color-scheme: dark)"));
1188 }
1189
1190 #[test]
1191 fn color_scheme_default_is_light() {
1192 let scheme = ColorScheme::default();
1193 assert_eq!(scheme.background, "#ffffff");
1194 }
1195
1196 #[test]
1197 fn clamp_size_to_css() {
1198 let size = ClampSize {
1199 size: "3vw".to_string(),
1200 min: "1rem".to_string(),
1201 max: "2.5rem".to_string(),
1202 };
1203 assert_eq!(size.to_css(), "clamp(1rem, 3vw, 2.5rem)");
1204 }
1205
1206 #[test]
1207 fn generate_theme_css_includes_mat_variables() {
1208 let theme = ThemeConfig::default();
1209 let css = generate_theme_css(&theme);
1210 assert!(css.contains("--mat-x: clamp(1rem, 3vw, 2.5rem)"));
1211 assert!(css.contains("--mat-y: clamp(2rem, 6vw, 5rem)"));
1212 assert!(css.contains("--thumbnail-gap: 0.2rem"));
1213 assert!(css.contains("--grid-padding: 2rem"));
1214 }
1215
1216 #[test]
1217 fn parse_thumbnail_gap_and_grid_padding() {
1218 let toml = r#"
1219[theme]
1220thumbnail_gap = "0.5rem"
1221grid_padding = "1rem"
1222"#;
1223 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1224 let config = SiteConfig::default().merge(partial);
1225 assert_eq!(config.theme.thumbnail_gap, "0.5rem");
1226 assert_eq!(config.theme.grid_padding, "1rem");
1227 }
1228
1229 #[test]
1230 fn default_thumbnail_gap_and_grid_padding() {
1231 let config = SiteConfig::default();
1232 assert_eq!(config.theme.thumbnail_gap, "0.2rem");
1233 assert_eq!(config.theme.grid_padding, "2rem");
1234 }
1235
1236 #[test]
1241 fn default_processing_config() {
1242 let config = ProcessingConfig::default();
1243 assert_eq!(config.max_processes, None);
1244 }
1245
1246 #[test]
1247 fn effective_threads_auto() {
1248 let config = ProcessingConfig {
1249 max_processes: None,
1250 };
1251 let threads = effective_threads(&config);
1252 let cores = std::thread::available_parallelism()
1253 .map(|n| n.get())
1254 .unwrap_or(1);
1255 assert_eq!(threads, cores);
1256 }
1257
1258 #[test]
1259 fn effective_threads_clamped_to_cores() {
1260 let config = ProcessingConfig {
1261 max_processes: Some(99999),
1262 };
1263 let threads = effective_threads(&config);
1264 let cores = std::thread::available_parallelism()
1265 .map(|n| n.get())
1266 .unwrap_or(1);
1267 assert_eq!(threads, cores);
1268 }
1269
1270 #[test]
1271 fn effective_threads_user_constrains_down() {
1272 let config = ProcessingConfig {
1273 max_processes: Some(1),
1274 };
1275 assert_eq!(effective_threads(&config), 1);
1276 }
1277
1278 #[test]
1279 fn parse_processing_config() {
1280 let toml = r#"
1281[processing]
1282max_processes = 4
1283"#;
1284 let config: SiteConfig = toml::from_str(toml).unwrap();
1285 assert_eq!(config.processing.max_processes, Some(4));
1286 }
1287
1288 #[test]
1289 fn parse_config_without_processing_uses_default() {
1290 let toml = r##"
1291[colors.light]
1292background = "#fafafa"
1293"##;
1294 let config: SiteConfig = toml::from_str(toml).unwrap();
1295 assert_eq!(config.processing.max_processes, None);
1296 }
1297
1298 #[test]
1307 fn unknown_key_rejected() {
1308 let toml_str = r#"
1309[images]
1310qualty = 90
1311"#;
1312 let result: Result<SiteConfig, _> = toml::from_str(toml_str);
1313 assert!(result.is_err());
1314 let err = result.unwrap_err().to_string();
1315 assert!(err.contains("unknown field"));
1316 }
1317
1318 #[test]
1319 fn unknown_section_rejected() {
1320 let toml_str = r#"
1321[imagez]
1322quality = 90
1323"#;
1324 let result: Result<SiteConfig, _> = toml::from_str(toml_str);
1325 assert!(result.is_err());
1326 }
1327
1328 #[test]
1329 fn unknown_nested_key_rejected() {
1330 let toml_str = r##"
1331[colors.light]
1332bg = "#fff"
1333"##;
1334 let result: Result<SiteConfig, _> = toml::from_str(toml_str);
1335 assert!(result.is_err());
1336 }
1337
1338 #[test]
1339 fn unknown_key_rejected_via_load_config() {
1340 let tmp = TempDir::new().unwrap();
1341 fs::write(
1342 tmp.path().join("config.toml"),
1343 r#"
1344[images]
1345qualty = 90
1346"#,
1347 )
1348 .unwrap();
1349
1350 let result = load_config(tmp.path());
1351 assert!(result.is_err());
1352 }
1353
1354 #[test]
1359 fn validate_quality_boundary_ok() {
1360 let mut config = SiteConfig::default();
1361 config.images.quality = 100;
1362 assert!(config.validate().is_ok());
1363
1364 config.images.quality = 0;
1365 assert!(config.validate().is_ok());
1366 }
1367
1368 #[test]
1369 fn validate_quality_too_high() {
1370 let mut config = SiteConfig::default();
1371 config.images.quality = 101;
1372 let err = config.validate().unwrap_err();
1373 assert!(err.to_string().contains("quality"));
1374 }
1375
1376 #[test]
1377 fn validate_aspect_ratio_zero() {
1378 let mut config = SiteConfig::default();
1379 config.thumbnails.aspect_ratio = [0, 5];
1380 assert!(config.validate().is_err());
1381
1382 config.thumbnails.aspect_ratio = [4, 0];
1383 assert!(config.validate().is_err());
1384 }
1385
1386 #[test]
1387 fn validate_sizes_empty() {
1388 let mut config = SiteConfig::default();
1389 config.images.sizes = vec![];
1390 assert!(config.validate().is_err());
1391 }
1392
1393 #[test]
1394 fn validate_default_config_passes() {
1395 let config = SiteConfig::default();
1396 assert!(config.validate().is_ok());
1397 }
1398
1399 #[test]
1400 fn load_config_validates_values() {
1401 let tmp = TempDir::new().unwrap();
1402 fs::write(
1403 tmp.path().join("config.toml"),
1404 r#"
1405[images]
1406quality = 200
1407"#,
1408 )
1409 .unwrap();
1410
1411 let result = load_config(tmp.path());
1412 assert!(matches!(result, Err(ConfigError::Validation(_))));
1413 }
1414
1415 #[test]
1420 fn load_partial_config_returns_none_when_no_file() {
1421 let tmp = TempDir::new().unwrap();
1422 let result = load_partial_config(tmp.path()).unwrap();
1423 assert!(result.is_none());
1424 }
1425
1426 #[test]
1427 fn load_partial_config_returns_value_when_file_exists() {
1428 let tmp = TempDir::new().unwrap();
1429 fs::write(
1430 tmp.path().join("config.toml"),
1431 r#"
1432[images]
1433quality = 85
1434"#,
1435 )
1436 .unwrap();
1437
1438 let result = load_partial_config(tmp.path()).unwrap();
1439 assert!(result.is_some());
1440 let partial = result.unwrap();
1441 assert_eq!(partial.images.unwrap().quality, Some(85));
1442 }
1443
1444 #[test]
1445 fn merge_with_no_overlay() {
1446 let base = SiteConfig::default();
1447 let config = base.merge(PartialSiteConfig::default());
1448 assert_eq!(config.images.quality, 90);
1449 assert_eq!(config.colors.light.background, "#ffffff");
1450 }
1451
1452 #[test]
1453 fn merge_with_overlay() {
1454 let base = SiteConfig::default();
1455 let toml = r#"
1456[images]
1457quality = 70
1458"#;
1459 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1460 let config = base.merge(partial);
1461 assert_eq!(config.images.quality, 70);
1462 assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
1464 }
1465
1466 #[test]
1467 fn load_config_validates_after_merge() {
1468 let tmp = TempDir::new().unwrap();
1469 fs::write(
1471 tmp.path().join("config.toml"),
1472 r#"
1473[images]
1474quality = 200
1475"#,
1476 )
1477 .unwrap();
1478
1479 let result = load_config(tmp.path());
1481 assert!(matches!(result, Err(ConfigError::Validation(_))));
1482 }
1483
1484 #[test]
1489 fn stock_config_toml_is_valid_toml() {
1490 let content = stock_config_toml();
1491 let _: toml::Value = toml::from_str(content).expect("stock config must be valid TOML");
1492 }
1493
1494 #[test]
1495 fn stock_config_toml_roundtrips_to_defaults() {
1496 let content = stock_config_toml();
1497 let config: SiteConfig = toml::from_str(content).unwrap();
1498 assert_eq!(config.images.quality, 90);
1499 assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
1500 assert_eq!(config.thumbnails.aspect_ratio, [4, 5]);
1501 assert_eq!(config.colors.light.background, "#ffffff");
1502 assert_eq!(config.colors.dark.background, "#000000");
1503 assert_eq!(config.theme.thumbnail_gap, "0.2rem");
1504 }
1505
1506 #[test]
1507 fn stock_config_toml_contains_all_sections() {
1508 let content = stock_config_toml();
1509 assert!(content.contains("[thumbnails]"));
1510 assert!(content.contains("[images]"));
1511 assert!(content.contains("[theme]"));
1512 assert!(content.contains("[theme.mat_x]"));
1513 assert!(content.contains("[theme.mat_y]"));
1514 assert!(content.contains("[colors.light]"));
1515 assert!(content.contains("[colors.dark]"));
1516 assert!(content.contains("[processing]"));
1517 }
1518
1519 #[test]
1520 fn stock_defaults_equivalent_to_default_trait() {
1521 let config = SiteConfig::default();
1523 assert_eq!(config.images.quality, 90);
1524 }
1525
1526 #[test]
1531 fn merge_partial_theme_mat_x_only() {
1532 let partial: PartialSiteConfig = toml::from_str(
1533 r#"
1534 [theme.mat_x]
1535 size = "5vw"
1536 "#,
1537 )
1538 .unwrap();
1539 let config = SiteConfig::default().merge(partial);
1540
1541 assert_eq!(config.theme.mat_x.size, "5vw");
1543 assert_eq!(config.theme.mat_x.min, "1rem");
1545 assert_eq!(config.theme.mat_x.max, "2.5rem");
1546 assert_eq!(config.theme.mat_y.size, "6vw");
1548 assert_eq!(config.theme.mat_y.min, "2rem");
1549 assert_eq!(config.theme.mat_y.max, "5rem");
1550 assert_eq!(config.theme.thumbnail_gap, "0.2rem");
1552 assert_eq!(config.theme.grid_padding, "2rem");
1553 }
1554
1555 #[test]
1556 fn merge_partial_colors_light_only() {
1557 let partial: PartialSiteConfig = toml::from_str(
1558 r##"
1559 [colors.light]
1560 background = "#fafafa"
1561 text = "#222222"
1562 "##,
1563 )
1564 .unwrap();
1565 let config = SiteConfig::default().merge(partial);
1566
1567 assert_eq!(config.colors.light.background, "#fafafa");
1569 assert_eq!(config.colors.light.text, "#222222");
1570 assert_eq!(config.colors.light.text_muted, "#666666");
1572 assert_eq!(config.colors.light.border, "#e0e0e0");
1573 assert_eq!(config.colors.light.link, "#333333");
1574 assert_eq!(config.colors.light.link_hover, "#000000");
1575 assert_eq!(config.colors.dark.background, "#000000");
1577 assert_eq!(config.colors.dark.text, "#fafafa");
1578 }
1579
1580 #[test]
1581 fn merge_partial_font_weight_only() {
1582 let partial: PartialSiteConfig = toml::from_str(
1583 r#"
1584 [font]
1585 weight = "300"
1586 "#,
1587 )
1588 .unwrap();
1589 let config = SiteConfig::default().merge(partial);
1590
1591 assert_eq!(config.font.weight, "300");
1592 assert_eq!(config.font.font, "Noto Sans");
1593 assert_eq!(config.font.font_type, FontType::Sans);
1594 }
1595
1596 #[test]
1597 fn merge_multiple_sections_independently() {
1598 let partial: PartialSiteConfig = toml::from_str(
1599 r##"
1600 [colors.dark]
1601 background = "#1a1a1a"
1602
1603 [font]
1604 font = "Lora"
1605 font_type = "serif"
1606 "##,
1607 )
1608 .unwrap();
1609 let config = SiteConfig::default().merge(partial);
1610
1611 assert_eq!(config.colors.dark.background, "#1a1a1a");
1613 assert_eq!(config.colors.dark.text, "#fafafa");
1614 assert_eq!(config.colors.light.background, "#ffffff");
1615
1616 assert_eq!(config.font.font, "Lora");
1617 assert_eq!(config.font.font_type, FontType::Serif);
1618 assert_eq!(config.font.weight, "600"); assert_eq!(config.images.quality, 90);
1622 assert_eq!(config.thumbnails.aspect_ratio, [4, 5]);
1623 assert_eq!(config.theme.mat_x.size, "3vw");
1624 }
1625
1626 #[test]
1631 fn default_assets_dir() {
1632 let config = SiteConfig::default();
1633 assert_eq!(config.assets_dir, "assets");
1634 }
1635
1636 #[test]
1637 fn parse_custom_assets_dir() {
1638 let toml = r#"assets_dir = "site-assets""#;
1639 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1640 let config = SiteConfig::default().merge(partial);
1641 assert_eq!(config.assets_dir, "site-assets");
1642 }
1643
1644 #[test]
1645 fn merge_preserves_default_assets_dir() {
1646 let partial: PartialSiteConfig = toml::from_str("[images]\nquality = 70\n").unwrap();
1647 let config = SiteConfig::default().merge(partial);
1648 assert_eq!(config.assets_dir, "assets");
1649 }
1650
1651 #[test]
1656 fn default_site_description_file() {
1657 let config = SiteConfig::default();
1658 assert_eq!(config.site_description_file, "site");
1659 }
1660
1661 #[test]
1662 fn parse_custom_site_description_file() {
1663 let toml = r#"site_description_file = "intro""#;
1664 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1665 let config = SiteConfig::default().merge(partial);
1666 assert_eq!(config.site_description_file, "intro");
1667 }
1668
1669 #[test]
1670 fn merge_preserves_default_site_description_file() {
1671 let partial: PartialSiteConfig = toml::from_str("[images]\nquality = 70\n").unwrap();
1672 let config = SiteConfig::default().merge(partial);
1673 assert_eq!(config.site_description_file, "site");
1674 }
1675
1676 #[test]
1681 fn default_font_is_google() {
1682 let config = FontConfig::default();
1683 assert!(!config.is_local());
1684 assert!(config.stylesheet_url().is_some());
1685 assert!(config.font_face_css().is_none());
1686 }
1687
1688 #[test]
1689 fn local_font_has_no_stylesheet_url() {
1690 let config = FontConfig {
1691 source: Some("fonts/MyFont.woff2".to_string()),
1692 ..FontConfig::default()
1693 };
1694 assert!(config.is_local());
1695 assert!(config.stylesheet_url().is_none());
1696 }
1697
1698 #[test]
1699 fn local_font_generates_font_face_css() {
1700 let config = FontConfig {
1701 font: "My Custom Font".to_string(),
1702 weight: "400".to_string(),
1703 font_type: FontType::Sans,
1704 source: Some("fonts/MyFont.woff2".to_string()),
1705 };
1706 let css = config.font_face_css().unwrap();
1707 assert!(css.contains("@font-face"));
1708 assert!(css.contains(r#"font-family: "My Custom Font""#));
1709 assert!(css.contains(r#"url("/fonts/MyFont.woff2")"#));
1710 assert!(css.contains(r#"format("woff2")"#));
1711 assert!(css.contains("font-weight: 400"));
1712 assert!(css.contains("font-display: swap"));
1713 }
1714
1715 #[test]
1716 fn parse_font_with_source() {
1717 let toml = r#"
1718[font]
1719font = "My Font"
1720weight = "400"
1721source = "fonts/myfont.woff2"
1722"#;
1723 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1724 let config = SiteConfig::default().merge(partial);
1725 assert_eq!(config.font.font, "My Font");
1726 assert_eq!(config.font.source.as_deref(), Some("fonts/myfont.woff2"));
1727 assert!(config.font.is_local());
1728 }
1729
1730 #[test]
1731 fn merge_font_source_preserves_other_fields() {
1732 let partial: PartialSiteConfig = toml::from_str(
1733 r#"
1734[font]
1735source = "fonts/custom.woff2"
1736"#,
1737 )
1738 .unwrap();
1739 let config = SiteConfig::default().merge(partial);
1740 assert_eq!(config.font.font, "Noto Sans"); assert_eq!(config.font.weight, "600"); assert_eq!(config.font.source.as_deref(), Some("fonts/custom.woff2"));
1743 }
1744
1745 #[test]
1746 fn font_format_detection() {
1747 assert_eq!(font_format_from_extension("font.woff2"), "woff2");
1748 assert_eq!(font_format_from_extension("font.woff"), "woff");
1749 assert_eq!(font_format_from_extension("font.ttf"), "truetype");
1750 assert_eq!(font_format_from_extension("font.otf"), "opentype");
1751 assert_eq!(font_format_from_extension("font.unknown"), "woff2");
1752 }
1753
1754 #[test]
1755 fn generate_font_css_includes_font_face_for_local() {
1756 let font = FontConfig {
1757 font: "Local Font".to_string(),
1758 weight: "700".to_string(),
1759 font_type: FontType::Serif,
1760 source: Some("fonts/local.woff2".to_string()),
1761 };
1762 let css = generate_font_css(&font);
1763 assert!(css.contains("@font-face"));
1764 assert!(css.contains("--font-family:"));
1765 assert!(css.contains("--font-weight: 700"));
1766 }
1767
1768 #[test]
1769 fn generate_font_css_no_font_face_for_google() {
1770 let font = FontConfig::default();
1771 let css = generate_font_css(&font);
1772 assert!(!css.contains("@font-face"));
1773 assert!(css.contains("--font-family:"));
1774 }
1775}