Skip to main content

simple_gal/
config.rs

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