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