1use serde::{Deserialize, Serialize};
102use std::fs;
103use std::path::Path;
104use thiserror::Error;
105
106#[derive(Error, Debug)]
107pub enum ConfigError {
108 #[error("IO error: {0}")]
109 Io(#[from] std::io::Error),
110 #[error("TOML parse error: {0}")]
111 Toml(#[from] toml::de::Error),
112 #[error("Config validation error: {0}")]
113 Validation(String),
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121#[serde(default, deny_unknown_fields)]
122pub struct SiteConfig {
123 #[serde(default = "default_site_title")]
125 pub site_title: String,
126 #[serde(default = "default_assets_dir")]
129 pub assets_dir: String,
130 #[serde(default = "default_site_description_file")]
133 pub site_description_file: String,
134 pub colors: ColorConfig,
136 pub thumbnails: ThumbnailsConfig,
138 pub full_index: FullIndexConfig,
140 pub images: ImagesConfig,
142 pub theme: ThemeConfig,
144 pub font: FontConfig,
146 pub processing: ProcessingConfig,
148}
149
150#[derive(Debug, Clone, Default, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct PartialSiteConfig {
154 pub site_title: Option<String>,
155 pub assets_dir: Option<String>,
156 pub site_description_file: Option<String>,
157 pub colors: Option<PartialColorConfig>,
158 pub thumbnails: Option<PartialThumbnailsConfig>,
159 pub full_index: Option<PartialFullIndexConfig>,
160 pub images: Option<PartialImagesConfig>,
161 pub theme: Option<PartialThemeConfig>,
162 pub font: Option<PartialFontConfig>,
163 pub processing: Option<PartialProcessingConfig>,
164}
165
166fn default_site_title() -> String {
167 "Gallery".to_string()
168}
169
170fn default_assets_dir() -> String {
171 "assets".to_string()
172}
173
174fn default_site_description_file() -> String {
175 "site".to_string()
176}
177
178impl Default for SiteConfig {
179 fn default() -> Self {
180 Self {
181 site_title: default_site_title(),
182 assets_dir: default_assets_dir(),
183 site_description_file: default_site_description_file(),
184 colors: ColorConfig::default(),
185 thumbnails: ThumbnailsConfig::default(),
186 full_index: FullIndexConfig::default(),
187 images: ImagesConfig::default(),
188 theme: ThemeConfig::default(),
189 font: FontConfig::default(),
190 processing: ProcessingConfig::default(),
191 }
192 }
193}
194
195impl SiteConfig {
196 pub fn validate(&self) -> Result<(), ConfigError> {
198 if self.images.quality > 100 {
199 return Err(ConfigError::Validation(
200 "images.quality must be 0-100".into(),
201 ));
202 }
203 if self.thumbnails.aspect_ratio[0] == 0 || self.thumbnails.aspect_ratio[1] == 0 {
204 return Err(ConfigError::Validation(
205 "thumbnails.aspect_ratio values must be non-zero".into(),
206 ));
207 }
208 if self.full_index.thumb_ratio[0] == 0 || self.full_index.thumb_ratio[1] == 0 {
209 return Err(ConfigError::Validation(
210 "full_index.thumb_ratio values must be non-zero".into(),
211 ));
212 }
213 if self.full_index.thumb_size == 0 {
214 return Err(ConfigError::Validation(
215 "full_index.thumb_size must be non-zero".into(),
216 ));
217 }
218 if self.images.sizes.is_empty() {
219 return Err(ConfigError::Validation(
220 "images.sizes must not be empty".into(),
221 ));
222 }
223 Ok(())
224 }
225
226 pub fn merge(mut self, other: PartialSiteConfig) -> Self {
228 if let Some(st) = other.site_title {
229 self.site_title = st;
230 }
231 if let Some(ad) = other.assets_dir {
232 self.assets_dir = ad;
233 }
234 if let Some(sd) = other.site_description_file {
235 self.site_description_file = sd;
236 }
237 if let Some(c) = other.colors {
238 self.colors = self.colors.merge(c);
239 }
240 if let Some(t) = other.thumbnails {
241 self.thumbnails = self.thumbnails.merge(t);
242 }
243 if let Some(fi) = other.full_index {
244 self.full_index = self.full_index.merge(fi);
245 }
246 if let Some(i) = other.images {
247 self.images = self.images.merge(i);
248 }
249 if let Some(t) = other.theme {
250 self.theme = self.theme.merge(t);
251 }
252 if let Some(f) = other.font {
253 self.font = self.font.merge(f);
254 }
255 if let Some(p) = other.processing {
256 self.processing = self.processing.merge(p);
257 }
258 self
259 }
260}
261
262#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
264#[serde(default, deny_unknown_fields)]
265pub struct ProcessingConfig {
266 pub max_processes: Option<usize>,
270}
271
272#[derive(Debug, Clone, Default, Deserialize)]
273#[serde(deny_unknown_fields)]
274pub struct PartialProcessingConfig {
275 pub max_processes: Option<usize>,
276}
277
278impl ProcessingConfig {
279 pub fn merge(mut self, other: PartialProcessingConfig) -> Self {
280 if other.max_processes.is_some() {
281 self.max_processes = other.max_processes;
282 }
283 self
284 }
285}
286
287#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
289#[serde(rename_all = "lowercase")]
290pub enum FontType {
291 #[default]
292 Sans,
293 Serif,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
318#[serde(default, deny_unknown_fields)]
319pub struct FontConfig {
320 pub font: String,
322 pub weight: String,
324 pub font_type: FontType,
326 #[serde(skip_serializing_if = "Option::is_none")]
330 pub source: Option<String>,
331}
332
333#[derive(Debug, Clone, Default, Deserialize)]
334#[serde(deny_unknown_fields)]
335pub struct PartialFontConfig {
336 pub font: Option<String>,
337 pub weight: Option<String>,
338 pub font_type: Option<FontType>,
339 pub source: Option<String>,
340}
341
342impl Default for FontConfig {
343 fn default() -> Self {
344 Self {
345 font: "Noto Sans".to_string(),
346 weight: "600".to_string(),
347 font_type: FontType::Sans,
348 source: None,
349 }
350 }
351}
352
353impl FontConfig {
354 pub fn merge(mut self, other: PartialFontConfig) -> Self {
355 if let Some(f) = other.font {
356 self.font = f;
357 }
358 if let Some(w) = other.weight {
359 self.weight = w;
360 }
361 if let Some(t) = other.font_type {
362 self.font_type = t;
363 }
364 if other.source.is_some() {
365 self.source = other.source;
366 }
367 self
368 }
369
370 pub fn is_local(&self) -> bool {
372 self.source.is_some()
373 }
374
375 pub fn stylesheet_url(&self) -> Option<String> {
378 if self.is_local() {
379 return None;
380 }
381 let family = self.font.replace(' ', "+");
382 Some(format!(
383 "https://fonts.googleapis.com/css2?family={}:wght@{}&display=swap",
384 family, self.weight
385 ))
386 }
387
388 pub fn font_face_css(&self) -> Option<String> {
391 let src = self.source.as_ref()?;
392 let format = font_format_from_extension(src);
393 Some(format!(
394 r#"@font-face {{
395 font-family: "{}";
396 src: url("/{}") format("{}");
397 font-weight: {};
398 font-display: swap;
399}}"#,
400 self.font, src, format, self.weight
401 ))
402 }
403
404 pub fn font_family_css(&self) -> String {
406 let fallbacks = match self.font_type {
407 FontType::Serif => r#"Georgia, "Times New Roman", serif"#,
408 FontType::Sans => "Helvetica, Verdana, sans-serif",
409 };
410 format!(r#""{}", {}"#, self.font, fallbacks)
411 }
412}
413
414fn font_format_from_extension(path: &str) -> &'static str {
416 match path.rsplit('.').next().map(|e| e.to_lowercase()).as_deref() {
417 Some("woff2") => "woff2",
418 Some("woff") => "woff",
419 Some("ttf") => "truetype",
420 Some("otf") => "opentype",
421 _ => "woff2", }
423}
424
425pub fn effective_threads(config: &ProcessingConfig) -> usize {
430 let cores = std::thread::available_parallelism()
431 .map(|n| n.get())
432 .unwrap_or(1);
433 config.max_processes.map(|n| n.min(cores)).unwrap_or(cores)
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
438#[serde(default, deny_unknown_fields)]
439pub struct ThumbnailsConfig {
440 pub aspect_ratio: [u32; 2],
442 pub size: u32,
444}
445
446#[derive(Debug, Clone, Default, Deserialize)]
447#[serde(deny_unknown_fields)]
448pub struct PartialThumbnailsConfig {
449 pub aspect_ratio: Option<[u32; 2]>,
450 pub size: Option<u32>,
451}
452
453impl ThumbnailsConfig {
454 pub fn merge(mut self, other: PartialThumbnailsConfig) -> Self {
455 if let Some(ar) = other.aspect_ratio {
456 self.aspect_ratio = ar;
457 }
458 if let Some(s) = other.size {
459 self.size = s;
460 }
461 self
462 }
463}
464
465impl Default for ThumbnailsConfig {
466 fn default() -> Self {
467 Self {
468 aspect_ratio: [4, 5],
469 size: 400,
470 }
471 }
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
481#[serde(default, deny_unknown_fields)]
482pub struct FullIndexConfig {
483 pub generates: bool,
485 pub show_link: bool,
487 pub thumb_ratio: [u32; 2],
489 pub thumb_size: u32,
491 pub thumb_gap: String,
493}
494
495#[derive(Debug, Clone, Default, Deserialize)]
496#[serde(deny_unknown_fields)]
497pub struct PartialFullIndexConfig {
498 pub generates: Option<bool>,
499 pub show_link: Option<bool>,
500 pub thumb_ratio: Option<[u32; 2]>,
501 pub thumb_size: Option<u32>,
502 pub thumb_gap: Option<String>,
503}
504
505impl FullIndexConfig {
506 pub fn merge(mut self, other: PartialFullIndexConfig) -> Self {
507 if let Some(g) = other.generates {
508 self.generates = g;
509 }
510 if let Some(l) = other.show_link {
511 self.show_link = l;
512 }
513 if let Some(r) = other.thumb_ratio {
514 self.thumb_ratio = r;
515 }
516 if let Some(s) = other.thumb_size {
517 self.thumb_size = s;
518 }
519 if let Some(g) = other.thumb_gap {
520 self.thumb_gap = g;
521 }
522 self
523 }
524}
525
526impl Default for FullIndexConfig {
527 fn default() -> Self {
528 Self {
529 generates: false,
530 show_link: false,
531 thumb_ratio: [4, 5],
532 thumb_size: 400,
533 thumb_gap: "0.2rem".to_string(),
534 }
535 }
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
540#[serde(default, deny_unknown_fields)]
541pub struct ImagesConfig {
542 pub sizes: Vec<u32>,
544 pub quality: u32,
546}
547
548#[derive(Debug, Clone, Default, Deserialize)]
549#[serde(deny_unknown_fields)]
550pub struct PartialImagesConfig {
551 pub sizes: Option<Vec<u32>>,
552 pub quality: Option<u32>,
553}
554
555impl ImagesConfig {
556 pub fn merge(mut self, other: PartialImagesConfig) -> Self {
557 if let Some(s) = other.sizes {
558 self.sizes = s;
559 }
560 if let Some(q) = other.quality {
561 self.quality = q;
562 }
563 self
564 }
565}
566
567impl Default for ImagesConfig {
568 fn default() -> Self {
569 Self {
570 sizes: vec![800, 1400, 2080],
571 quality: 90,
572 }
573 }
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
584#[serde(deny_unknown_fields)]
585pub struct ClampSize {
586 pub size: String,
588 pub min: String,
590 pub max: String,
592}
593
594#[derive(Debug, Clone, Default, Deserialize)]
595#[serde(deny_unknown_fields)]
596pub struct PartialClampSize {
597 pub size: Option<String>,
598 pub min: Option<String>,
599 pub max: Option<String>,
600}
601
602impl ClampSize {
603 pub fn merge(mut self, other: PartialClampSize) -> Self {
604 if let Some(s) = other.size {
605 self.size = s;
606 }
607 if let Some(m) = other.min {
608 self.min = m;
609 }
610 if let Some(m) = other.max {
611 self.max = m;
612 }
613 self
614 }
615}
616
617impl ClampSize {
618 pub fn to_css(&self) -> String {
620 format!("clamp({}, {}, {})", self.min, self.size, self.max)
621 }
622}
623
624#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
626#[serde(default, deny_unknown_fields)]
627pub struct ThemeConfig {
628 pub mat_x: ClampSize,
630 pub mat_y: ClampSize,
632 pub thumbnail_gap: String,
634 pub grid_padding: String,
636}
637
638#[derive(Debug, Clone, Default, Deserialize)]
639#[serde(deny_unknown_fields)]
640pub struct PartialThemeConfig {
641 pub mat_x: Option<PartialClampSize>,
642 pub mat_y: Option<PartialClampSize>,
643 pub thumbnail_gap: Option<String>,
644 pub grid_padding: Option<String>,
645}
646
647impl ThemeConfig {
648 pub fn merge(mut self, other: PartialThemeConfig) -> Self {
649 if let Some(x) = other.mat_x {
650 self.mat_x = self.mat_x.merge(x);
651 }
652 if let Some(y) = other.mat_y {
653 self.mat_y = self.mat_y.merge(y);
654 }
655 if let Some(g) = other.thumbnail_gap {
656 self.thumbnail_gap = g;
657 }
658 if let Some(p) = other.grid_padding {
659 self.grid_padding = p;
660 }
661 self
662 }
663}
664
665impl Default for ThemeConfig {
666 fn default() -> Self {
667 Self {
668 mat_x: ClampSize {
669 size: "3vw".to_string(),
670 min: "1rem".to_string(),
671 max: "2.5rem".to_string(),
672 },
673 mat_y: ClampSize {
674 size: "6vw".to_string(),
675 min: "2rem".to_string(),
676 max: "5rem".to_string(),
677 },
678 thumbnail_gap: "0.2rem".to_string(),
679 grid_padding: "2rem".to_string(),
680 }
681 }
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
686#[serde(default, deny_unknown_fields)]
687pub struct ColorConfig {
688 pub light: ColorScheme,
690 pub dark: ColorScheme,
692}
693
694#[derive(Debug, Clone, Default, Deserialize)]
695#[serde(deny_unknown_fields)]
696pub struct PartialColorConfig {
697 pub light: Option<PartialColorScheme>,
698 pub dark: Option<PartialColorScheme>,
699}
700
701impl ColorConfig {
702 pub fn merge(mut self, other: PartialColorConfig) -> Self {
703 if let Some(l) = other.light {
704 self.light = self.light.merge(l);
705 }
706 if let Some(d) = other.dark {
707 self.dark = self.dark.merge(d);
708 }
709 self
710 }
711}
712
713impl Default for ColorConfig {
714 fn default() -> Self {
715 Self {
716 light: ColorScheme::default_light(),
717 dark: ColorScheme::default_dark(),
718 }
719 }
720}
721
722#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
724#[serde(default, deny_unknown_fields)]
725pub struct ColorScheme {
726 pub background: String,
728 pub text: String,
730 pub text_muted: String,
732 pub border: String,
734 pub separator: String,
736 pub link: String,
738 pub link_hover: String,
740}
741
742#[derive(Debug, Clone, Default, Deserialize)]
743#[serde(deny_unknown_fields)]
744pub struct PartialColorScheme {
745 pub background: Option<String>,
746 pub text: Option<String>,
747 pub text_muted: Option<String>,
748 pub border: Option<String>,
749 pub separator: Option<String>,
750 pub link: Option<String>,
751 pub link_hover: Option<String>,
752}
753
754impl ColorScheme {
755 pub fn merge(mut self, other: PartialColorScheme) -> Self {
756 if let Some(v) = other.background {
757 self.background = v;
758 }
759 if let Some(v) = other.text {
760 self.text = v;
761 }
762 if let Some(v) = other.text_muted {
763 self.text_muted = v;
764 }
765 if let Some(v) = other.border {
766 self.border = v;
767 }
768 if let Some(v) = other.separator {
769 self.separator = v;
770 }
771 if let Some(v) = other.link {
772 self.link = v;
773 }
774 if let Some(v) = other.link_hover {
775 self.link_hover = v;
776 }
777 self
778 }
779}
780
781impl ColorScheme {
782 pub fn default_light() -> Self {
783 Self {
784 background: "#ffffff".to_string(),
785 text: "#111111".to_string(),
786 text_muted: "#666666".to_string(),
787 border: "#e0e0e0".to_string(),
788 separator: "#e0e0e0".to_string(),
789 link: "#333333".to_string(),
790 link_hover: "#000000".to_string(),
791 }
792 }
793
794 pub fn default_dark() -> Self {
795 Self {
796 background: "#000000".to_string(),
797 text: "#fafafa".to_string(),
798 text_muted: "#999999".to_string(),
799 border: "#333333".to_string(),
800 separator: "#333333".to_string(),
801 link: "#cccccc".to_string(),
802 link_hover: "#ffffff".to_string(),
803 }
804 }
805}
806
807impl Default for ColorScheme {
808 fn default() -> Self {
809 Self::default_light()
810 }
811}
812
813pub fn load_partial_config(path: &Path) -> Result<Option<PartialSiteConfig>, ConfigError> {
822 let config_path = path.join("config.toml");
823 if !config_path.exists() {
824 return Ok(None);
825 }
826 let content = fs::read_to_string(&config_path)?;
827 let partial: PartialSiteConfig = toml::from_str(&content)?;
828 Ok(Some(partial))
829}
830
831pub fn load_config(root: &Path) -> Result<SiteConfig, ConfigError> {
833 let base = SiteConfig::default();
834 let partial = load_partial_config(root)?;
835 if let Some(p) = partial {
836 let merged = base.merge(p);
837 merged.validate()?;
838 Ok(merged)
839 } else {
840 Ok(base)
841 }
842}
843
844pub fn stock_config_toml() -> &'static str {
848 r##"# Simple Gal Configuration
849# ========================
850# All settings are optional. Remove or comment out any you don't need.
851# Values shown below are the defaults.
852#
853# Config files can be placed at any level of the directory tree:
854# content/config.toml -> root (overrides stock defaults)
855# content/020-Travel/config.toml -> group (overrides root)
856# content/020-Travel/010-Japan/config.toml -> gallery (overrides group)
857#
858# Each level only needs the keys it wants to override.
859# Unknown keys will cause an error.
860
861# Site title shown in breadcrumbs and the browser tab for the home page.
862site_title = "Gallery"
863
864# Directory for static assets (favicon, fonts, etc.), relative to content root.
865# Contents are copied verbatim to the output root during generation.
866# If the directory doesn't exist, it is silently skipped.
867assets_dir = "assets"
868
869# Stem of the site description file in the content root.
870# If site.md or site.txt exists, its content is rendered on the index page.
871# site_description_file = "site"
872
873# ---------------------------------------------------------------------------
874# Thumbnail generation
875# ---------------------------------------------------------------------------
876[thumbnails]
877# Aspect ratio as [width, height] for thumbnail crops.
878# Common choices: [1, 1] for square, [4, 5] for portrait, [3, 2] for landscape.
879aspect_ratio = [4, 5]
880
881# Short-edge size in pixels for generated thumbnails.
882size = 400
883
884# ---------------------------------------------------------------------------
885# Full index ("All Photos") page
886# ---------------------------------------------------------------------------
887# When enabled, generates a single page at /all-photos/ showing every image
888# from every public album in one thumbnail grid. Off by default.
889[full_index]
890# If true, renders the All Photos page.
891generates = false
892
893# If true, adds an "All Photos" entry to the navigation menu.
894show_link = false
895
896# Aspect ratio [width, height] for full-index thumbnails.
897thumb_ratio = [4, 5]
898
899# Short-edge size in pixels for full-index thumbnails.
900thumb_size = 400
901
902# Gap between thumbnails on the All Photos grid (CSS value).
903thumb_gap = "0.2rem"
904
905# ---------------------------------------------------------------------------
906# Responsive image generation
907# ---------------------------------------------------------------------------
908[images]
909# Pixel widths (longer edge) to generate for responsive <picture> elements.
910sizes = [800, 1400, 2080]
911
912# AVIF encoding quality (0 = worst, 100 = best).
913quality = 90
914
915# ---------------------------------------------------------------------------
916# Theme / layout
917# ---------------------------------------------------------------------------
918[theme]
919# Gap between thumbnails in album and image grids (CSS value).
920thumbnail_gap = "0.2rem"
921
922# Padding around the thumbnail grid container (CSS value).
923grid_padding = "2rem"
924
925# Horizontal mat around images, as CSS clamp(min, size, max).
926# See docs/dev/photo-page-layout.md for the layout spec.
927[theme.mat_x]
928size = "3vw"
929min = "1rem"
930max = "2.5rem"
931
932# Vertical mat around images, as CSS clamp(min, size, max).
933[theme.mat_y]
934size = "6vw"
935min = "2rem"
936max = "5rem"
937
938# ---------------------------------------------------------------------------
939# Colors - Light mode (prefers-color-scheme: light)
940# ---------------------------------------------------------------------------
941[colors.light]
942background = "#ffffff"
943text = "#111111"
944text_muted = "#666666" # Nav, breadcrumbs, captions
945border = "#e0e0e0"
946separator = "#e0e0e0" # Header underline, nav menu divider
947link = "#333333"
948link_hover = "#000000"
949
950# ---------------------------------------------------------------------------
951# Colors - Dark mode (prefers-color-scheme: dark)
952# ---------------------------------------------------------------------------
953[colors.dark]
954background = "#000000"
955text = "#fafafa"
956text_muted = "#999999"
957border = "#333333"
958separator = "#333333" # Header underline, nav menu divider
959link = "#cccccc"
960link_hover = "#ffffff"
961
962# ---------------------------------------------------------------------------
963# Font
964# ---------------------------------------------------------------------------
965[font]
966# Google Fonts family name.
967font = "Noto Sans"
968
969# Font weight to load from Google Fonts.
970weight = "600"
971
972# Font category: "sans" or "serif". Determines fallback fonts in the CSS stack.
973# sans -> Helvetica, Verdana, sans-serif
974# serif -> Georgia, "Times New Roman", serif
975font_type = "sans"
976
977# Local font file path, relative to the site root (e.g. "fonts/MyFont.woff2").
978# When set, generates @font-face CSS instead of loading from Google Fonts.
979# Place the font file in your assets directory so it gets copied to the output.
980# Supported formats: .woff2, .woff, .ttf, .otf
981# source = "fonts/MyFont.woff2"
982
983# ---------------------------------------------------------------------------
984# Processing
985# ---------------------------------------------------------------------------
986[processing]
987# Maximum parallel image-processing workers.
988# Omit or comment out to auto-detect (= number of CPU cores).
989# max_processes = 4
990
991# ---------------------------------------------------------------------------
992# Custom CSS & HTML Snippets
993# ---------------------------------------------------------------------------
994# Drop any of these files into your assets/ directory to inject custom content.
995# No configuration needed — the files are detected automatically.
996#
997# assets/custom.css → <link rel="stylesheet"> after main styles (CSS overrides)
998# assets/head.html → raw HTML at the end of <head> (analytics, meta tags)
999# assets/body-end.html → raw HTML before </body> (tracking scripts, widgets)
1000"##
1001}
1002
1003pub fn generate_color_css(colors: &ColorConfig) -> String {
1011 format!(
1012 r#":root {{
1013 --color-bg: {light_bg};
1014 --color-text: {light_text};
1015 --color-text-muted: {light_text_muted};
1016 --color-border: {light_border};
1017 --color-link: {light_link};
1018 --color-link-hover: {light_link_hover};
1019 --color-separator: {light_separator};
1020}}
1021
1022@media (prefers-color-scheme: dark) {{
1023 :root {{
1024 --color-bg: {dark_bg};
1025 --color-text: {dark_text};
1026 --color-text-muted: {dark_text_muted};
1027 --color-border: {dark_border};
1028 --color-link: {dark_link};
1029 --color-link-hover: {dark_link_hover};
1030 --color-separator: {dark_separator};
1031 }}
1032}}"#,
1033 light_bg = colors.light.background,
1034 light_text = colors.light.text,
1035 light_text_muted = colors.light.text_muted,
1036 light_border = colors.light.border,
1037 light_separator = colors.light.separator,
1038 light_link = colors.light.link,
1039 light_link_hover = colors.light.link_hover,
1040 dark_bg = colors.dark.background,
1041 dark_text = colors.dark.text,
1042 dark_text_muted = colors.dark.text_muted,
1043 dark_border = colors.dark.border,
1044 dark_separator = colors.dark.separator,
1045 dark_link = colors.dark.link,
1046 dark_link_hover = colors.dark.link_hover,
1047 )
1048}
1049
1050pub fn generate_theme_css(theme: &ThemeConfig) -> String {
1052 format!(
1053 r#":root {{
1054 --mat-x: {mat_x};
1055 --mat-y: {mat_y};
1056 --thumbnail-gap: {thumbnail_gap};
1057 --grid-padding: {grid_padding};
1058}}"#,
1059 mat_x = theme.mat_x.to_css(),
1060 mat_y = theme.mat_y.to_css(),
1061 thumbnail_gap = theme.thumbnail_gap,
1062 grid_padding = theme.grid_padding,
1063 )
1064}
1065
1066pub fn generate_font_css(font: &FontConfig) -> String {
1070 let vars = format!(
1071 r#":root {{
1072 --font-family: {family};
1073 --font-weight: {weight};
1074}}"#,
1075 family = font.font_family_css(),
1076 weight = font.weight,
1077 );
1078 match font.font_face_css() {
1079 Some(face) => format!("{}\n\n{}", face, vars),
1080 None => vars,
1081 }
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086 use super::*;
1087 use tempfile::TempDir;
1088
1089 #[test]
1090 fn default_config_has_colors() {
1091 let config = SiteConfig::default();
1092 assert_eq!(config.colors.light.background, "#ffffff");
1093 assert_eq!(config.colors.dark.background, "#000000");
1094 }
1095
1096 #[test]
1097 fn default_config_has_site_title() {
1098 let config = SiteConfig::default();
1099 assert_eq!(config.site_title, "Gallery");
1100 }
1101
1102 #[test]
1103 fn parse_custom_site_title() {
1104 let toml = r#"site_title = "My Portfolio""#;
1105 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1106 let config = SiteConfig::default().merge(partial);
1107 assert_eq!(config.site_title, "My Portfolio");
1108 }
1109
1110 #[test]
1111 fn default_config_has_image_settings() {
1112 let config = SiteConfig::default();
1113 assert_eq!(config.thumbnails.aspect_ratio, [4, 5]);
1114 assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
1115 assert_eq!(config.images.quality, 90);
1116 assert_eq!(config.theme.mat_x.to_css(), "clamp(1rem, 3vw, 2.5rem)");
1117 assert_eq!(config.theme.mat_y.to_css(), "clamp(2rem, 6vw, 5rem)");
1118 }
1119
1120 #[test]
1121 fn parse_partial_config() {
1122 let toml = r##"
1123[colors.light]
1124background = "#fafafa"
1125"##;
1126 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1127 let config = SiteConfig::default().merge(partial);
1128
1129 assert_eq!(config.colors.light.background, "#fafafa");
1131 assert_eq!(config.colors.light.text, "#111111");
1133 assert_eq!(config.colors.dark.background, "#000000");
1134 assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
1136 }
1137
1138 #[test]
1139 fn parse_image_settings() {
1140 let toml = r##"
1141[thumbnails]
1142aspect_ratio = [1, 1]
1143
1144[images]
1145sizes = [400, 800]
1146quality = 85
1147"##;
1148 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1149 let config = SiteConfig::default().merge(partial);
1150
1151 assert_eq!(config.thumbnails.aspect_ratio, [1, 1]);
1152 assert_eq!(config.images.sizes, vec![400, 800]);
1153 assert_eq!(config.images.quality, 85);
1154 assert_eq!(config.colors.light.background, "#ffffff");
1156 }
1157
1158 #[test]
1159 fn default_full_index_is_off() {
1160 let config = SiteConfig::default();
1161 assert!(!config.full_index.generates);
1162 assert!(!config.full_index.show_link);
1163 assert_eq!(config.full_index.thumb_ratio, [4, 5]);
1164 assert_eq!(config.full_index.thumb_size, 400);
1165 assert_eq!(config.full_index.thumb_gap, "0.2rem");
1166 }
1167
1168 #[test]
1169 fn parse_full_index_settings() {
1170 let toml = r##"
1171[full_index]
1172generates = true
1173show_link = true
1174thumb_ratio = [4, 4]
1175thumb_size = 1000
1176thumb_gap = "0.5rem"
1177"##;
1178 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1179 let config = SiteConfig::default().merge(partial);
1180
1181 assert!(config.full_index.generates);
1182 assert!(config.full_index.show_link);
1183 assert_eq!(config.full_index.thumb_ratio, [4, 4]);
1184 assert_eq!(config.full_index.thumb_size, 1000);
1185 assert_eq!(config.full_index.thumb_gap, "0.5rem");
1186 }
1187
1188 #[test]
1189 fn full_index_partial_merge_preserves_defaults() {
1190 let toml = r##"
1191[full_index]
1192generates = true
1193"##;
1194 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1195 let config = SiteConfig::default().merge(partial);
1196
1197 assert!(config.full_index.generates);
1198 assert!(!config.full_index.show_link);
1200 assert_eq!(config.full_index.thumb_ratio, [4, 5]);
1201 assert_eq!(config.full_index.thumb_size, 400);
1202 }
1203
1204 #[test]
1205 fn full_index_validation_rejects_zero_ratio() {
1206 let mut config = SiteConfig::default();
1207 config.full_index.thumb_ratio = [0, 1];
1208 assert!(config.validate().is_err());
1209 }
1210
1211 #[test]
1212 fn generate_css_uses_config_colors() {
1213 let mut colors = ColorConfig::default();
1214 colors.light.background = "#f0f0f0".to_string();
1215 colors.dark.background = "#1a1a1a".to_string();
1216
1217 let css = generate_color_css(&colors);
1218 assert!(css.contains("--color-bg: #f0f0f0"));
1219 assert!(css.contains("--color-bg: #1a1a1a"));
1220 }
1221
1222 #[test]
1227 fn load_config_returns_default_when_no_file() {
1228 let tmp = TempDir::new().unwrap();
1229 let config = load_config(tmp.path()).unwrap();
1230
1231 assert_eq!(config.colors.light.background, "#ffffff");
1232 assert_eq!(config.colors.dark.background, "#000000");
1233 }
1234
1235 #[test]
1236 fn load_config_reads_file() {
1237 let tmp = TempDir::new().unwrap();
1238 let config_path = tmp.path().join("config.toml");
1239
1240 fs::write(
1241 &config_path,
1242 r##"
1243[colors.light]
1244background = "#123456"
1245text = "#abcdef"
1246"##,
1247 )
1248 .unwrap();
1249
1250 let config = load_config(tmp.path()).unwrap();
1251 assert_eq!(config.colors.light.background, "#123456");
1252 assert_eq!(config.colors.light.text, "#abcdef");
1253 assert_eq!(config.colors.dark.background, "#000000");
1255 }
1256
1257 #[test]
1258 fn load_config_full_config() {
1259 let tmp = TempDir::new().unwrap();
1260 let config_path = tmp.path().join("config.toml");
1261
1262 fs::write(
1263 &config_path,
1264 r##"
1265[colors.light]
1266background = "#fff"
1267text = "#000"
1268text_muted = "#666"
1269border = "#ccc"
1270link = "#00f"
1271link_hover = "#f00"
1272
1273[colors.dark]
1274background = "#111"
1275text = "#eee"
1276text_muted = "#888"
1277border = "#444"
1278link = "#88f"
1279link_hover = "#f88"
1280"##,
1281 )
1282 .unwrap();
1283
1284 let config = load_config(tmp.path()).unwrap();
1285
1286 assert_eq!(config.colors.light.background, "#fff");
1288 assert_eq!(config.colors.light.text, "#000");
1289 assert_eq!(config.colors.light.link, "#00f");
1290
1291 assert_eq!(config.colors.dark.background, "#111");
1293 assert_eq!(config.colors.dark.text, "#eee");
1294 assert_eq!(config.colors.dark.link, "#88f");
1295 }
1296
1297 #[test]
1298 fn load_config_invalid_toml_is_error() {
1299 let tmp = TempDir::new().unwrap();
1300 let config_path = tmp.path().join("config.toml");
1301
1302 fs::write(&config_path, "this is not valid toml [[[").unwrap();
1303
1304 let result = load_config(tmp.path());
1305 assert!(matches!(result, Err(ConfigError::Toml(_))));
1306 }
1307
1308 #[test]
1309 fn load_config_unknown_keys_is_error() {
1310 let tmp = TempDir::new().unwrap();
1311 let config_path = tmp.path().join("config.toml");
1312
1313 fs::write(
1315 &config_path,
1316 r#"
1317 unknown_key = "foo"
1318 "#,
1319 )
1320 .unwrap();
1321
1322 let result = load_config(tmp.path());
1323 assert!(matches!(result, Err(ConfigError::Toml(_))));
1324 }
1325
1326 #[test]
1331 fn generate_css_includes_all_variables() {
1332 let colors = ColorConfig::default();
1333 let css = generate_color_css(&colors);
1334
1335 assert!(css.contains("--color-bg:"));
1337 assert!(css.contains("--color-text:"));
1338 assert!(css.contains("--color-text-muted:"));
1339 assert!(css.contains("--color-border:"));
1340 assert!(css.contains("--color-link:"));
1341 assert!(css.contains("--color-link-hover:"));
1342 }
1343
1344 #[test]
1345 fn generate_css_includes_dark_mode_media_query() {
1346 let colors = ColorConfig::default();
1347 let css = generate_color_css(&colors);
1348
1349 assert!(css.contains("@media (prefers-color-scheme: dark)"));
1350 }
1351
1352 #[test]
1353 fn color_scheme_default_is_light() {
1354 let scheme = ColorScheme::default();
1355 assert_eq!(scheme.background, "#ffffff");
1356 }
1357
1358 #[test]
1359 fn clamp_size_to_css() {
1360 let size = ClampSize {
1361 size: "3vw".to_string(),
1362 min: "1rem".to_string(),
1363 max: "2.5rem".to_string(),
1364 };
1365 assert_eq!(size.to_css(), "clamp(1rem, 3vw, 2.5rem)");
1366 }
1367
1368 #[test]
1369 fn generate_theme_css_includes_mat_variables() {
1370 let theme = ThemeConfig::default();
1371 let css = generate_theme_css(&theme);
1372 assert!(css.contains("--mat-x: clamp(1rem, 3vw, 2.5rem)"));
1373 assert!(css.contains("--mat-y: clamp(2rem, 6vw, 5rem)"));
1374 assert!(css.contains("--thumbnail-gap: 0.2rem"));
1375 assert!(css.contains("--grid-padding: 2rem"));
1376 }
1377
1378 #[test]
1379 fn parse_thumbnail_gap_and_grid_padding() {
1380 let toml = r#"
1381[theme]
1382thumbnail_gap = "0.5rem"
1383grid_padding = "1rem"
1384"#;
1385 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1386 let config = SiteConfig::default().merge(partial);
1387 assert_eq!(config.theme.thumbnail_gap, "0.5rem");
1388 assert_eq!(config.theme.grid_padding, "1rem");
1389 }
1390
1391 #[test]
1392 fn default_thumbnail_gap_and_grid_padding() {
1393 let config = SiteConfig::default();
1394 assert_eq!(config.theme.thumbnail_gap, "0.2rem");
1395 assert_eq!(config.theme.grid_padding, "2rem");
1396 }
1397
1398 #[test]
1403 fn default_processing_config() {
1404 let config = ProcessingConfig::default();
1405 assert_eq!(config.max_processes, None);
1406 }
1407
1408 #[test]
1409 fn effective_threads_auto() {
1410 let config = ProcessingConfig {
1411 max_processes: None,
1412 };
1413 let threads = effective_threads(&config);
1414 let cores = std::thread::available_parallelism()
1415 .map(|n| n.get())
1416 .unwrap_or(1);
1417 assert_eq!(threads, cores);
1418 }
1419
1420 #[test]
1421 fn effective_threads_clamped_to_cores() {
1422 let config = ProcessingConfig {
1423 max_processes: Some(99999),
1424 };
1425 let threads = effective_threads(&config);
1426 let cores = std::thread::available_parallelism()
1427 .map(|n| n.get())
1428 .unwrap_or(1);
1429 assert_eq!(threads, cores);
1430 }
1431
1432 #[test]
1433 fn effective_threads_user_constrains_down() {
1434 let config = ProcessingConfig {
1435 max_processes: Some(1),
1436 };
1437 assert_eq!(effective_threads(&config), 1);
1438 }
1439
1440 #[test]
1441 fn parse_processing_config() {
1442 let toml = r#"
1443[processing]
1444max_processes = 4
1445"#;
1446 let config: SiteConfig = toml::from_str(toml).unwrap();
1447 assert_eq!(config.processing.max_processes, Some(4));
1448 }
1449
1450 #[test]
1451 fn parse_config_without_processing_uses_default() {
1452 let toml = r##"
1453[colors.light]
1454background = "#fafafa"
1455"##;
1456 let config: SiteConfig = toml::from_str(toml).unwrap();
1457 assert_eq!(config.processing.max_processes, None);
1458 }
1459
1460 #[test]
1469 fn unknown_key_rejected() {
1470 let toml_str = r#"
1471[images]
1472qualty = 90
1473"#;
1474 let result: Result<SiteConfig, _> = toml::from_str(toml_str);
1475 assert!(result.is_err());
1476 let err = result.unwrap_err().to_string();
1477 assert!(err.contains("unknown field"));
1478 }
1479
1480 #[test]
1481 fn unknown_section_rejected() {
1482 let toml_str = r#"
1483[imagez]
1484quality = 90
1485"#;
1486 let result: Result<SiteConfig, _> = toml::from_str(toml_str);
1487 assert!(result.is_err());
1488 }
1489
1490 #[test]
1491 fn unknown_nested_key_rejected() {
1492 let toml_str = r##"
1493[colors.light]
1494bg = "#fff"
1495"##;
1496 let result: Result<SiteConfig, _> = toml::from_str(toml_str);
1497 assert!(result.is_err());
1498 }
1499
1500 #[test]
1501 fn unknown_key_rejected_via_load_config() {
1502 let tmp = TempDir::new().unwrap();
1503 fs::write(
1504 tmp.path().join("config.toml"),
1505 r#"
1506[images]
1507qualty = 90
1508"#,
1509 )
1510 .unwrap();
1511
1512 let result = load_config(tmp.path());
1513 assert!(result.is_err());
1514 }
1515
1516 #[test]
1521 fn validate_quality_boundary_ok() {
1522 let mut config = SiteConfig::default();
1523 config.images.quality = 100;
1524 assert!(config.validate().is_ok());
1525
1526 config.images.quality = 0;
1527 assert!(config.validate().is_ok());
1528 }
1529
1530 #[test]
1531 fn validate_quality_too_high() {
1532 let mut config = SiteConfig::default();
1533 config.images.quality = 101;
1534 let err = config.validate().unwrap_err();
1535 assert!(err.to_string().contains("quality"));
1536 }
1537
1538 #[test]
1539 fn validate_aspect_ratio_zero() {
1540 let mut config = SiteConfig::default();
1541 config.thumbnails.aspect_ratio = [0, 5];
1542 assert!(config.validate().is_err());
1543
1544 config.thumbnails.aspect_ratio = [4, 0];
1545 assert!(config.validate().is_err());
1546 }
1547
1548 #[test]
1549 fn validate_sizes_empty() {
1550 let mut config = SiteConfig::default();
1551 config.images.sizes = vec![];
1552 assert!(config.validate().is_err());
1553 }
1554
1555 #[test]
1556 fn validate_default_config_passes() {
1557 let config = SiteConfig::default();
1558 assert!(config.validate().is_ok());
1559 }
1560
1561 #[test]
1562 fn load_config_validates_values() {
1563 let tmp = TempDir::new().unwrap();
1564 fs::write(
1565 tmp.path().join("config.toml"),
1566 r#"
1567[images]
1568quality = 200
1569"#,
1570 )
1571 .unwrap();
1572
1573 let result = load_config(tmp.path());
1574 assert!(matches!(result, Err(ConfigError::Validation(_))));
1575 }
1576
1577 #[test]
1582 fn load_partial_config_returns_none_when_no_file() {
1583 let tmp = TempDir::new().unwrap();
1584 let result = load_partial_config(tmp.path()).unwrap();
1585 assert!(result.is_none());
1586 }
1587
1588 #[test]
1589 fn load_partial_config_returns_value_when_file_exists() {
1590 let tmp = TempDir::new().unwrap();
1591 fs::write(
1592 tmp.path().join("config.toml"),
1593 r#"
1594[images]
1595quality = 85
1596"#,
1597 )
1598 .unwrap();
1599
1600 let result = load_partial_config(tmp.path()).unwrap();
1601 assert!(result.is_some());
1602 let partial = result.unwrap();
1603 assert_eq!(partial.images.unwrap().quality, Some(85));
1604 }
1605
1606 #[test]
1607 fn merge_with_no_overlay() {
1608 let base = SiteConfig::default();
1609 let config = base.merge(PartialSiteConfig::default());
1610 assert_eq!(config.images.quality, 90);
1611 assert_eq!(config.colors.light.background, "#ffffff");
1612 }
1613
1614 #[test]
1615 fn merge_with_overlay() {
1616 let base = SiteConfig::default();
1617 let toml = r#"
1618[images]
1619quality = 70
1620"#;
1621 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1622 let config = base.merge(partial);
1623 assert_eq!(config.images.quality, 70);
1624 assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
1626 }
1627
1628 #[test]
1629 fn load_config_validates_after_merge() {
1630 let tmp = TempDir::new().unwrap();
1631 fs::write(
1633 tmp.path().join("config.toml"),
1634 r#"
1635[images]
1636quality = 200
1637"#,
1638 )
1639 .unwrap();
1640
1641 let result = load_config(tmp.path());
1643 assert!(matches!(result, Err(ConfigError::Validation(_))));
1644 }
1645
1646 #[test]
1651 fn stock_config_toml_is_valid_toml() {
1652 let content = stock_config_toml();
1653 let _: toml::Value = toml::from_str(content).expect("stock config must be valid TOML");
1654 }
1655
1656 #[test]
1657 fn stock_config_toml_roundtrips_to_defaults() {
1658 let content = stock_config_toml();
1659 let config: SiteConfig = toml::from_str(content).unwrap();
1660 assert_eq!(config.images.quality, 90);
1661 assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
1662 assert_eq!(config.thumbnails.aspect_ratio, [4, 5]);
1663 assert_eq!(config.colors.light.background, "#ffffff");
1664 assert_eq!(config.colors.dark.background, "#000000");
1665 assert_eq!(config.theme.thumbnail_gap, "0.2rem");
1666 }
1667
1668 #[test]
1669 fn stock_config_toml_contains_all_sections() {
1670 let content = stock_config_toml();
1671 assert!(content.contains("[thumbnails]"));
1672 assert!(content.contains("[full_index]"));
1673 assert!(content.contains("[images]"));
1674 assert!(content.contains("[theme]"));
1675 assert!(content.contains("[theme.mat_x]"));
1676 assert!(content.contains("[theme.mat_y]"));
1677 assert!(content.contains("[colors.light]"));
1678 assert!(content.contains("[colors.dark]"));
1679 assert!(content.contains("[processing]"));
1680 }
1681
1682 #[test]
1683 fn stock_defaults_equivalent_to_default_trait() {
1684 let config = SiteConfig::default();
1686 assert_eq!(config.images.quality, 90);
1687 }
1688
1689 #[test]
1694 fn merge_partial_theme_mat_x_only() {
1695 let partial: PartialSiteConfig = toml::from_str(
1696 r#"
1697 [theme.mat_x]
1698 size = "5vw"
1699 "#,
1700 )
1701 .unwrap();
1702 let config = SiteConfig::default().merge(partial);
1703
1704 assert_eq!(config.theme.mat_x.size, "5vw");
1706 assert_eq!(config.theme.mat_x.min, "1rem");
1708 assert_eq!(config.theme.mat_x.max, "2.5rem");
1709 assert_eq!(config.theme.mat_y.size, "6vw");
1711 assert_eq!(config.theme.mat_y.min, "2rem");
1712 assert_eq!(config.theme.mat_y.max, "5rem");
1713 assert_eq!(config.theme.thumbnail_gap, "0.2rem");
1715 assert_eq!(config.theme.grid_padding, "2rem");
1716 }
1717
1718 #[test]
1719 fn merge_partial_colors_light_only() {
1720 let partial: PartialSiteConfig = toml::from_str(
1721 r##"
1722 [colors.light]
1723 background = "#fafafa"
1724 text = "#222222"
1725 "##,
1726 )
1727 .unwrap();
1728 let config = SiteConfig::default().merge(partial);
1729
1730 assert_eq!(config.colors.light.background, "#fafafa");
1732 assert_eq!(config.colors.light.text, "#222222");
1733 assert_eq!(config.colors.light.text_muted, "#666666");
1735 assert_eq!(config.colors.light.border, "#e0e0e0");
1736 assert_eq!(config.colors.light.link, "#333333");
1737 assert_eq!(config.colors.light.link_hover, "#000000");
1738 assert_eq!(config.colors.dark.background, "#000000");
1740 assert_eq!(config.colors.dark.text, "#fafafa");
1741 }
1742
1743 #[test]
1744 fn merge_partial_font_weight_only() {
1745 let partial: PartialSiteConfig = toml::from_str(
1746 r#"
1747 [font]
1748 weight = "300"
1749 "#,
1750 )
1751 .unwrap();
1752 let config = SiteConfig::default().merge(partial);
1753
1754 assert_eq!(config.font.weight, "300");
1755 assert_eq!(config.font.font, "Noto Sans");
1756 assert_eq!(config.font.font_type, FontType::Sans);
1757 }
1758
1759 #[test]
1760 fn merge_multiple_sections_independently() {
1761 let partial: PartialSiteConfig = toml::from_str(
1762 r##"
1763 [colors.dark]
1764 background = "#1a1a1a"
1765
1766 [font]
1767 font = "Lora"
1768 font_type = "serif"
1769 "##,
1770 )
1771 .unwrap();
1772 let config = SiteConfig::default().merge(partial);
1773
1774 assert_eq!(config.colors.dark.background, "#1a1a1a");
1776 assert_eq!(config.colors.dark.text, "#fafafa");
1777 assert_eq!(config.colors.light.background, "#ffffff");
1778
1779 assert_eq!(config.font.font, "Lora");
1780 assert_eq!(config.font.font_type, FontType::Serif);
1781 assert_eq!(config.font.weight, "600"); assert_eq!(config.images.quality, 90);
1785 assert_eq!(config.thumbnails.aspect_ratio, [4, 5]);
1786 assert_eq!(config.theme.mat_x.size, "3vw");
1787 }
1788
1789 #[test]
1794 fn default_assets_dir() {
1795 let config = SiteConfig::default();
1796 assert_eq!(config.assets_dir, "assets");
1797 }
1798
1799 #[test]
1800 fn parse_custom_assets_dir() {
1801 let toml = r#"assets_dir = "site-assets""#;
1802 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1803 let config = SiteConfig::default().merge(partial);
1804 assert_eq!(config.assets_dir, "site-assets");
1805 }
1806
1807 #[test]
1808 fn merge_preserves_default_assets_dir() {
1809 let partial: PartialSiteConfig = toml::from_str("[images]\nquality = 70\n").unwrap();
1810 let config = SiteConfig::default().merge(partial);
1811 assert_eq!(config.assets_dir, "assets");
1812 }
1813
1814 #[test]
1819 fn default_site_description_file() {
1820 let config = SiteConfig::default();
1821 assert_eq!(config.site_description_file, "site");
1822 }
1823
1824 #[test]
1825 fn parse_custom_site_description_file() {
1826 let toml = r#"site_description_file = "intro""#;
1827 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1828 let config = SiteConfig::default().merge(partial);
1829 assert_eq!(config.site_description_file, "intro");
1830 }
1831
1832 #[test]
1833 fn merge_preserves_default_site_description_file() {
1834 let partial: PartialSiteConfig = toml::from_str("[images]\nquality = 70\n").unwrap();
1835 let config = SiteConfig::default().merge(partial);
1836 assert_eq!(config.site_description_file, "site");
1837 }
1838
1839 #[test]
1844 fn default_font_is_google() {
1845 let config = FontConfig::default();
1846 assert!(!config.is_local());
1847 assert!(config.stylesheet_url().is_some());
1848 assert!(config.font_face_css().is_none());
1849 }
1850
1851 #[test]
1852 fn local_font_has_no_stylesheet_url() {
1853 let config = FontConfig {
1854 source: Some("fonts/MyFont.woff2".to_string()),
1855 ..FontConfig::default()
1856 };
1857 assert!(config.is_local());
1858 assert!(config.stylesheet_url().is_none());
1859 }
1860
1861 #[test]
1862 fn local_font_generates_font_face_css() {
1863 let config = FontConfig {
1864 font: "My Custom Font".to_string(),
1865 weight: "400".to_string(),
1866 font_type: FontType::Sans,
1867 source: Some("fonts/MyFont.woff2".to_string()),
1868 };
1869 let css = config.font_face_css().unwrap();
1870 assert!(css.contains("@font-face"));
1871 assert!(css.contains(r#"font-family: "My Custom Font""#));
1872 assert!(css.contains(r#"url("/fonts/MyFont.woff2")"#));
1873 assert!(css.contains(r#"format("woff2")"#));
1874 assert!(css.contains("font-weight: 400"));
1875 assert!(css.contains("font-display: swap"));
1876 }
1877
1878 #[test]
1879 fn parse_font_with_source() {
1880 let toml = r#"
1881[font]
1882font = "My Font"
1883weight = "400"
1884source = "fonts/myfont.woff2"
1885"#;
1886 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1887 let config = SiteConfig::default().merge(partial);
1888 assert_eq!(config.font.font, "My Font");
1889 assert_eq!(config.font.source.as_deref(), Some("fonts/myfont.woff2"));
1890 assert!(config.font.is_local());
1891 }
1892
1893 #[test]
1894 fn merge_font_source_preserves_other_fields() {
1895 let partial: PartialSiteConfig = toml::from_str(
1896 r#"
1897[font]
1898source = "fonts/custom.woff2"
1899"#,
1900 )
1901 .unwrap();
1902 let config = SiteConfig::default().merge(partial);
1903 assert_eq!(config.font.font, "Noto Sans"); assert_eq!(config.font.weight, "600"); assert_eq!(config.font.source.as_deref(), Some("fonts/custom.woff2"));
1906 }
1907
1908 #[test]
1909 fn font_format_detection() {
1910 assert_eq!(font_format_from_extension("font.woff2"), "woff2");
1911 assert_eq!(font_format_from_extension("font.woff"), "woff");
1912 assert_eq!(font_format_from_extension("font.ttf"), "truetype");
1913 assert_eq!(font_format_from_extension("font.otf"), "opentype");
1914 assert_eq!(font_format_from_extension("font.unknown"), "woff2");
1915 }
1916
1917 #[test]
1918 fn generate_font_css_includes_font_face_for_local() {
1919 let font = FontConfig {
1920 font: "Local Font".to_string(),
1921 weight: "700".to_string(),
1922 font_type: FontType::Serif,
1923 source: Some("fonts/local.woff2".to_string()),
1924 };
1925 let css = generate_font_css(&font);
1926 assert!(css.contains("@font-face"));
1927 assert!(css.contains("--font-family:"));
1928 assert!(css.contains("--font-weight: 700"));
1929 }
1930
1931 #[test]
1932 fn generate_font_css_no_font_face_for_google() {
1933 let font = FontConfig::default();
1934 let css = generate_font_css(&font);
1935 assert!(!css.contains("@font-face"));
1936 assert!(css.contains("--font-family:"));
1937 }
1938}