Skip to main content

simple_gal/
config.rs

1//! Site configuration module.
2//!
3//! `SiteConfig` is a [confique][confique::Config] struct: defaults live as
4//! `#[config(default = ...)]` annotations on the fields, sparse loading +
5//! deep merge are handled by confique's generated `Layer` type, and the
6//! [`simple-gal config`][crate] CLI subcommand group is wired through
7//! [clapfig][clapfig].
8//!
9//! ## Per-directory cascade
10//!
11//! Site config is hierarchical: a `config.toml` may live in the content
12//! root, in any album group, and in any album directory. The scan stage
13//! walks the tree and merges every `config.toml` it finds onto its parent's
14//! resolved config, so each album sees its own combined view (root → group
15//! → gallery). The cascade machinery lives in `scan.rs`; this module owns
16//! the type, defaults, validation, and CSS-emitting helpers consumed by
17//! `generate.rs`.
18//!
19//! ## Configuration shape
20//!
21//! ```toml
22//! site_title = "Gallery"
23//! assets_dir = "assets"
24//!
25//! [thumbnails]
26//! aspect_ratio = [4, 5]
27//! size = 400
28//!
29//! [full_index]
30//! generates = false
31//! show_link = false
32//! thumb_ratio = [4, 5]
33//! thumb_size = 400
34//! thumb_gap = "0.2rem"
35//!
36//! [images]
37//! sizes = [800, 1400, 2080]
38//! quality = 90
39//!
40//! [theme]
41//! thumbnail_gap = "0.2rem"
42//! grid_padding = "2rem"
43//!
44//! [theme.mat_x]
45//! size = "3vw"
46//! min  = "1rem"
47//! max  = "2.5rem"
48//!
49//! [theme.mat_y]
50//! size = "6vw"
51//! min  = "2rem"
52//! max  = "5rem"
53//!
54//! [colors.light]
55//! background = "#ffffff"
56//! # ...
57//!
58//! [colors.dark]
59//! background = "#000000"
60//! # ...
61//!
62//! [font]
63//! font = "Noto Sans"
64//! weight = "600"
65//! font_type = "sans"
66//!
67//! [processing]
68//! # max_processes = 4   # omit for auto-detect
69//! ```
70//!
71//! Run `simple-gal config gen` to print a documented template derived
72//! directly from this struct.
73
74use confique::Config;
75use confique::Layer;
76use confique::meta::Meta;
77use serde::{Deserialize, Serialize};
78use std::fs;
79use std::path::{Path, PathBuf};
80use std::sync::Arc;
81use thiserror::Error;
82
83#[derive(Error, Debug)]
84pub enum ConfigError {
85    #[error("IO error: {0}")]
86    Io(#[from] std::io::Error),
87    /// A TOML parse failure on a specific config file.
88    ///
89    /// Carries the originating path and the full file contents so callers
90    /// (e.g. the CLI in `main.rs`) can hand the error to clapfig's renderer
91    /// for a snippet + caret view instead of showing a bare parser message.
92    #[error("failed to parse {}: {source}", path.display())]
93    Toml {
94        path: PathBuf,
95        #[source]
96        source: Box<toml::de::Error>,
97        source_text: String,
98    },
99    /// confique's deserialize/build error from converting a merged layer
100    /// into the typed `SiteConfig`. Distinct from `Toml` because it can
101    /// fire on a layer that came from multiple files.
102    #[error("config error: {0}")]
103    Confique(#[from] confique::Error),
104    #[error("Config validation error: {0}")]
105    Validation(String),
106}
107
108impl ConfigError {
109    /// Convert a config error into the richer `clapfig::error::ClapfigError`
110    /// representation when possible, so the CLI can render it through
111    /// clapfig's plain/rich (miette) renderers. Returns `None` for error
112    /// kinds that don't carry source-file context (IO failures, range
113    /// validation failures).
114    pub fn to_clapfig_error(&self) -> Option<clapfig::error::ClapfigError> {
115        match self {
116            ConfigError::Toml {
117                path,
118                source,
119                source_text,
120            } => Some(clapfig::error::ClapfigError::ParseError {
121                path: path.clone(),
122                source: source.clone(),
123                source_text: Some(Arc::from(source_text.as_str())),
124            }),
125            _ => None,
126        }
127    }
128}
129
130// =============================================================================
131// SiteConfig — top level
132// =============================================================================
133
134/// Site configuration loaded from `config.toml`.
135///
136/// All fields have sensible defaults. User config files need only specify
137/// the values they want to override. Unknown keys are rejected.
138//
139// Note: `Deserialize` is implemented manually below so that any caller
140// reading a `SiteConfig` from JSON or TOML — including the manifest reader
141// in `process.rs` — gets the same sparse-tolerant + default-merge semantics
142// as `load_config`. Kept as a regular comment so it doesn't leak into the
143// schema/template doc strings.
144#[derive(Config, Debug, Clone, Serialize, PartialEq)]
145#[config(layer_attr(derive(Clone)))]
146#[config(layer_attr(serde(deny_unknown_fields)))]
147pub struct SiteConfig {
148    /// Site title used in breadcrumbs and the browser tab for the home page.
149    #[config(default = "Gallery")]
150    pub site_title: String,
151
152    /// Directory for static assets (favicon, fonts, etc.), relative to
153    /// content root. Contents are copied verbatim to the output root during
154    /// generation. If the directory doesn't exist, it is silently skipped.
155    #[config(default = "assets")]
156    pub assets_dir: String,
157
158    /// Stem of the site description file in the content root (e.g. `site`
159    /// → looks for `site.md` / `site.txt`). Rendered on the index page.
160    #[config(default = "site")]
161    pub site_description_file: String,
162
163    /// Color schemes for light and dark modes.
164    #[config(nested)]
165    pub colors: ColorConfig,
166
167    /// Thumbnail generation settings (aspect ratio, size).
168    #[config(nested)]
169    pub thumbnails: ThumbnailsConfig,
170
171    /// Site-wide "All Photos" index settings.
172    #[config(nested)]
173    pub full_index: FullIndexConfig,
174
175    /// Responsive image generation settings (sizes, quality).
176    #[config(nested)]
177    pub images: ImagesConfig,
178
179    /// Theme / layout settings (mats, grid spacing).
180    #[config(nested)]
181    pub theme: ThemeConfig,
182
183    /// Font configuration (Google Fonts or local font file).
184    #[config(nested)]
185    pub font: FontConfig,
186
187    /// Parallel processing settings.
188    #[config(nested)]
189    pub processing: ProcessingConfig,
190}
191
192impl Default for SiteConfig {
193    /// Construct a `SiteConfig` populated entirely from confique-declared
194    /// defaults. Used by tests and by the scan stage to seed the cascade
195    /// before any user `config.toml` is layered on.
196    fn default() -> Self {
197        let layer = <SiteConfig as Config>::Layer::default_values();
198        SiteConfig::from_layer(layer).expect("confique defaults must satisfy the SiteConfig schema")
199    }
200}
201
202impl<'de> Deserialize<'de> for SiteConfig {
203    /// Custom deserialize that funnels any input (TOML config file, JSON
204    /// manifest field, test fixture) through the same sparse-layer +
205    /// fill-defaults pipeline `load_config` uses.
206    ///
207    /// Without this, missing fields on a directly-deserialized `SiteConfig`
208    /// would be hard errors instead of falling through to confique-declared
209    /// defaults — which would force every manifest writer (and every test
210    /// fixture) to spell out every field explicitly.
211    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
212    where
213        D: serde::Deserializer<'de>,
214    {
215        let layer = SiteConfigLayer::deserialize(deserializer)?;
216        let merged = layer.with_fallback(SiteConfigLayer::default_values());
217        SiteConfig::from_layer(merged).map_err(serde::de::Error::custom)
218    }
219}
220
221impl SiteConfig {
222    /// Validate semantic constraints that confique's type system can't
223    /// express: numeric ranges, non-empty arrays, and so on.
224    ///
225    /// Wired into clapfig's `.post_validate()` hook in [`load_config`] so
226    /// every loaded config (CLI, cascade leaf, test fixture) runs the same
227    /// checks.
228    pub fn validate(&self) -> Result<(), ConfigError> {
229        if self.images.quality > 100 {
230            return Err(ConfigError::Validation(
231                "images.quality must be 0-100".into(),
232            ));
233        }
234        if self.thumbnails.aspect_ratio[0] == 0 || self.thumbnails.aspect_ratio[1] == 0 {
235            return Err(ConfigError::Validation(
236                "thumbnails.aspect_ratio values must be non-zero".into(),
237            ));
238        }
239        if self.full_index.thumb_ratio[0] == 0 || self.full_index.thumb_ratio[1] == 0 {
240            return Err(ConfigError::Validation(
241                "full_index.thumb_ratio values must be non-zero".into(),
242            ));
243        }
244        if self.full_index.thumb_size == 0 {
245            return Err(ConfigError::Validation(
246                "full_index.thumb_size must be non-zero".into(),
247            ));
248        }
249        if self.images.sizes.is_empty() {
250            return Err(ConfigError::Validation(
251                "images.sizes must not be empty".into(),
252            ));
253        }
254        Ok(())
255    }
256}
257
258// =============================================================================
259// Thumbnails
260// =============================================================================
261
262/// Thumbnail generation settings.
263#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
264#[config(layer_attr(derive(Clone)))]
265#[config(layer_attr(serde(deny_unknown_fields)))]
266pub struct ThumbnailsConfig {
267    /// Aspect ratio as `[width, height]`, e.g. `[4, 5]` for portrait.
268    #[config(default = [4, 5])]
269    pub aspect_ratio: [u32; 2],
270    /// Thumbnail short-edge size in pixels.
271    #[config(default = 400)]
272    pub size: u32,
273}
274
275// =============================================================================
276// Full index ("All Photos")
277// =============================================================================
278
279/// Settings for the site-wide "All Photos" index page.
280///
281/// When `generates` is true, the generate stage renders an extra page at
282/// `/all-photos/` showing every image from every public album in a single
283/// thumbnail grid. Thumbnails are generated at the ratio/size specified
284/// here, independent of the regular per-album `[thumbnails]` settings.
285#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
286#[config(layer_attr(derive(Clone)))]
287#[config(layer_attr(serde(deny_unknown_fields)))]
288pub struct FullIndexConfig {
289    /// Whether the All Photos page is rendered.
290    #[config(default = false)]
291    pub generates: bool,
292    /// Whether to add an "All Photos" item to the navigation menu.
293    #[config(default = false)]
294    pub show_link: bool,
295    /// Aspect ratio `[width, height]` for full-index thumbnails.
296    #[config(default = [4, 5])]
297    pub thumb_ratio: [u32; 2],
298    /// Short-edge size (in pixels) for full-index thumbnails.
299    #[config(default = 400)]
300    pub thumb_size: u32,
301    /// Gap between thumbnails on the All Photos grid (CSS value).
302    #[config(default = "0.2rem")]
303    pub thumb_gap: String,
304}
305
306// =============================================================================
307// Images
308// =============================================================================
309
310/// Responsive image generation settings.
311#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
312#[config(layer_attr(derive(Clone)))]
313#[config(layer_attr(serde(deny_unknown_fields)))]
314pub struct ImagesConfig {
315    /// Pixel widths (longer edge) to generate for responsive `<picture>`
316    /// elements.
317    #[config(default = [800, 1400, 2080])]
318    pub sizes: Vec<u32>,
319    /// AVIF encoding quality (0 = worst, 100 = best).
320    #[config(default = 90)]
321    pub quality: u32,
322}
323
324// =============================================================================
325// Theme — mat_x and mat_y are split into distinct types so each side has
326// its own confique-declared defaults (and shows up correctly in the schema).
327// =============================================================================
328
329/// Horizontal mat around images, expressed as `clamp(min, size, max)`.
330///
331/// - `size`: preferred/fluid value, typically viewport-relative (e.g. `3vw`)
332/// - `min`: minimum bound (e.g. `1rem`)
333/// - `max`: maximum bound (e.g. `2.5rem`)
334#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
335#[config(layer_attr(derive(Clone)))]
336#[config(layer_attr(serde(deny_unknown_fields)))]
337pub struct MatX {
338    /// Preferred/fluid value, typically viewport-relative.
339    #[config(default = "3vw")]
340    pub size: String,
341    /// Minimum bound.
342    #[config(default = "1rem")]
343    pub min: String,
344    /// Maximum bound.
345    #[config(default = "2.5rem")]
346    pub max: String,
347}
348
349/// Vertical mat around images, expressed as `clamp(min, size, max)`.
350#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
351#[config(layer_attr(derive(Clone)))]
352#[config(layer_attr(serde(deny_unknown_fields)))]
353pub struct MatY {
354    /// Preferred/fluid value, typically viewport-relative.
355    #[config(default = "6vw")]
356    pub size: String,
357    /// Minimum bound.
358    #[config(default = "2rem")]
359    pub min: String,
360    /// Maximum bound.
361    #[config(default = "5rem")]
362    pub max: String,
363}
364
365/// Render a `clamp(min, size, max)` CSS expression from the three parts.
366fn clamp_to_css(size: &str, min: &str, max: &str) -> String {
367    format!("clamp({}, {}, {})", min, size, max)
368}
369
370impl MatX {
371    pub fn to_css(&self) -> String {
372        clamp_to_css(&self.size, &self.min, &self.max)
373    }
374}
375
376impl MatY {
377    pub fn to_css(&self) -> String {
378        clamp_to_css(&self.size, &self.min, &self.max)
379    }
380}
381
382/// Theme / layout settings.
383#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
384#[config(layer_attr(derive(Clone)))]
385#[config(layer_attr(serde(deny_unknown_fields)))]
386pub struct ThemeConfig {
387    /// Horizontal mat around images. See `docs/dev/photo-page-layout.md`.
388    #[config(nested)]
389    pub mat_x: MatX,
390    /// Vertical mat around images. See `docs/dev/photo-page-layout.md`.
391    #[config(nested)]
392    pub mat_y: MatY,
393    /// Gap between thumbnails in both album and image grids (CSS value).
394    #[config(default = "0.2rem")]
395    pub thumbnail_gap: String,
396    /// Padding around the thumbnail grid container (CSS value).
397    #[config(default = "2rem")]
398    pub grid_padding: String,
399}
400
401// =============================================================================
402// Colors — light and dark are split into distinct types so each side has
403// its own confique-declared defaults.
404// =============================================================================
405
406/// Color configuration for light and dark modes.
407#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
408#[config(layer_attr(derive(Clone)))]
409#[config(layer_attr(serde(deny_unknown_fields)))]
410pub struct ColorConfig {
411    /// Light mode color scheme.
412    #[config(nested)]
413    pub light: LightColors,
414    /// Dark mode color scheme.
415    #[config(nested)]
416    pub dark: DarkColors,
417}
418
419/// Light-mode color scheme.
420#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
421#[config(layer_attr(derive(Clone)))]
422#[config(layer_attr(serde(deny_unknown_fields)))]
423pub struct LightColors {
424    /// Background color.
425    #[config(default = "#ffffff")]
426    pub background: String,
427    /// Primary text color.
428    #[config(default = "#111111")]
429    pub text: String,
430    /// Muted/secondary text color (nav menu, breadcrumbs, captions).
431    #[config(default = "#666666")]
432    pub text_muted: String,
433    /// Border color.
434    #[config(default = "#e0e0e0")]
435    pub border: String,
436    /// Separator color (header bar underline, nav menu divider).
437    #[config(default = "#e0e0e0")]
438    pub separator: String,
439    /// Link color.
440    #[config(default = "#333333")]
441    pub link: String,
442    /// Link hover color.
443    #[config(default = "#000000")]
444    pub link_hover: String,
445}
446
447/// Dark-mode color scheme.
448#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
449#[config(layer_attr(derive(Clone)))]
450#[config(layer_attr(serde(deny_unknown_fields)))]
451pub struct DarkColors {
452    /// Background color.
453    #[config(default = "#000000")]
454    pub background: String,
455    /// Primary text color.
456    #[config(default = "#fafafa")]
457    pub text: String,
458    /// Muted/secondary text color (nav menu, breadcrumbs, captions).
459    #[config(default = "#999999")]
460    pub text_muted: String,
461    /// Border color.
462    #[config(default = "#333333")]
463    pub border: String,
464    /// Separator color (header bar underline, nav menu divider).
465    #[config(default = "#333333")]
466    pub separator: String,
467    /// Link color.
468    #[config(default = "#cccccc")]
469    pub link: String,
470    /// Link hover color.
471    #[config(default = "#ffffff")]
472    pub link_hover: String,
473}
474
475// =============================================================================
476// Font
477// =============================================================================
478
479/// Font category — determines fallback fonts in the CSS font stack.
480#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
481#[serde(rename_all = "lowercase")]
482pub enum FontType {
483    #[default]
484    Sans,
485    Serif,
486}
487
488/// Font configuration for the site.
489///
490/// By default, the font is loaded from Google Fonts via a `<link>` tag.
491/// Set `source` to a local font file path (relative to site root) to use a
492/// self-hosted font instead — this generates a `@font-face` declaration
493/// and skips the Google Fonts request entirely.
494///
495/// ```toml
496/// # Google Fonts (default)
497/// [font]
498/// font = "Noto Sans"
499/// weight = "600"
500/// font_type = "sans"
501///
502/// # Local font (put the file in your assets directory)
503/// [font]
504/// font = "My Custom Font"
505/// weight = "400"
506/// font_type = "sans"
507/// source = "fonts/MyFont.woff2"
508/// ```
509#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
510#[config(layer_attr(derive(Clone)))]
511#[config(layer_attr(serde(deny_unknown_fields)))]
512pub struct FontConfig {
513    /// Font family name (Google Fonts family or custom name for local fonts).
514    #[config(default = "Noto Sans")]
515    pub font: String,
516    /// Font weight to load (e.g. `"600"`).
517    #[config(default = "600")]
518    pub weight: String,
519    /// Font category: `"sans"` or `"serif"` — determines fallback fonts.
520    #[config(default = "sans")]
521    pub font_type: FontType,
522    /// Path to a local font file, relative to the site root
523    /// (e.g. `"fonts/MyFont.woff2"`). When set, generates `@font-face` CSS
524    /// instead of loading from Google Fonts. The file should be placed in
525    /// the assets directory so it gets copied to the output.
526    pub source: Option<String>,
527}
528
529impl FontConfig {
530    /// Whether this font is loaded from a local file (vs. Google Fonts).
531    pub fn is_local(&self) -> bool {
532        self.source.is_some()
533    }
534
535    /// Google Fonts stylesheet URL for use in a `<link>` element.
536    /// Returns `None` for local fonts.
537    pub fn stylesheet_url(&self) -> Option<String> {
538        if self.is_local() {
539            return None;
540        }
541        let family = self.font.replace(' ', "+");
542        Some(format!(
543            "https://fonts.googleapis.com/css2?family={}:wght@{}&display=swap",
544            family, self.weight
545        ))
546    }
547
548    /// Generate `@font-face` CSS for a local font. Returns `None` for
549    /// Google Fonts.
550    pub fn font_face_css(&self) -> Option<String> {
551        let src = self.source.as_ref()?;
552        let format = font_format_from_extension(src);
553        Some(format!(
554            r#"@font-face {{
555    font-family: "{}";
556    src: url("/{}") format("{}");
557    font-weight: {};
558    font-display: swap;
559}}"#,
560            self.font, src, format, self.weight
561        ))
562    }
563
564    /// CSS `font-family` value with fallbacks based on `font_type`.
565    pub fn font_family_css(&self) -> String {
566        let fallbacks = match self.font_type {
567            FontType::Serif => r#"Georgia, "Times New Roman", serif"#,
568            FontType::Sans => "Helvetica, Verdana, sans-serif",
569        };
570        format!(r#""{}", {}"#, self.font, fallbacks)
571    }
572}
573
574/// Determine the CSS font format string from a file extension.
575fn font_format_from_extension(path: &str) -> &'static str {
576    match path.rsplit('.').next().map(|e| e.to_lowercase()).as_deref() {
577        Some("woff2") => "woff2",
578        Some("woff") => "woff",
579        Some("ttf") => "truetype",
580        Some("otf") => "opentype",
581        _ => "woff2", // sensible default
582    }
583}
584
585// =============================================================================
586// Processing
587// =============================================================================
588
589/// Parallel processing settings.
590#[derive(Config, Debug, Clone, Serialize, Deserialize, PartialEq)]
591#[config(layer_attr(derive(Clone)))]
592#[config(layer_attr(serde(deny_unknown_fields)))]
593pub struct ProcessingConfig {
594    /// Maximum number of parallel image processing workers.
595    /// When absent, defaults to the number of CPU cores.
596    /// Values larger than the core count are clamped down.
597    pub max_processes: Option<usize>,
598}
599
600/// Resolve the effective thread count from config.
601///
602/// - `None` → use all available cores
603/// - `Some(n)` → use `min(n, cores)` (user can constrain down, not up)
604pub fn effective_threads(config: &ProcessingConfig) -> usize {
605    let cores = std::thread::available_parallelism()
606        .map(|n| n.get())
607        .unwrap_or(1);
608    config.max_processes.map(|n| n.min(cores)).unwrap_or(cores)
609}
610
611// =============================================================================
612// Loading: per-file partial layer + per-directory cascade helpers
613// =============================================================================
614
615/// Confique-derived layer type for `SiteConfig`.
616///
617/// Every field is `Option<T>` so layers compose by overlay. The scan stage
618/// produces one of these per `config.toml` it finds and folds them together
619/// with [`with_fallback`][confique::Layer::with_fallback].
620pub type SiteConfigLayer = <SiteConfig as Config>::Layer;
621
622/// Load a single sparse `config.toml` from `dir` into a layer.
623///
624/// Returns `Ok(None)` if the file is absent — caller decides whether that's
625/// an error. Returns `Err` for parse failures (with the original text
626/// retained for snippet rendering) and for unknown-key violations enforced
627/// by confique's strict deserializer.
628pub fn load_layer(dir: &Path) -> Result<Option<SiteConfigLayer>, ConfigError> {
629    let config_path = dir.join("config.toml");
630    if !config_path.exists() {
631        return Ok(None);
632    }
633    let content = fs::read_to_string(&config_path)?;
634    let layer: SiteConfigLayer = toml::from_str(&content).map_err(|e| ConfigError::Toml {
635        path: config_path.clone(),
636        source: Box::new(e),
637        source_text: content,
638    })?;
639    Ok(Some(layer))
640}
641
642/// Load `config.toml` from `dir`, merge it onto confique defaults, and
643/// validate.
644///
645/// Used at the root of the cascade and by tests that exercise the full
646/// load → validate flow.
647pub fn load_config(dir: &Path) -> Result<SiteConfig, ConfigError> {
648    let user = load_layer(dir)?.unwrap_or_else(SiteConfigLayer::empty);
649    let merged = user.with_fallback(SiteConfigLayer::default_values());
650    let config = SiteConfig::from_layer(merged)?;
651    config.validate()?;
652    Ok(config)
653}
654
655/// Build a `SiteConfig` from a single layer, merging in confique defaults
656/// for any unset fields. Validates before returning. Used by the scan
657/// stage's per-directory cascade after layers have been folded together.
658pub fn finalize_layer(layer: SiteConfigLayer) -> Result<SiteConfig, ConfigError> {
659    let merged = layer.with_fallback(SiteConfigLayer::default_values());
660    let config = SiteConfig::from_layer(merged)?;
661    config.validate()?;
662    Ok(config)
663}
664
665/// Static metadata for the `SiteConfig` schema. Re-exported so the
666/// [`crate`] CLI can hand it to clapfig's schema-emitting subcommand
667/// without having to depend on confique itself.
668pub fn site_config_meta() -> &'static Meta {
669    &<SiteConfig as Config>::META
670}
671
672// =============================================================================
673// CSS generators (consumers of resolved config — not config plumbing)
674// =============================================================================
675
676/// Generate CSS custom properties from color config.
677///
678/// These `generate_*_css()` functions produce `:root { … }` blocks that are
679/// prepended to the inline `<style>` in every page. The Google Font is
680/// loaded separately via a `<link>` tag (see `FontConfig::stylesheet_url`
681/// and `base_document` in `generate.rs`). Variables defined here are
682/// consumed by `static/style.css`; do not redefine them there.
683pub fn generate_color_css(colors: &ColorConfig) -> String {
684    format!(
685        r#":root {{
686    --color-bg: {light_bg};
687    --color-text: {light_text};
688    --color-text-muted: {light_text_muted};
689    --color-border: {light_border};
690    --color-link: {light_link};
691    --color-link-hover: {light_link_hover};
692    --color-separator: {light_separator};
693}}
694
695@media (prefers-color-scheme: dark) {{
696    :root {{
697        --color-bg: {dark_bg};
698        --color-text: {dark_text};
699        --color-text-muted: {dark_text_muted};
700        --color-border: {dark_border};
701        --color-link: {dark_link};
702        --color-link-hover: {dark_link_hover};
703        --color-separator: {dark_separator};
704    }}
705}}"#,
706        light_bg = colors.light.background,
707        light_text = colors.light.text,
708        light_text_muted = colors.light.text_muted,
709        light_border = colors.light.border,
710        light_separator = colors.light.separator,
711        light_link = colors.light.link,
712        light_link_hover = colors.light.link_hover,
713        dark_bg = colors.dark.background,
714        dark_text = colors.dark.text,
715        dark_text_muted = colors.dark.text_muted,
716        dark_border = colors.dark.border,
717        dark_separator = colors.dark.separator,
718        dark_link = colors.dark.link,
719        dark_link_hover = colors.dark.link_hover,
720    )
721}
722
723/// Generate CSS custom properties from theme config.
724pub fn generate_theme_css(theme: &ThemeConfig) -> String {
725    format!(
726        r#":root {{
727    --mat-x: {mat_x};
728    --mat-y: {mat_y};
729    --thumbnail-gap: {thumbnail_gap};
730    --grid-padding: {grid_padding};
731}}"#,
732        mat_x = theme.mat_x.to_css(),
733        mat_y = theme.mat_y.to_css(),
734        thumbnail_gap = theme.thumbnail_gap,
735        grid_padding = theme.grid_padding,
736    )
737}
738
739/// Generate CSS custom properties from font config.
740///
741/// For local fonts, also includes the `@font-face` declaration.
742pub fn generate_font_css(font: &FontConfig) -> String {
743    let vars = format!(
744        r#":root {{
745    --font-family: {family};
746    --font-weight: {weight};
747}}"#,
748        family = font.font_family_css(),
749        weight = font.weight,
750    );
751    match font.font_face_css() {
752        Some(face) => format!("{}\n\n{}", face, vars),
753        None => vars,
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760    use tempfile::TempDir;
761
762    fn write_config(dir: &Path, body: &str) {
763        fs::write(dir.join("config.toml"), body).unwrap();
764    }
765
766    // ----- defaults -----
767
768    #[test]
769    fn default_config_has_colors() {
770        let config = SiteConfig::default();
771        assert_eq!(config.colors.light.background, "#ffffff");
772        assert_eq!(config.colors.dark.background, "#000000");
773    }
774
775    #[test]
776    fn default_config_has_site_title() {
777        let config = SiteConfig::default();
778        assert_eq!(config.site_title, "Gallery");
779    }
780
781    #[test]
782    fn default_config_has_image_settings() {
783        let config = SiteConfig::default();
784        assert_eq!(config.thumbnails.aspect_ratio, [4, 5]);
785        assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
786        assert_eq!(config.images.quality, 90);
787        assert_eq!(config.theme.mat_x.to_css(), "clamp(1rem, 3vw, 2.5rem)");
788        assert_eq!(config.theme.mat_y.to_css(), "clamp(2rem, 6vw, 5rem)");
789    }
790
791    #[test]
792    fn default_full_index_is_off() {
793        let config = SiteConfig::default();
794        assert!(!config.full_index.generates);
795        assert!(!config.full_index.show_link);
796        assert_eq!(config.full_index.thumb_ratio, [4, 5]);
797        assert_eq!(config.full_index.thumb_size, 400);
798        assert_eq!(config.full_index.thumb_gap, "0.2rem");
799    }
800
801    #[test]
802    fn default_thumbnail_gap_and_grid_padding() {
803        let config = SiteConfig::default();
804        assert_eq!(config.theme.thumbnail_gap, "0.2rem");
805        assert_eq!(config.theme.grid_padding, "2rem");
806    }
807
808    #[test]
809    fn default_assets_dir() {
810        let config = SiteConfig::default();
811        assert_eq!(config.assets_dir, "assets");
812    }
813
814    #[test]
815    fn default_site_description_file() {
816        let config = SiteConfig::default();
817        assert_eq!(config.site_description_file, "site");
818    }
819
820    #[test]
821    fn default_processing_config() {
822        let config = SiteConfig::default();
823        assert_eq!(config.processing.max_processes, None);
824    }
825
826    // ----- sparse layer parsing through load_config -----
827
828    #[test]
829    fn parse_custom_site_title() {
830        let tmp = TempDir::new().unwrap();
831        write_config(tmp.path(), r#"site_title = "My Portfolio""#);
832        let config = load_config(tmp.path()).unwrap();
833        assert_eq!(config.site_title, "My Portfolio");
834    }
835
836    #[test]
837    fn parse_partial_colors_only() {
838        let tmp = TempDir::new().unwrap();
839        write_config(
840            tmp.path(),
841            r##"
842[colors.light]
843background = "#fafafa"
844"##,
845        );
846        let config = load_config(tmp.path()).unwrap();
847        // Overridden value
848        assert_eq!(config.colors.light.background, "#fafafa");
849        // Sibling defaults preserved
850        assert_eq!(config.colors.light.text, "#111111");
851        assert_eq!(config.colors.dark.background, "#000000");
852        // Unrelated section defaults preserved
853        assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
854    }
855
856    #[test]
857    fn parse_image_settings() {
858        let tmp = TempDir::new().unwrap();
859        write_config(
860            tmp.path(),
861            r##"
862[thumbnails]
863aspect_ratio = [1, 1]
864
865[images]
866sizes = [400, 800]
867quality = 85
868"##,
869        );
870        let config = load_config(tmp.path()).unwrap();
871        assert_eq!(config.thumbnails.aspect_ratio, [1, 1]);
872        assert_eq!(config.images.sizes, vec![400, 800]);
873        assert_eq!(config.images.quality, 85);
874        assert_eq!(config.colors.light.background, "#ffffff");
875    }
876
877    #[test]
878    fn parse_full_index_settings() {
879        let tmp = TempDir::new().unwrap();
880        write_config(
881            tmp.path(),
882            r##"
883[full_index]
884generates = true
885show_link = true
886thumb_ratio = [4, 4]
887thumb_size = 1000
888thumb_gap = "0.5rem"
889"##,
890        );
891        let config = load_config(tmp.path()).unwrap();
892        assert!(config.full_index.generates);
893        assert!(config.full_index.show_link);
894        assert_eq!(config.full_index.thumb_ratio, [4, 4]);
895        assert_eq!(config.full_index.thumb_size, 1000);
896        assert_eq!(config.full_index.thumb_gap, "0.5rem");
897    }
898
899    #[test]
900    fn full_index_partial_preserves_defaults() {
901        let tmp = TempDir::new().unwrap();
902        write_config(
903            tmp.path(),
904            r##"
905[full_index]
906generates = true
907"##,
908        );
909        let config = load_config(tmp.path()).unwrap();
910        assert!(config.full_index.generates);
911        assert!(!config.full_index.show_link);
912        assert_eq!(config.full_index.thumb_ratio, [4, 5]);
913        assert_eq!(config.full_index.thumb_size, 400);
914    }
915
916    #[test]
917    fn parse_partial_theme_mat_x_only() {
918        let tmp = TempDir::new().unwrap();
919        write_config(
920            tmp.path(),
921            r#"
922[theme.mat_x]
923size = "5vw"
924"#,
925        );
926        let config = load_config(tmp.path()).unwrap();
927        // Overridden
928        assert_eq!(config.theme.mat_x.size, "5vw");
929        // Preserved from defaults
930        assert_eq!(config.theme.mat_x.min, "1rem");
931        assert_eq!(config.theme.mat_x.max, "2.5rem");
932        // mat_y entirely untouched
933        assert_eq!(config.theme.mat_y.size, "6vw");
934        assert_eq!(config.theme.mat_y.min, "2rem");
935        assert_eq!(config.theme.mat_y.max, "5rem");
936        // Other theme fields untouched
937        assert_eq!(config.theme.thumbnail_gap, "0.2rem");
938        assert_eq!(config.theme.grid_padding, "2rem");
939    }
940
941    #[test]
942    fn parse_partial_colors_light_keeps_dark_defaults() {
943        let tmp = TempDir::new().unwrap();
944        write_config(
945            tmp.path(),
946            r##"
947[colors.light]
948background = "#fafafa"
949text = "#222222"
950"##,
951        );
952        let config = load_config(tmp.path()).unwrap();
953        assert_eq!(config.colors.light.background, "#fafafa");
954        assert_eq!(config.colors.light.text, "#222222");
955        // Light defaults preserved for unset fields
956        assert_eq!(config.colors.light.text_muted, "#666666");
957        assert_eq!(config.colors.light.border, "#e0e0e0");
958        assert_eq!(config.colors.light.link, "#333333");
959        assert_eq!(config.colors.light.link_hover, "#000000");
960        // Dark entirely untouched
961        assert_eq!(config.colors.dark.background, "#000000");
962        assert_eq!(config.colors.dark.text, "#fafafa");
963    }
964
965    #[test]
966    fn parse_partial_font_weight_only() {
967        let tmp = TempDir::new().unwrap();
968        write_config(
969            tmp.path(),
970            r#"
971[font]
972weight = "300"
973"#,
974        );
975        let config = load_config(tmp.path()).unwrap();
976        assert_eq!(config.font.weight, "300");
977        assert_eq!(config.font.font, "Noto Sans");
978        assert_eq!(config.font.font_type, FontType::Sans);
979    }
980
981    #[test]
982    fn parse_thumbnail_gap_and_grid_padding() {
983        let tmp = TempDir::new().unwrap();
984        write_config(
985            tmp.path(),
986            r#"
987[theme]
988thumbnail_gap = "0.5rem"
989grid_padding = "1rem"
990"#,
991        );
992        let config = load_config(tmp.path()).unwrap();
993        assert_eq!(config.theme.thumbnail_gap, "0.5rem");
994        assert_eq!(config.theme.grid_padding, "1rem");
995    }
996
997    #[test]
998    fn parse_processing_config() {
999        let tmp = TempDir::new().unwrap();
1000        write_config(tmp.path(), "[processing]\nmax_processes = 4\n");
1001        let config = load_config(tmp.path()).unwrap();
1002        assert_eq!(config.processing.max_processes, Some(4));
1003    }
1004
1005    #[test]
1006    fn parse_config_without_processing_uses_default() {
1007        let tmp = TempDir::new().unwrap();
1008        write_config(
1009            tmp.path(),
1010            r##"
1011[colors.light]
1012background = "#fafafa"
1013"##,
1014        );
1015        let config = load_config(tmp.path()).unwrap();
1016        assert_eq!(config.processing.max_processes, None);
1017    }
1018
1019    #[test]
1020    fn parse_custom_assets_dir() {
1021        let tmp = TempDir::new().unwrap();
1022        write_config(tmp.path(), r#"assets_dir = "site-assets""#);
1023        let config = load_config(tmp.path()).unwrap();
1024        assert_eq!(config.assets_dir, "site-assets");
1025    }
1026
1027    #[test]
1028    fn parse_custom_site_description_file() {
1029        let tmp = TempDir::new().unwrap();
1030        write_config(tmp.path(), r#"site_description_file = "intro""#);
1031        let config = load_config(tmp.path()).unwrap();
1032        assert_eq!(config.site_description_file, "intro");
1033    }
1034
1035    #[test]
1036    fn parse_multiple_sections_independently() {
1037        let tmp = TempDir::new().unwrap();
1038        write_config(
1039            tmp.path(),
1040            r##"
1041[colors.dark]
1042background = "#1a1a1a"
1043
1044[font]
1045font = "Lora"
1046font_type = "serif"
1047"##,
1048        );
1049        let config = load_config(tmp.path()).unwrap();
1050        assert_eq!(config.colors.dark.background, "#1a1a1a");
1051        assert_eq!(config.colors.dark.text, "#fafafa");
1052        assert_eq!(config.colors.light.background, "#ffffff");
1053        assert_eq!(config.font.font, "Lora");
1054        assert_eq!(config.font.font_type, FontType::Serif);
1055        assert_eq!(config.font.weight, "600");
1056        assert_eq!(config.images.quality, 90);
1057        assert_eq!(config.thumbnails.aspect_ratio, [4, 5]);
1058        assert_eq!(config.theme.mat_x.size, "3vw");
1059    }
1060
1061    // ----- error rendering -----
1062
1063    #[test]
1064    fn toml_error_carries_path_and_source_text() {
1065        let tmp = TempDir::new().unwrap();
1066        // Unquoted CSS value — the same class of mistake that produced the
1067        // original "expected newline, `#`" error we want rich rendering for.
1068        write_config(tmp.path(), "[theme]\nthumbnail_gap = 0.2rem\n");
1069        let err = load_config(tmp.path()).unwrap_err();
1070        match &err {
1071            ConfigError::Toml {
1072                path,
1073                source_text,
1074                source,
1075            } => {
1076                assert!(path.ends_with("config.toml"));
1077                assert!(source_text.contains("thumbnail_gap"));
1078                assert!(source.span().is_some());
1079            }
1080            other => panic!("expected Toml variant, got {:?}", other),
1081        }
1082    }
1083
1084    #[test]
1085    fn to_clapfig_error_wraps_parse_failure() {
1086        let tmp = TempDir::new().unwrap();
1087        write_config(tmp.path(), "[theme]\nthumbnail_gap = 0.2rem\n");
1088        let err = load_config(tmp.path()).unwrap_err();
1089        let clap_err = err
1090            .to_clapfig_error()
1091            .expect("parse errors should be convertible to ClapfigError");
1092        let (path, parse_err, source_text) = clap_err
1093            .parse_error()
1094            .expect("ClapfigError should be a ParseError");
1095        assert!(path.ends_with("config.toml"));
1096        assert!(parse_err.span().is_some());
1097        assert!(source_text.unwrap().contains("thumbnail_gap"));
1098    }
1099
1100    #[test]
1101    fn to_clapfig_error_is_none_for_validation_failure() {
1102        let err = ConfigError::Validation("quality out of range".into());
1103        assert!(err.to_clapfig_error().is_none());
1104    }
1105
1106    #[test]
1107    fn clapfig_render_plain_includes_path_and_snippet() {
1108        let tmp = TempDir::new().unwrap();
1109        write_config(tmp.path(), "[theme]\nthumbnail_gap = 0.2rem\n");
1110        let err = load_config(tmp.path()).unwrap_err();
1111        let clap_err = err.to_clapfig_error().unwrap();
1112        let out = clapfig::render::render_plain(&clap_err);
1113        assert!(out.contains("config.toml"), "missing path in {out}");
1114        assert!(
1115            out.contains("thumbnail_gap"),
1116            "missing source snippet in {out}"
1117        );
1118        assert!(out.contains('^'), "missing caret in {out}");
1119    }
1120
1121    // ----- load_config behavior -----
1122
1123    #[test]
1124    fn load_config_returns_default_when_no_file() {
1125        let tmp = TempDir::new().unwrap();
1126        let config = load_config(tmp.path()).unwrap();
1127        assert_eq!(config.colors.light.background, "#ffffff");
1128        assert_eq!(config.colors.dark.background, "#000000");
1129    }
1130
1131    #[test]
1132    fn load_config_reads_file() {
1133        let tmp = TempDir::new().unwrap();
1134        write_config(
1135            tmp.path(),
1136            r##"
1137[colors.light]
1138background = "#123456"
1139text = "#abcdef"
1140"##,
1141        );
1142        let config = load_config(tmp.path()).unwrap();
1143        assert_eq!(config.colors.light.background, "#123456");
1144        assert_eq!(config.colors.light.text, "#abcdef");
1145        assert_eq!(config.colors.dark.background, "#000000");
1146    }
1147
1148    #[test]
1149    fn load_config_invalid_toml_is_error() {
1150        let tmp = TempDir::new().unwrap();
1151        write_config(tmp.path(), "this is not valid toml [[[");
1152        let result = load_config(tmp.path());
1153        assert!(matches!(result, Err(ConfigError::Toml { .. })));
1154    }
1155
1156    #[test]
1157    fn load_config_unknown_keys_is_error() {
1158        let tmp = TempDir::new().unwrap();
1159        write_config(tmp.path(), "unknown_key = \"foo\"\n");
1160        let result = load_config(tmp.path());
1161        assert!(result.is_err());
1162    }
1163
1164    #[test]
1165    fn load_config_validates_values() {
1166        let tmp = TempDir::new().unwrap();
1167        write_config(tmp.path(), "[images]\nquality = 200\n");
1168        let result = load_config(tmp.path());
1169        assert!(matches!(result, Err(ConfigError::Validation(_))));
1170    }
1171
1172    // ----- validate() unit checks -----
1173
1174    #[test]
1175    fn validate_quality_boundary_ok() {
1176        let mut config = SiteConfig::default();
1177        config.images.quality = 100;
1178        assert!(config.validate().is_ok());
1179        config.images.quality = 0;
1180        assert!(config.validate().is_ok());
1181    }
1182
1183    #[test]
1184    fn validate_quality_too_high() {
1185        let mut config = SiteConfig::default();
1186        config.images.quality = 101;
1187        let err = config.validate().unwrap_err();
1188        assert!(err.to_string().contains("quality"));
1189    }
1190
1191    #[test]
1192    fn validate_aspect_ratio_zero() {
1193        let mut config = SiteConfig::default();
1194        config.thumbnails.aspect_ratio = [0, 5];
1195        assert!(config.validate().is_err());
1196        config.thumbnails.aspect_ratio = [4, 0];
1197        assert!(config.validate().is_err());
1198    }
1199
1200    #[test]
1201    fn full_index_validation_rejects_zero_ratio() {
1202        let mut config = SiteConfig::default();
1203        config.full_index.thumb_ratio = [0, 1];
1204        assert!(config.validate().is_err());
1205    }
1206
1207    #[test]
1208    fn validate_sizes_empty() {
1209        let mut config = SiteConfig::default();
1210        config.images.sizes = vec![];
1211        assert!(config.validate().is_err());
1212    }
1213
1214    #[test]
1215    fn validate_default_config_passes() {
1216        let config = SiteConfig::default();
1217        assert!(config.validate().is_ok());
1218    }
1219
1220    // ----- effective_threads -----
1221
1222    #[test]
1223    fn effective_threads_auto() {
1224        let config = ProcessingConfig {
1225            max_processes: None,
1226        };
1227        let threads = effective_threads(&config);
1228        let cores = std::thread::available_parallelism()
1229            .map(|n| n.get())
1230            .unwrap_or(1);
1231        assert_eq!(threads, cores);
1232    }
1233
1234    #[test]
1235    fn effective_threads_clamped_to_cores() {
1236        let config = ProcessingConfig {
1237            max_processes: Some(99999),
1238        };
1239        let threads = effective_threads(&config);
1240        let cores = std::thread::available_parallelism()
1241            .map(|n| n.get())
1242            .unwrap_or(1);
1243        assert_eq!(threads, cores);
1244    }
1245
1246    #[test]
1247    fn effective_threads_user_constrains_down() {
1248        let config = ProcessingConfig {
1249            max_processes: Some(1),
1250        };
1251        assert_eq!(effective_threads(&config), 1);
1252    }
1253
1254    // ----- CSS generation -----
1255
1256    #[test]
1257    fn generate_css_uses_config_colors() {
1258        let mut config = SiteConfig::default();
1259        config.colors.light.background = "#f0f0f0".to_string();
1260        config.colors.dark.background = "#1a1a1a".to_string();
1261        let css = generate_color_css(&config.colors);
1262        assert!(css.contains("--color-bg: #f0f0f0"));
1263        assert!(css.contains("--color-bg: #1a1a1a"));
1264    }
1265
1266    #[test]
1267    fn generate_css_includes_all_variables() {
1268        let config = SiteConfig::default();
1269        let css = generate_color_css(&config.colors);
1270        assert!(css.contains("--color-bg:"));
1271        assert!(css.contains("--color-text:"));
1272        assert!(css.contains("--color-text-muted:"));
1273        assert!(css.contains("--color-border:"));
1274        assert!(css.contains("--color-link:"));
1275        assert!(css.contains("--color-link-hover:"));
1276    }
1277
1278    #[test]
1279    fn generate_css_includes_dark_mode_media_query() {
1280        let config = SiteConfig::default();
1281        let css = generate_color_css(&config.colors);
1282        assert!(css.contains("@media (prefers-color-scheme: dark)"));
1283    }
1284
1285    #[test]
1286    fn mat_x_to_css() {
1287        let config = SiteConfig::default();
1288        assert_eq!(config.theme.mat_x.to_css(), "clamp(1rem, 3vw, 2.5rem)");
1289    }
1290
1291    #[test]
1292    fn generate_theme_css_includes_mat_variables() {
1293        let config = SiteConfig::default();
1294        let css = generate_theme_css(&config.theme);
1295        assert!(css.contains("--mat-x: clamp(1rem, 3vw, 2.5rem)"));
1296        assert!(css.contains("--mat-y: clamp(2rem, 6vw, 5rem)"));
1297        assert!(css.contains("--thumbnail-gap: 0.2rem"));
1298        assert!(css.contains("--grid-padding: 2rem"));
1299    }
1300
1301    // ----- font helpers -----
1302
1303    #[test]
1304    fn default_font_is_google() {
1305        let config = SiteConfig::default();
1306        assert!(!config.font.is_local());
1307        assert!(config.font.stylesheet_url().is_some());
1308        assert!(config.font.font_face_css().is_none());
1309    }
1310
1311    #[test]
1312    fn local_font_has_no_stylesheet_url() {
1313        let mut config = SiteConfig::default();
1314        config.font.source = Some("fonts/MyFont.woff2".to_string());
1315        assert!(config.font.is_local());
1316        assert!(config.font.stylesheet_url().is_none());
1317    }
1318
1319    #[test]
1320    fn local_font_generates_font_face_css() {
1321        let mut config = SiteConfig::default();
1322        config.font.font = "My Custom Font".to_string();
1323        config.font.weight = "400".to_string();
1324        config.font.font_type = FontType::Sans;
1325        config.font.source = Some("fonts/MyFont.woff2".to_string());
1326        let css = config.font.font_face_css().unwrap();
1327        assert!(css.contains("@font-face"));
1328        assert!(css.contains(r#"font-family: "My Custom Font""#));
1329        assert!(css.contains(r#"url("/fonts/MyFont.woff2")"#));
1330        assert!(css.contains(r#"format("woff2")"#));
1331        assert!(css.contains("font-weight: 400"));
1332        assert!(css.contains("font-display: swap"));
1333    }
1334
1335    #[test]
1336    fn parse_font_with_source() {
1337        let tmp = TempDir::new().unwrap();
1338        write_config(
1339            tmp.path(),
1340            r#"
1341[font]
1342font = "My Font"
1343weight = "400"
1344source = "fonts/myfont.woff2"
1345"#,
1346        );
1347        let config = load_config(tmp.path()).unwrap();
1348        assert_eq!(config.font.font, "My Font");
1349        assert_eq!(config.font.source.as_deref(), Some("fonts/myfont.woff2"));
1350        assert!(config.font.is_local());
1351    }
1352
1353    #[test]
1354    fn parse_font_source_preserves_other_fields() {
1355        let tmp = TempDir::new().unwrap();
1356        write_config(
1357            tmp.path(),
1358            r#"
1359[font]
1360source = "fonts/custom.woff2"
1361"#,
1362        );
1363        let config = load_config(tmp.path()).unwrap();
1364        assert_eq!(config.font.font, "Noto Sans");
1365        assert_eq!(config.font.weight, "600");
1366        assert_eq!(config.font.source.as_deref(), Some("fonts/custom.woff2"));
1367    }
1368
1369    #[test]
1370    fn font_format_detection() {
1371        assert_eq!(font_format_from_extension("font.woff2"), "woff2");
1372        assert_eq!(font_format_from_extension("font.woff"), "woff");
1373        assert_eq!(font_format_from_extension("font.ttf"), "truetype");
1374        assert_eq!(font_format_from_extension("font.otf"), "opentype");
1375        assert_eq!(font_format_from_extension("font.unknown"), "woff2");
1376    }
1377
1378    #[test]
1379    fn generate_font_css_includes_font_face_for_local() {
1380        let mut config = SiteConfig::default();
1381        config.font.font = "Local Font".to_string();
1382        config.font.weight = "700".to_string();
1383        config.font.font_type = FontType::Serif;
1384        config.font.source = Some("fonts/local.woff2".to_string());
1385        let css = generate_font_css(&config.font);
1386        assert!(css.contains("@font-face"));
1387        assert!(css.contains("--font-family:"));
1388        assert!(css.contains("--font-weight: 700"));
1389    }
1390
1391    #[test]
1392    fn generate_font_css_no_font_face_for_google() {
1393        let config = SiteConfig::default();
1394        let css = generate_font_css(&config.font);
1395        assert!(!css.contains("@font-face"));
1396        assert!(css.contains("--font-family:"));
1397    }
1398
1399    // ----- unknown key rejection -----
1400
1401    #[test]
1402    fn unknown_key_rejected_via_load_config() {
1403        let tmp = TempDir::new().unwrap();
1404        write_config(
1405            tmp.path(),
1406            r#"
1407[images]
1408qualty = 90
1409"#,
1410        );
1411        let result = load_config(tmp.path());
1412        assert!(result.is_err());
1413    }
1414}