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