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