Skip to main content

simple_gal/
config.rs

1//! Site configuration module.
2//!
3//! Handles loading, validating, and merging `config.toml` files. Configuration
4//! is hierarchical: stock defaults are overridden by user config files at any
5//! level of the directory tree (root → group → gallery).
6//!
7//! ## Config File Location
8//!
9//! Place `config.toml` in the content root and/or any album group or album directory:
10//!
11//! ```text
12//! content/
13//! ├── config.toml              # Root config (overrides stock defaults)
14//! ├── 010-Landscapes/
15//! │   └── ...
16//! └── 020-Travel/
17//!     ├── config.toml          # Group config (overrides root)
18//!     ├── 010-Japan/
19//!     │   ├── config.toml      # Gallery config (overrides group)
20//!     │   └── ...
21//!     └── 020-Italy/
22//!         └── ...
23//! ```
24//!
25//! ## Configuration Options
26//!
27//! ```toml
28//! # All options are optional - defaults shown below
29//!
30//! content_root = "content"  # Path to content directory (root-level only)
31//! site_title = "Gallery"    # Breadcrumb home label and index page title
32//!
33//! [thumbnails]
34//! aspect_ratio = [4, 5]     # width:height ratio
35//!
36//! [full_index]
37//! generates = false         # If true, render an "All Photos" page with every image
38//! show_link = false         # If true, add an "All Photos" item to the nav menu
39//! thumb_ratio = [4, 5]      # Aspect ratio for full-index thumbnails
40//! thumb_size = 400          # Short-edge size in pixels for full-index thumbnails
41//! thumb_gap = "0.2rem"      # Gap between thumbnails on the All Photos grid
42//!
43//! [images]
44//! sizes = [800, 1400, 2080] # Responsive sizes to generate
45//! quality = 90              # AVIF quality (0-100)
46//!
47//! [theme]
48//! thumbnail_gap = "0.2rem"  # Gap between thumbnails in grids
49//! grid_padding = "2rem"     # Padding around thumbnail grids
50//!
51//! [theme.mat_x]
52//! size = "3vw"              # Preferred horizontal mat size
53//! min = "1rem"              # Minimum horizontal mat size
54//! max = "2.5rem"            # Maximum horizontal mat size
55//!
56//! [theme.mat_y]
57//! size = "6vw"              # Preferred vertical mat size
58//! min = "2rem"              # Minimum vertical mat size
59//! max = "5rem"              # Maximum vertical mat size
60//!
61//! [colors.light]
62//! background = "#ffffff"
63//! text = "#111111"
64//! text_muted = "#666666"    # Nav menu, breadcrumbs, captions
65//! border = "#e0e0e0"
66//! separator = "#e0e0e0"
67//! link = "#333333"
68//! link_hover = "#000000"
69//!
70//! [colors.dark]
71//! background = "#000000"
72//! text = "#fafafa"
73//! text_muted = "#999999"
74//! border = "#333333"
75//! separator = "#333333"
76//! link = "#cccccc"
77//! link_hover = "#ffffff"
78//!
79//! [font]
80//! font = "Noto Sans"    # Google Fonts family name
81//! weight = "600"            # Font weight to load
82//! font_type = "sans"        # "sans" or "serif" (determines fallbacks)
83//!
84//! [processing]
85//! max_processes = 4         # Max parallel workers (omit for auto = CPU cores)
86//!
87//! ```
88//!
89//! ## Partial Configuration
90//!
91//! Config files are sparse — override just the values you want:
92//!
93//! ```toml
94//! # Only override the light mode background
95//! [colors.light]
96//! background = "#fafafa"
97//! ```
98//!
99//! Unknown keys are rejected to catch typos early.
100
101use 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/// Site configuration loaded from `config.toml`.
117///
118/// All fields have sensible defaults. User config files need only specify
119/// the values they want to override. Unknown keys are rejected.
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121#[serde(default, deny_unknown_fields)]
122pub struct SiteConfig {
123    /// Site title used in breadcrumbs and the browser tab for the home page.
124    #[serde(default = "default_site_title")]
125    pub site_title: String,
126    /// Directory for static assets (favicon, fonts, etc.), relative to content root.
127    /// Contents are copied verbatim to the output root during generation.
128    #[serde(default = "default_assets_dir")]
129    pub assets_dir: String,
130    /// Stem of the site description file in the content root (e.g. "site" →
131    /// looks for `site.md` / `site.txt`). Rendered on the index page.
132    #[serde(default = "default_site_description_file")]
133    pub site_description_file: String,
134    /// Color schemes for light and dark modes.
135    pub colors: ColorConfig,
136    /// Thumbnail generation settings (aspect ratio).
137    pub thumbnails: ThumbnailsConfig,
138    /// Site-wide "All Photos" index settings.
139    pub full_index: FullIndexConfig,
140    /// Responsive image generation settings (sizes, quality).
141    pub images: ImagesConfig,
142    /// Theme/layout settings (frame padding, grid spacing).
143    pub theme: ThemeConfig,
144    /// Font configuration (Google Fonts or local font file).
145    pub font: FontConfig,
146    /// Parallel processing settings.
147    pub processing: ProcessingConfig,
148}
149
150/// Partial site configuration for sparse loading and strict validation.
151#[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    /// Validate config values are within acceptable ranges.
197    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    /// Merge a partial config on top of this one.
227    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/// Parallel processing settings.
263#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
264#[serde(default, deny_unknown_fields)]
265pub struct ProcessingConfig {
266    /// Maximum number of parallel image processing workers.
267    /// When absent or null, defaults to the number of CPU cores.
268    /// Values larger than the core count are clamped down.
269    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/// Font category — determines fallback fonts in the CSS font stack.
288#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
289#[serde(rename_all = "lowercase")]
290pub enum FontType {
291    #[default]
292    Sans,
293    Serif,
294}
295
296/// Font configuration for the site.
297///
298/// By default, the font is loaded from Google Fonts via a `<link>` tag.
299/// Set `source` to a local font file path (relative to site root) to use
300/// a self-hosted font instead — this generates a `@font-face` declaration
301/// and skips the Google Fonts request entirely.
302///
303/// ```toml
304/// # Google Fonts (default)
305/// [font]
306/// font = "Noto Sans"
307/// weight = "600"
308/// font_type = "sans"
309///
310/// # Local font (put the file in your assets directory)
311/// [font]
312/// font = "My Custom Font"
313/// weight = "400"
314/// font_type = "sans"
315/// source = "fonts/MyFont.woff2"
316/// ```
317#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
318#[serde(default, deny_unknown_fields)]
319pub struct FontConfig {
320    /// Font family name (Google Fonts family or custom name for local fonts).
321    pub font: String,
322    /// Font weight to load (e.g. `"600"`).
323    pub weight: String,
324    /// Font category: `"sans"` or `"serif"` — determines fallback fonts.
325    pub font_type: FontType,
326    /// Path to a local font file, relative to the site root (e.g. `"fonts/MyFont.woff2"`).
327    /// When set, generates `@font-face` CSS instead of loading from Google Fonts.
328    /// The file should be placed in the assets directory so it gets copied to the output.
329    #[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    /// Whether this font is loaded from a local file (vs. Google Fonts).
371    pub fn is_local(&self) -> bool {
372        self.source.is_some()
373    }
374
375    /// Google Fonts stylesheet URL for use in a `<link>` element.
376    /// Returns `None` for local fonts.
377    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    /// Generate `@font-face` CSS for a local font.
389    /// Returns `None` for Google Fonts.
390    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    /// CSS `font-family` value with fallbacks based on `font_type`.
405    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
414/// Determine the CSS font format string from a file extension.
415fn 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", // sensible default
422    }
423}
424
425/// Resolve the effective thread count from config.
426///
427/// - `None` → use all available cores
428/// - `Some(n)` → use `min(n, cores)` (user can constrain down, not up)
429pub 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/// Thumbnail generation settings.
437#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
438#[serde(default, deny_unknown_fields)]
439pub struct ThumbnailsConfig {
440    /// Aspect ratio as `[width, height]`, e.g. `[4, 5]` for portrait thumbnails.
441    pub aspect_ratio: [u32; 2],
442    /// Thumbnail short-edge size in pixels.
443    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/// Settings for the site-wide "All Photos" index page.
475///
476/// When `generates` is true, the generate stage renders an extra page at
477/// `/all-photos/` showing every image from every public album in a single
478/// thumbnail grid. Thumbnails are generated at the ratio/size specified here,
479/// independent of the regular per-album `[thumbnails]` settings.
480#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
481#[serde(default, deny_unknown_fields)]
482pub struct FullIndexConfig {
483    /// Whether the All Photos page is rendered.
484    pub generates: bool,
485    /// Whether to add an "All Photos" item to the navigation menu.
486    pub show_link: bool,
487    /// Aspect ratio `[width, height]` for full-index thumbnails.
488    pub thumb_ratio: [u32; 2],
489    /// Short-edge size (in pixels) for full-index thumbnails.
490    pub thumb_size: u32,
491    /// Gap between thumbnails on the All Photos grid (CSS value).
492    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/// Responsive image generation settings.
539#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
540#[serde(default, deny_unknown_fields)]
541pub struct ImagesConfig {
542    /// Pixel widths (longer edge) to generate for responsive `<picture>` elements.
543    pub sizes: Vec<u32>,
544    /// AVIF encoding quality (0 = worst, 100 = best).
545    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/// A responsive CSS size expressed as `clamp(min, size, max)`.
577///
578/// - `size`: the preferred/fluid value, typically viewport-relative (e.g. `"3vw"`)
579/// - `min`: the minimum bound (e.g. `"1rem"`)
580/// - `max`: the maximum bound (e.g. `"2.5rem"`)
581///
582/// Generates `clamp(min, size, max)` in the output CSS.
583#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
584#[serde(deny_unknown_fields)]
585pub struct ClampSize {
586    /// Preferred/fluid value, typically viewport-relative (e.g. `"3vw"`).
587    pub size: String,
588    /// Minimum bound (e.g. `"1rem"`).
589    pub min: String,
590    /// Maximum bound (e.g. `"2.5rem"`).
591    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    /// Render as a CSS `clamp()` expression.
619    pub fn to_css(&self) -> String {
620        format!("clamp({}, {}, {})", self.min, self.size, self.max)
621    }
622}
623
624/// Theme/layout settings.
625#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
626#[serde(default, deny_unknown_fields)]
627pub struct ThemeConfig {
628    /// Horizontal mat around images (left/right). See docs/dev/photo-page-layout.md.
629    pub mat_x: ClampSize,
630    /// Vertical mat around images (top/bottom). See docs/dev/photo-page-layout.md.
631    pub mat_y: ClampSize,
632    /// Gap between thumbnails in both album and image grids (CSS value).
633    pub thumbnail_gap: String,
634    /// Padding around the thumbnail grid container (CSS value).
635    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/// Color configuration for light and dark modes.
685#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
686#[serde(default, deny_unknown_fields)]
687pub struct ColorConfig {
688    /// Light mode color scheme.
689    pub light: ColorScheme,
690    /// Dark mode color scheme.
691    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/// Individual color scheme (light or dark).
723#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
724#[serde(default, deny_unknown_fields)]
725pub struct ColorScheme {
726    /// Background color.
727    pub background: String,
728    /// Primary text color.
729    pub text: String,
730    /// Muted/secondary text color (used for nav menu, breadcrumbs, captions).
731    pub text_muted: String,
732    /// Border color.
733    pub border: String,
734    /// Separator color (header bar underline, nav menu divider).
735    pub separator: String,
736    /// Link color.
737    pub link: String,
738    /// Link hover color.
739    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
813// =============================================================================
814// Config loading, merging, and validation
815// =============================================================================
816
817/// Load a partial, validated config from `config.toml`.
818///
819/// Returns `Ok(None)` if no `config.toml` exists.
820/// Returns `Err` if the file exists but contains unknown keys or invalid values.
821pub 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
831/// Load config from `config.toml` in the given directory and merge onto defaults.
832pub 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
844/// Returns a fully-commented stock `config.toml` with all keys and explanations.
845///
846/// Used by the `gen-config` CLI command.
847pub 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
1003/// Generate CSS custom properties from color config.
1004///
1005/// These `generate_*_css()` functions produce `:root { … }` blocks that are
1006/// prepended to the inline `<style>` in every page. The Google Font is loaded
1007/// separately via a `<link>` tag (see `FontConfig::stylesheet_url` and
1008/// `base_document` in generate.rs). Variables defined here are consumed by
1009/// `static/style.css`; do not redefine them there.
1010pub 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
1050/// Generate CSS custom properties from theme config.
1051pub 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
1066/// Generate CSS custom properties from font config.
1067///
1068/// For local fonts, also includes the `@font-face` declaration.
1069pub 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        // Overridden value
1130        assert_eq!(config.colors.light.background, "#fafafa");
1131        // Default values preserved
1132        assert_eq!(config.colors.light.text, "#111111");
1133        assert_eq!(config.colors.dark.background, "#000000");
1134        // Image settings should be defaults
1135        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        // Unspecified defaults preserved
1155        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        // Other fields keep defaults
1199        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    // =========================================================================
1223    // load_config tests
1224    // =========================================================================
1225
1226    #[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        // Unspecified values should be defaults
1254        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        // Light mode
1287        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        // Dark mode
1292        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        // "unknown_key" is not a valid field
1314        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    // =========================================================================
1327    // CSS generation tests
1328    // =========================================================================
1329
1330    #[test]
1331    fn generate_css_includes_all_variables() {
1332        let colors = ColorConfig::default();
1333        let css = generate_color_css(&colors);
1334
1335        // Check all CSS variables are present
1336        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    // =========================================================================
1399    // Processing config tests
1400    // =========================================================================
1401
1402    #[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    // =========================================================================
1461    // merge_toml tests - REMOVED (function removed)
1462    // =========================================================================
1463
1464    // =========================================================================
1465    // Unknown key rejection tests
1466    // =========================================================================
1467
1468    #[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    // =========================================================================
1517    // Validation tests
1518    // =========================================================================
1519
1520    #[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    // =========================================================================
1578    // load_partial_config / merge tests
1579    // =========================================================================
1580
1581    #[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        // Other fields preserved from defaults
1625        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        // Create config with invalid value
1632        fs::write(
1633            tmp.path().join("config.toml"),
1634            r#"
1635[images]
1636quality = 200
1637"#,
1638        )
1639        .unwrap();
1640
1641        // load_config should fail validation
1642        let result = load_config(tmp.path());
1643        assert!(matches!(result, Err(ConfigError::Validation(_))));
1644    }
1645
1646    // =========================================================================
1647    // stock_config_toml tests
1648    // =========================================================================
1649
1650    #[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        // We removed stock_defaults_value, but we can test that Default trait works
1685        let config = SiteConfig::default();
1686        assert_eq!(config.images.quality, 90);
1687    }
1688
1689    // =========================================================================
1690    // Partial nested merge tests — verify unset fields are preserved
1691    // =========================================================================
1692
1693    #[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        // Overridden
1705        assert_eq!(config.theme.mat_x.size, "5vw");
1706        // Preserved from defaults
1707        assert_eq!(config.theme.mat_x.min, "1rem");
1708        assert_eq!(config.theme.mat_x.max, "2.5rem");
1709        // mat_y entirely untouched
1710        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        // Other theme fields untouched
1714        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        // Overridden
1731        assert_eq!(config.colors.light.background, "#fafafa");
1732        assert_eq!(config.colors.light.text, "#222222");
1733        // Light defaults preserved for unset fields
1734        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        // Dark entirely untouched
1739        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        // Each section merged independently
1775        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"); // preserved
1782
1783        // Sections not mentioned at all → full defaults
1784        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    // =========================================================================
1790    // Assets directory tests
1791    // =========================================================================
1792
1793    #[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    // =========================================================================
1815    // Site description file tests
1816    // =========================================================================
1817
1818    #[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    // =========================================================================
1840    // Local font tests
1841    // =========================================================================
1842
1843    #[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"); // default preserved
1904        assert_eq!(config.font.weight, "600"); // default preserved
1905        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}