Skip to main content

ssg/cmd/
config.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! SSG site configuration and builder.
5
6use super::error::CliError;
7use super::validation::{validate_path_safety, validate_url};
8use super::{default_config, MAX_CONFIG_SIZE};
9use clap::ArgMatches;
10use log::{debug, error, info, warn};
11use serde::{Deserialize, Serialize};
12use std::{
13    collections::BTreeMap,
14    fs,
15    path::{Path, PathBuf},
16    str::FromStr,
17};
18
19/// Image-optimization tunables (issue #521). Surfaces the
20/// `[image]` section of `ssg.toml`:
21///
22/// ```toml
23/// [image]
24/// avif_quality = 70   # 1..=100, default 70 (visually transparent)
25/// lazy_avif    = false  # set true to skip AVIF for non-priority images
26/// ```
27///
28/// Both fields are optional; absent fields fall back to the same
29/// defaults baked into [`crate::plugins_group::image_plugin::ImageOptimizationPlugin`].
30#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
31pub struct ImageConfig {
32    /// AVIF encoding quality (1..=100). Defaults to 70.
33    #[serde(default = "default_avif_quality")]
34    pub avif_quality: u8,
35    /// If true, AVIF encoding is skipped for images without a
36    /// `priority="high"` shortcode marker — see issue #521 AC5.
37    /// Defaults to false (AVIF for every responsive variant).
38    #[serde(default)]
39    pub lazy_avif: bool,
40}
41
42/// The template directory a project gets when it names none.
43///
44/// Kept as a function rather than `PathBuf::default` because an empty
45/// path is not the same thing as "the conventional one", and
46/// `apply_theme` distinguishes them.
47fn default_template_dir() -> PathBuf {
48    PathBuf::from("templates")
49}
50
51const fn default_avif_quality() -> u8 {
52    70
53}
54
55impl Default for ImageConfig {
56    fn default() -> Self {
57        Self {
58            avif_quality: default_avif_quality(),
59            lazy_avif: false,
60        }
61    }
62}
63
64/// Edge-runtime header emitter configuration (issue #550). Surfaces
65/// the `[edge_headers]` section of `ssg.toml`:
66///
67/// ```toml
68/// [edge_headers]
69/// targets = ["cloudflare", "netlify", "vercel"]
70///
71/// [edge_headers.overrides]
72/// permissions-policy = "geolocation=(self)"
73/// ```
74///
75/// When `targets` is empty (the default), the
76/// [`crate::postprocess::EdgeHeadersPlugin`] is a no-op: nothing is
77/// emitted into `dist/`. Listing one or more of `"cloudflare"`,
78/// `"netlify"`, or `"vercel"` opts in to per-platform header config
79/// generation; unknown target strings are logged and ignored.
80///
81/// `overrides` is a header-name → header-value map (case-insensitive
82/// on the platform side, but stored verbatim) that lets a site author
83/// replace any of the five baseline headers without recompiling.
84#[derive(Debug, Clone, Serialize, Deserialize, Default)]
85pub struct EdgeHeadersConfig {
86    /// Edge platforms to emit configuration for. Recognised values:
87    /// `"cloudflare"`, `"netlify"`, `"vercel"`. Anything else is
88    /// logged and skipped. Empty (the default) disables the plugin.
89    #[serde(default)]
90    pub targets: Vec<String>,
91    /// Header-name → header-value overrides applied on top of the
92    /// baseline defaults. Names are matched case-insensitively when
93    /// the emitter merges overrides, so `"permissions-policy"`,
94    /// `"Permissions-Policy"`, and `"PERMISSIONS-POLICY"` all win.
95    #[serde(default)]
96    pub overrides: BTreeMap<String, String>,
97}
98
99impl EdgeHeadersConfig {
100    /// Returns `true` when at least one valid target is configured —
101    /// the registration site in `register_default_plugins` uses this to
102    /// decide whether to register the emitter.
103    ///
104    /// # Examples
105    ///
106    /// ```rust
107    /// use ssg::cmd::EdgeHeadersConfig;
108    ///
109    /// let cfg = EdgeHeadersConfig::default();
110    /// // No targets configured by default.
111    /// assert!(!cfg.is_enabled());
112    /// ```
113    #[must_use]
114    pub const fn is_enabled(&self) -> bool {
115        !self.targets.is_empty()
116    }
117}
118
119/// Digest algorithm used for Subresource Integrity `integrity=`
120/// attributes on externalized assets (v0.0.47 plan §3 item 2.3).
121///
122/// Applies to the SRI attributes emitted by the fingerprint plugin
123/// (`crate::assets::FingerprintPlugin`) and the CSP inline-extraction
124/// plugin (`crate::csp::CspPlugin`). It deliberately does **not**
125/// govern CSP *directive source hashes* (the `'sha256-…'` entries
126/// inside a Content-Security-Policy header/meta value) — those stay
127/// SHA-256 for the broadest UA compatibility.
128///
129/// Serialized in `ssg.toml` as the lowercase strings `"sha256"`,
130/// `"sha384"`, and `"sha512"`; kept in lockstep with the
131/// `security.sri_algorithm` enum in `ssg.schema.json`.
132///
133/// # Examples
134///
135/// ```rust
136/// use ssg::cmd::SriAlgorithm;
137///
138/// // SHA-384 is the default, matching the documented posture.
139/// assert_eq!(SriAlgorithm::default(), SriAlgorithm::Sha384);
140///
141/// // Every emitted integrity value starts with the algorithm prefix.
142/// let sri = SriAlgorithm::Sha512.integrity(b"body{margin:0}");
143/// assert!(sri.starts_with("sha512-"));
144/// ```
145#[derive(
146    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize,
147)]
148#[serde(rename_all = "lowercase")]
149pub enum SriAlgorithm {
150    /// SHA-256 — the pre-v0.0.47 behaviour, kept for back-compat.
151    Sha256,
152    /// SHA-384 — the default; matches the README/SECURITY.md claim.
153    #[default]
154    Sha384,
155    /// SHA-512 — the strongest digest the SRI spec admits.
156    Sha512,
157}
158
159impl SriAlgorithm {
160    /// Returns the SRI prefix token for this algorithm
161    /// (`"sha256"`, `"sha384"`, or `"sha512"`).
162    ///
163    /// # Examples
164    ///
165    /// ```rust
166    /// use ssg::cmd::SriAlgorithm;
167    ///
168    /// assert_eq!(SriAlgorithm::default().prefix(), "sha384");
169    /// assert_eq!(SriAlgorithm::Sha256.prefix(), "sha256");
170    /// ```
171    #[must_use]
172    pub const fn prefix(self) -> &'static str {
173        match self {
174            Self::Sha256 => "sha256",
175            Self::Sha384 => "sha384",
176            Self::Sha512 => "sha512",
177        }
178    }
179
180    /// Computes the full SRI attribute value for `data`:
181    /// `<prefix>-<base64(digest(data))>`.
182    ///
183    /// Browsers compare the `integrity` attribute against
184    /// `base64(digest(body))` per the
185    /// [W3C SRI spec](https://www.w3.org/TR/SRI/#the-integrity-attribute),
186    /// so the returned string is exactly what a UA will validate the
187    /// response body against.
188    ///
189    /// # Examples
190    ///
191    /// ```rust
192    /// use ssg::cmd::SriAlgorithm;
193    ///
194    /// // SHA-384("") — well-known empty-input digest.
195    /// assert_eq!(
196    ///     SriAlgorithm::Sha384.integrity(b""),
197    ///     "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"
198    /// );
199    /// ```
200    #[must_use]
201    pub fn integrity(self, data: &[u8]) -> String {
202        use base64::{engine::general_purpose::STANDARD, Engine as _};
203        use sha2::{Digest as _, Sha256, Sha384, Sha512};
204
205        let b64 = match self {
206            Self::Sha256 => STANDARD.encode(Sha256::digest(data)),
207            Self::Sha384 => STANDARD.encode(Sha384::digest(data)),
208            Self::Sha512 => STANDARD.encode(Sha512::digest(data)),
209        };
210        format!("{}-{}", self.prefix(), b64)
211    }
212}
213
214/// Security tunables (v0.0.47 plan §3 item 2.3). Surfaces the
215/// `[security]` section of `ssg.toml`:
216///
217/// ```toml
218/// [security]
219/// sri_algorithm = "sha384"   # "sha256" | "sha384" | "sha512"
220/// ```
221///
222/// Absent section (the default) means SHA-384 SRI, matching the
223/// documented posture in README/SECURITY.md.
224///
225/// # Examples
226///
227/// ```rust
228/// use ssg::cmd::{SecurityConfig, SriAlgorithm};
229///
230/// // The default posture is SHA-384 SRI.
231/// let cfg = SecurityConfig::default();
232/// assert_eq!(cfg.sri_algorithm, SriAlgorithm::Sha384);
233///
234/// // `[security] sri_algorithm = "sha512"` in ssg.toml deserializes
235/// // into the strongest digest the SRI spec admits.
236/// let cfg: SecurityConfig =
237///     toml::from_str("sri_algorithm = \"sha512\"").unwrap();
238/// assert_eq!(cfg.sri_algorithm, SriAlgorithm::Sha512);
239/// ```
240#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
241pub struct SecurityConfig {
242    /// Digest algorithm for `integrity=` attributes on externalized
243    /// assets. Defaults to [`SriAlgorithm::Sha384`].
244    #[serde(default)]
245    pub sri_algorithm: SriAlgorithm,
246}
247
248/// Core configuration for the static site generator.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct SsgConfig {
251    /// Name of the site.
252    pub site_name: String,
253    /// Directory containing content files.
254    pub content_dir: PathBuf,
255    /// Directory for generated output files.
256    pub output_dir: PathBuf,
257    /// Directory containing template files.
258    ///
259    /// Defaults to `templates`, so a config that names a `theme`
260    /// instead does not have to repeat a path it is about to have
261    /// resolved for it. `apply_theme` treats this default as "unset".
262    #[serde(default = "default_template_dir")]
263    pub template_dir: PathBuf,
264    /// Name of a theme to take templates and assets from.
265    ///
266    /// A theme is a directory holding a layout set — `_layouts/` for
267    /// the published SSG themes, `templates/` for a project-shaped one.
268    /// Setting this resolves `template_dir` for you, so a project using
269    /// a theme does not have to hand-write a path into someone else's
270    /// tree. An explicit `template_dir` still wins: naming both is how
271    /// you override one layout without forking the theme.
272    ///
273    /// Resolution is by [`crate::theme::resolve`], which searches the
274    /// config file's own directory first and reports every path it
275    /// tried when a name does not resolve.
276    #[serde(default)]
277    pub theme: Option<String>,
278    /// Optional directory for development server files.
279    pub serve_dir: Option<PathBuf>,
280    /// Base URL of the site.
281    pub base_url: String,
282    /// Title of the site.
283    pub site_title: String,
284    /// Description of the site.
285    pub site_description: String,
286    /// Language code for the site.
287    pub language: String,
288    /// Optional i18n configuration for multi-locale sites.
289    ///
290    /// Present only with the `i18n` feature (on by default): the type
291    /// comes from the `ssg-i18n` crate, which that feature pulls in.
292    #[cfg(feature = "i18n")]
293    #[serde(default)]
294    pub i18n: Option<crate::i18n::I18nConfig>,
295    /// Named, filtered, paginated listings (#587).
296    ///
297    /// Absent means none, which is the behaviour every site had before:
298    /// `/page/N/` over every dated page and nothing else.
299    #[serde(default)]
300    pub listings: Vec<crate::plugins_group::listings::ListingConfig>,
301    /// Optional CDN prefix for markdown images.
302    #[serde(default)]
303    pub cdn_prefix: Option<String>,
304    /// Optional site-wide fallback `og:image` (a URL or site-relative
305    /// path). Used by generated pages that have no per-page image of
306    /// their own — currently the taxonomy/tag pages emitted by
307    /// [`crate::taxonomy::TaxonomyPlugin`], which bypass the
308    /// `SeoPlugin` transform chain (#586) and so never see the
309    /// front-matter-derived `og:image` that regular content pages get.
310    /// Absent ⇒ no `og:image` tag on those pages.
311    #[serde(default)]
312    pub og_image: Option<String>,
313    /// Optional image-pipeline tunables (issue #521).
314    #[serde(default)]
315    pub image: ImageConfig,
316    /// Edge-runtime header emitter config (issue #550). Absent /
317    /// empty `targets` disables the emitter.
318    #[serde(default)]
319    pub edge_headers: EdgeHeadersConfig,
320    /// Agentic-discovery emitters: `agents.txt`, `ai-plugin.json`, and
321    /// the MCP registry (issue #552). All three are opt-in per the
322    /// `[agents]` section of `ssg.toml`. Absent ⇒ no files written.
323    #[serde(default)]
324    pub agents: Option<
325        crate::plugins_group::postprocess::agentic_discovery::AgentsConfig,
326    >,
327    /// Opt-in View Transitions + lazy-nav client (issue #547).
328    ///
329    /// When `true`, the build emits `_transitions/ssg-transitions.js`
330    /// and injects a small `<script>` + `<style>` block into every
331    /// page so same-origin navigations animate via the View
332    /// Transitions API (Chromium/Safari) or fall back to a plain
333    /// reload in non-supporting browsers (Firefox stable as of
334    /// 2026-06). Persistent `<header>` / `<footer>` roots get
335    /// `view-transition-name` so they don't animate across boundaries.
336    /// Defaults to `false` to keep zero-JS sites zero-JS.
337    #[serde(default)]
338    pub transitions: bool,
339    /// Skip generating taxonomy (tag / category / topic) pages.
340    ///
341    /// Defaults to `false`, so a build that does not ask for this is
342    /// unchanged. Sites that curate their own taxonomy — a canonical
343    /// vocabulary, a minimum-article threshold, hand-translated slugs —
344    /// need to own `/tags/` outright: emitting a page per raw
345    /// front-matter term contradicts that curation, and on a
346    /// multi-locale corpus it multiplies the URL surface with thin
347    /// pages. Opting out is cheaper and more honest than deleting the
348    /// output afterwards.
349    #[serde(default)]
350    pub no_taxonomy_pages: bool,
351    /// Security tunables (v0.0.47 plan §3 item 2.3): the `[security]`
352    /// section of `ssg.toml`. Currently holds the SRI digest
353    /// algorithm; absent ⇒ SHA-384.
354    #[serde(default)]
355    pub security: SecurityConfig,
356}
357
358impl Default for SsgConfig {
359    fn default() -> Self {
360        default_config().as_ref().clone()
361    }
362}
363
364impl SsgConfig {
365    /// The configured locales, or empty when multi-locale support is
366    /// unavailable or unconfigured.
367    ///
368    /// The `i18n` feature is the only thing in the crate that knows
369    /// where these come from. Callers ask this instead of reaching for
370    /// the field, so disabling the feature does not scatter `cfg`
371    /// branches through every plugin that cares about locales.
372    // With `i18n` off this body is a bare `None`, which clippy would
373    // have as a `const fn` — but the feature-on body clones from
374    // `self`, so it can never be const. Scoped to the configuration
375    // that actually triggers it rather than allowed outright.
376    #[cfg_attr(not(feature = "i18n"), allow(clippy::missing_const_for_fn))]
377    #[must_use]
378    pub fn i18n_locales(&self) -> Vec<String> {
379        #[cfg(feature = "i18n")]
380        let locales = self
381            .i18n
382            .as_ref()
383            .map(|i| i.locales.clone())
384            .unwrap_or_default();
385        #[cfg(not(feature = "i18n"))]
386        let locales = Vec::new();
387        locales
388    }
389
390    /// Every declared locale including the default, or `None` when no
391    /// i18n configuration is reachable at all.
392    ///
393    /// The `None` case is load-bearing and distinct from an empty list:
394    /// callers use it to choose between strict matching against a
395    /// declared set and a heuristic. Collapsing the two would make an
396    /// undeclared `de/` directory look like a locale page.
397    // With `i18n` off this body is a bare `None`, which clippy would
398    // have as a `const fn` — but the feature-on body clones from
399    // `self`, so it can never be const. Scoped to the configuration
400    // that actually triggers it rather than allowed outright.
401    #[cfg_attr(not(feature = "i18n"), allow(clippy::missing_const_for_fn))]
402    #[must_use]
403    pub fn i18n_locale_set(&self) -> Option<Vec<String>> {
404        #[cfg(feature = "i18n")]
405        let declared = self.i18n.as_ref().map(|i| {
406            i.locales
407                .iter()
408                .chain(std::iter::once(&i.default_locale))
409                .cloned()
410                .collect()
411        });
412        #[cfg(not(feature = "i18n"))]
413        let declared = None;
414        declared
415    }
416
417    /// The configured default locale, or `None` when multi-locale
418    /// support is unavailable, unconfigured, or set to an empty string.
419    // With `i18n` off this body is a bare `None`, which clippy would
420    // have as a `const fn` — but the feature-on body clones from
421    // `self`, so it can never be const. Scoped to the configuration
422    // that actually triggers it rather than allowed outright.
423    #[cfg_attr(not(feature = "i18n"), allow(clippy::missing_const_for_fn))]
424    #[must_use]
425    pub fn i18n_default_locale(&self) -> Option<String> {
426        #[cfg(feature = "i18n")]
427        let default = self
428            .i18n
429            .as_ref()
430            .map(|i| i.default_locale.clone())
431            .filter(|l| !l.is_empty());
432        #[cfg(not(feature = "i18n"))]
433        let default = None;
434        default
435    }
436
437    /// Applies command-line arguments to override defaults.
438    fn override_with_cli(
439        mut self,
440        matches: &ArgMatches,
441    ) -> Result<Self, CliError> {
442        // If `-n/--new` was used
443        if let Some(site_name) = matches.get_one::<String>("new") {
444            self.site_name.clone_from(site_name);
445        }
446
447        // If `-c/--content` was used
448        if let Some(content_dir) = matches.get_one::<PathBuf>("content") {
449            self.content_dir.clone_from(content_dir);
450        }
451
452        // If `-o/--output` was used
453        if let Some(output_dir) = matches.get_one::<PathBuf>("output") {
454            self.output_dir.clone_from(output_dir);
455        }
456
457        // If `--theme` was used. Resolved against the working directory,
458        // because a flag is typed where the build is run, unlike a
459        // `theme =` key, which belongs to its config file.
460        //
461        // `template_dir` is reset to the default first. Without that, a
462        // config that already names a theme has had `template_dir`
463        // resolved for it, and `apply_theme` — which will not overwrite
464        // a directory somebody chose — cannot tell that apart from a
465        // deliberate choice and silently ignores the flag. Passing
466        // `--theme stablo` over `theme = "quill"` produced a
467        // byte-identical quill site.
468        if let Some(theme) = matches.get_one::<String>("theme") {
469            self.theme = Some(theme.clone());
470            self.template_dir = default_template_dir();
471            let cwd = std::env::current_dir().unwrap_or_default();
472            self.apply_theme(&cwd)?;
473        }
474
475        // If `-t/--template` was used. After the theme, so naming both
476        // on one command line overrides the theme's layouts.
477        if let Some(template_dir) = matches.get_one::<PathBuf>("template") {
478            self.template_dir.clone_from(template_dir);
479        }
480
481        // If `-s/--serve` was used
482        if let Some(serve_dir) = matches.get_one::<PathBuf>("serve") {
483            self.serve_dir = Some(serve_dir.clone());
484        }
485
486        // `--no-tag-pages` / SSG_NO_TAG_PAGES. Only ever turns generation
487        // off — absent means the default, so no existing build changes
488        // behaviour merely by upgrading.
489        if matches.get_flag("no_tag_pages") {
490            self.no_taxonomy_pages = true;
491        }
492
493        // `--watch` flag is handled by the caller (run() in lib.rs)
494
495        // Re-validate after overriding
496        self.validate()?;
497        Ok(self)
498    }
499    /// Creates a configuration by merging the default values with any command-line arguments.
500    ///
501    /// # Arguments
502    /// * `matches` - Parsed command-line arguments from Clap.
503    ///
504    /// # Errors
505    /// Returns a [`CliError`] if:
506    /// - A path fails validation (e.g., directory traversal or symlink).
507    /// - A URL is malformed.
508    /// - The language is incorrectly formatted.
509    ///
510    /// # Examples
511    /// ```rust,ignore
512    /// let matches = cli.build().get_matches();
513    /// let config = SsgConfig::from_matches(&matches)?;
514    /// ```
515    pub fn from_matches(matches: &ArgMatches) -> Result<Self, CliError> {
516        if let Some(config_path) = matches.get_one::<PathBuf>("config") {
517            let loaded_config = Self::from_file(config_path)?;
518            info!("config: loaded {}", config_path.display());
519            return Ok(loaded_config);
520        }
521
522        // No `--config`: look for one where a project keeps it (issue #730).
523        // Previously this fell straight through to the defaults, so a project
524        // with an `ssg.toml` sitting next to it was compiled against
525        // `http://localhost:8000` — every canonical URL, sitemap entry and
526        // JSON-LD `@id` in the published site pointed at the developer's
527        // machine, and the build reported success.
528        if let Some(discovered) = Self::discover_config_file() {
529            let loaded_config = Self::from_file(&discovered)?;
530            info!("config: loaded {}", discovered.display());
531            return loaded_config.override_with_cli(matches);
532        }
533
534        // Nothing found. Warn rather than inform: the defaults are only ever
535        // right for a scratch build, and the canonical host is the part that
536        // silently ruins a deploy.
537        warn!(
538            "config: no config file found; using defaults (canonical host = {})",
539            Self::default().base_url
540        );
541
542        // 1) Start with defaults
543        let config = Self::default();
544
545        // 2) Override them with CLI flags
546        let config = config.override_with_cli(matches)?;
547
548        // 3) Return the result
549        Ok(config)
550    }
551
552    /// Finds a configuration file when `--config` was not given.
553    ///
554    /// Search order, first match wins:
555    ///
556    /// 1. `./ssg.toml`     — the name the documentation and every example use
557    /// 2. `./config.toml`  — the name issue #730 was reported against
558    /// 3. `$SSG_CONFIG`    — an explicit path, for CI and wrapper scripts
559    ///
560    /// The environment variable is checked last so a file in the project
561    /// cannot be silently overridden by a stale variable in the shell.
562    #[must_use]
563    pub fn discover_config_file() -> Option<PathBuf> {
564        Self::discover_config_file_in(Path::new("."))
565    }
566
567    /// [`Self::discover_config_file`] rooted at an explicit directory.
568    ///
569    /// Taking the directory as a parameter keeps this testable without
570    /// `set_current_dir`, which is process-wide: changing it from a test
571    /// leaks into every other test resolving a relative path, in parallel.
572    #[must_use]
573    pub fn discover_config_file_in(dir: &Path) -> Option<PathBuf> {
574        for name in ["ssg.toml", "config.toml"] {
575            let candidate = dir.join(name);
576            if candidate.is_file() {
577                return Some(candidate);
578            }
579        }
580
581        std::env::var_os("SSG_CONFIG")
582            .map(PathBuf::from)
583            .filter(|p| p.is_file())
584    }
585
586    /// Subcommand variant: subcommand parsers re-use the same
587    /// `--config / --content / --output / --template / --serve` flag
588    /// names but omit the legacy `--new` (project scaffolding is its
589    /// own command). The override logic is identical otherwise, so we
590    /// just delegate through a thin shim that skips the missing
591    /// `--new` lookup.
592    ///
593    /// # Errors
594    /// Returns [`CliError`] under the same conditions as
595    /// [`Self::from_matches`].
596    pub fn from_subcommand_matches(
597        sub_m: &ArgMatches,
598    ) -> Result<Self, CliError> {
599        if let Some(config_path) = sub_m.get_one::<PathBuf>("config") {
600            info!("config: loaded {}", config_path.display());
601            return Self::from_file(config_path);
602        }
603
604        // `ssg build` is a subcommand, so this — not `from_matches` — is the
605        // path a normal invocation takes. Discovery has to live here too, or
606        // #730 is only fixed for the bare `ssg` form nobody uses.
607        if let Some(discovered) = Self::discover_config_file() {
608            let loaded = Self::from_file(&discovered)?;
609            info!("config: loaded {}", discovered.display());
610            return loaded.override_with_subcommand(sub_m);
611        }
612
613        warn!(
614            "config: no config file found; using defaults (canonical host = {})",
615            Self::default().base_url
616        );
617
618        Self::default().override_with_subcommand(sub_m)
619    }
620
621    /// Applies a subcommand's path flags on top of this configuration.
622    ///
623    /// Extracted so the discovered-config path and the defaults path apply
624    /// flags identically: a flag has to win over a discovered file, or
625    /// `--output` would be silently ignored the moment a project gained an
626    /// `ssg.toml`.
627    fn override_with_subcommand(
628        mut self,
629        sub_m: &ArgMatches,
630    ) -> Result<Self, CliError> {
631        if let Some(content_dir) = sub_m.get_one::<PathBuf>("content") {
632            self.content_dir.clone_from(content_dir);
633        }
634        if let Some(output_dir) = sub_m.get_one::<PathBuf>("output") {
635            self.output_dir.clone_from(output_dir);
636        }
637        // `--theme` before `--template`, and resetting `template_dir`
638        // first, for the reasons given in `override_with_cli`. This is
639        // the path `ssg build` actually takes, so a flag handled only
640        // there is a flag that does nothing in practice: `--theme` was
641        // wired into `override_with_cli` alone at first, and
642        // `ssg build --theme stablo` over `theme = "quill"` produced a
643        // byte-identical quill site without a word of complaint.
644        if sub_m.try_contains_id("theme").unwrap_or(false) {
645            if let Some(theme) = sub_m.get_one::<String>("theme") {
646                self.theme = Some(theme.clone());
647                self.template_dir = default_template_dir();
648                let cwd = std::env::current_dir().unwrap_or_default();
649                self.apply_theme(&cwd)?;
650            }
651        }
652        if let Some(template_dir) = sub_m.get_one::<PathBuf>("template") {
653            self.template_dir.clone_from(template_dir);
654        }
655        // `dev` exposes `--serve`; `build` / `check` / `deploy` do not.
656        if sub_m.try_contains_id("serve").unwrap_or(false) {
657            if let Some(serve_dir) = sub_m.get_one::<PathBuf>("serve") {
658                self.serve_dir = Some(serve_dir.clone());
659            }
660        }
661        self.validate()?;
662        Ok(self)
663    }
664    /// Loads configuration from a TOML or JSON file, enforcing a maximum
665    /// file size limit.
666    ///
667    /// The format is chosen by file extension: `.json` is parsed as JSON,
668    /// anything else as TOML. Parsing every file as TOML meant a `.json`
669    /// config — the form `ssg.schema.json` describes, and the form
670    /// `--config config/ssg.json` invites — failed on its opening brace
671    /// with "invalid key-value pair, expected key", which reads as a
672    /// malformed file rather than an unsupported format.
673    ///
674    /// # Arguments
675    /// * `path` - The path of the config file to be read.
676    ///
677    /// # Errors
678    /// Returns a [`CliError`] if:
679    /// - The file cannot be read or exceeds `MAX_CONFIG_SIZE`.
680    /// - The file is malformed for its format.
681    /// - Any fields fail validation afterward.
682    ///
683    /// # Examples
684    /// ```rust,ignore
685    /// let config = SsgConfig::from_file(Path::new("config.toml"))?;
686    /// let config = SsgConfig::from_file(Path::new("config/ssg.json"))?;
687    /// ```
688    pub fn from_file(path: &Path) -> Result<Self, CliError> {
689        let metadata = fs::metadata(path)?;
690        if metadata.len() > MAX_CONFIG_SIZE as u64 {
691            return Err(CliError::ValidationError(format!(
692                "Config file too large (max {MAX_CONFIG_SIZE} bytes)"
693            )));
694        }
695
696        let content = fs::read_to_string(path)?;
697        let is_json = path
698            .extension()
699            .is_some_and(|e| e.eq_ignore_ascii_case("json"));
700
701        let mut config: Self = if is_json {
702            serde_json::from_str(&content).map_err(|e| {
703                CliError::ValidationError(format!(
704                    "JSON parsing error in {}: {e}",
705                    path.display()
706                ))
707            })?
708        } else {
709            toml::from_str(&content)?
710        };
711        config.apply_theme(path.parent().unwrap_or_else(|| Path::new(".")))?;
712        config.validate()?;
713        Ok(config)
714    }
715
716    /// Resolves `theme` into `template_dir`, relative to `base`.
717    ///
718    /// A no-op when no theme is named. An explicitly configured
719    /// `template_dir` wins: naming both is how a project overrides one
720    /// layout without forking the theme.
721    ///
722    /// `base` is the directory of the config file, so a `themes/`
723    /// beside `ssg.toml` resolves whatever the working directory
724    /// happens to be — a build must not depend on where it was invoked
725    /// from.
726    ///
727    /// # Errors
728    ///
729    /// [`CliError::ValidationError`] when the name does not resolve,
730    /// carrying every path searched and the names that do exist.
731    pub fn apply_theme(&mut self, base: &Path) -> Result<(), CliError> {
732        let Some(name) = self.theme.clone() else {
733            return Ok(());
734        };
735        let theme = crate::theme::resolve(&name, base)
736            .map_err(|e| CliError::ValidationError(e.to_string()))?;
737        // Only the default placeholder is replaced; anything the user
738        // actually chose is left alone.
739        if self.template_dir == Path::new("templates")
740            || self.template_dir.as_os_str().is_empty()
741        {
742            self.template_dir = theme.template_dir;
743        }
744        Ok(())
745    }
746
747    /// Validates the configuration's URLs and paths.
748    ///
749    /// # Examples
750    ///
751    /// ```rust
752    /// use ssg::cmd::SsgConfig;
753    ///
754    /// let cfg = SsgConfig::default();
755    /// assert!(cfg.validate().is_ok());
756    /// ```
757    ///
758    /// # Errors
759    ///
760    /// Returns [`CliError::ValidationError`] when `site_name` is empty,
761    /// or path/URL safety checks fail.
762    pub fn validate(&self) -> Result<(), CliError> {
763        debug!("Validating config: {self:?}");
764
765        if self.site_name.trim().is_empty() {
766            error!("site_name cannot be empty");
767            return Err(CliError::ValidationError(
768                "site_name cannot be empty".into(),
769            ));
770        }
771
772        if !self.base_url.is_empty() {
773            validate_url(&self.base_url)?;
774        }
775
776        validate_path_safety(&self.content_dir, "content_dir")?;
777        validate_path_safety(&self.output_dir, "output_dir")?;
778        validate_path_safety(&self.template_dir, "template_dir")?;
779        if let Some(ref serve_dir) = self.serve_dir {
780            validate_path_safety(serve_dir, "serve_dir")?;
781        }
782
783        info!("Config validation successful");
784        Ok(())
785    }
786
787    /// Returns a fresh [`SsgConfigBuilder`] for fluent construction.
788    ///
789    /// # Examples
790    ///
791    /// ```rust
792    /// use ssg::cmd::SsgConfig;
793    ///
794    /// let cfg = SsgConfig::builder()
795    ///     .site_name("My Site".into())
796    ///     .build()
797    ///     .expect("valid config");
798    /// assert_eq!(cfg.site_name, "My Site");
799    /// ```
800    #[must_use]
801    pub fn builder() -> SsgConfigBuilder {
802        SsgConfigBuilder::default()
803    }
804}
805
806impl FromStr for SsgConfig {
807    type Err = CliError;
808
809    fn from_str(s: &str) -> Result<Self, Self::Err> {
810        let config: Self = toml::from_str(s)?;
811        config.validate()?;
812        Ok(config)
813    }
814}
815
816/// Builder for `SsgConfig`.
817#[derive(Debug, Clone, Default)]
818pub struct SsgConfigBuilder {
819    config: SsgConfig,
820}
821
822/// # Examples
823/// ```
824/// use ssg::cmd::SsgConfig;
825/// let config = SsgConfig::builder()
826///     .site_name("My Site".to_string())
827///     .base_url("http://example.com".to_string())
828///     .build()
829///     .unwrap();
830/// ```
831impl SsgConfigBuilder {
832    /// Sets the site name for the configuration.
833    ///
834    /// # Examples
835    ///
836    /// ```rust
837    /// use ssg::cmd::SsgConfig;
838    ///
839    /// let cfg = SsgConfig::builder().site_name("Hello".into()).build().unwrap();
840    /// assert_eq!(cfg.site_name, "Hello");
841    /// ```
842    #[must_use]
843    pub fn site_name(mut self, name: String) -> Self {
844        self.config.site_name = name;
845        self
846    }
847    /// Sets the base URL for the configuration.
848    ///
849    /// # Examples
850    ///
851    /// ```rust
852    /// use ssg::cmd::SsgConfig;
853    ///
854    /// let cfg = SsgConfig::builder()
855    ///     .base_url("https://example.com".into())
856    ///     .build()
857    ///     .unwrap();
858    /// assert_eq!(cfg.base_url, "https://example.com");
859    /// ```
860    #[must_use]
861    pub fn base_url(mut self, url: String) -> Self {
862        self.config.base_url = url;
863        self
864    }
865    /// Sets the content directory for the configuration.
866    ///
867    /// # Examples
868    ///
869    /// ```rust
870    /// use ssg::cmd::SsgConfig;
871    /// use std::path::PathBuf;
872    ///
873    /// let cfg = SsgConfig::builder()
874    ///     .content_dir(PathBuf::from("docs"))
875    ///     .build()
876    ///     .unwrap();
877    /// assert_eq!(cfg.content_dir, PathBuf::from("docs"));
878    /// ```
879    #[must_use]
880    pub fn content_dir(mut self, dir: PathBuf) -> Self {
881        self.config.content_dir = dir;
882        self
883    }
884    /// Sets the output directory for the configuration.
885    ///
886    /// # Examples
887    ///
888    /// ```rust
889    /// use ssg::cmd::SsgConfig;
890    /// use std::path::PathBuf;
891    ///
892    /// let cfg = SsgConfig::builder()
893    ///     .output_dir(PathBuf::from("dist"))
894    ///     .build()
895    ///     .unwrap();
896    /// assert_eq!(cfg.output_dir, PathBuf::from("dist"));
897    /// ```
898    #[must_use]
899    pub fn output_dir(mut self, dir: PathBuf) -> Self {
900        self.config.output_dir = dir;
901        self
902    }
903    /// Sets the template directory for the configuration.
904    ///
905    /// # Examples
906    ///
907    /// ```rust
908    /// use ssg::cmd::SsgConfig;
909    /// use std::path::PathBuf;
910    ///
911    /// let cfg = SsgConfig::builder()
912    ///     .template_dir(PathBuf::from("tpl"))
913    ///     .build()
914    ///     .unwrap();
915    /// assert_eq!(cfg.template_dir, PathBuf::from("tpl"));
916    /// ```
917    #[must_use]
918    pub fn template_dir(mut self, dir: PathBuf) -> Self {
919        self.config.template_dir = dir;
920        self
921    }
922    /// Sets the optional development server directory for the configuration.
923    ///
924    /// # Examples
925    ///
926    /// ```rust
927    /// use ssg::cmd::SsgConfig;
928    /// use std::path::PathBuf;
929    ///
930    /// let cfg = SsgConfig::builder()
931    ///     .serve_dir(Some(PathBuf::from("public")))
932    ///     .build()
933    ///     .unwrap();
934    /// assert_eq!(cfg.serve_dir, Some(PathBuf::from("public")));
935    /// ```
936    #[must_use]
937    pub fn serve_dir(mut self, dir: Option<PathBuf>) -> Self {
938        self.config.serve_dir = dir;
939        self
940    }
941    /// Sets the site title for the configuration.
942    ///
943    /// # Examples
944    ///
945    /// ```rust
946    /// use ssg::cmd::SsgConfig;
947    ///
948    /// let cfg = SsgConfig::builder().site_title("Title".into()).build().unwrap();
949    /// assert_eq!(cfg.site_title, "Title");
950    /// ```
951    #[must_use]
952    pub fn site_title(mut self, title: String) -> Self {
953        self.config.site_title = title;
954        self
955    }
956    /// Sets the site description for the configuration.
957    ///
958    /// # Examples
959    ///
960    /// ```rust
961    /// use ssg::cmd::SsgConfig;
962    ///
963    /// let cfg = SsgConfig::builder().site_description("Demo".into()).build().unwrap();
964    /// assert_eq!(cfg.site_description, "Demo");
965    /// ```
966    #[must_use]
967    pub fn site_description(mut self, desc: String) -> Self {
968        self.config.site_description = desc;
969        self
970    }
971    /// Sets the language code for the configuration.
972    ///
973    /// # Examples
974    ///
975    /// ```rust
976    /// use ssg::cmd::SsgConfig;
977    ///
978    /// let cfg = SsgConfig::builder().language("fr-FR".into()).build().unwrap();
979    /// assert_eq!(cfg.language, "fr-FR");
980    /// ```
981    #[must_use]
982    pub fn language(mut self, lang: String) -> Self {
983        self.config.language = lang;
984        self
985    }
986    /// Sets the i18n configuration.
987    ///
988    /// # Examples
989    ///
990    /// ```rust
991    /// use ssg::cmd::SsgConfig;
992    ///
993    /// let cfg = SsgConfig::builder().i18n(None).build().unwrap();
994    /// assert!(cfg.i18n.is_none());
995    /// ```
996    #[cfg(feature = "i18n")]
997    #[must_use]
998    pub fn i18n(mut self, i18n: Option<crate::i18n::I18nConfig>) -> Self {
999        self.config.i18n = i18n;
1000        self
1001    }
1002    /// Sets the CDN prefix configuration.
1003    ///
1004    /// # Examples
1005    ///
1006    /// ```rust
1007    /// use ssg::cmd::SsgConfig;
1008    ///
1009    /// let cfg = SsgConfig::builder()
1010    ///     .cdn_prefix(Some("https://cdn.example.com".into()))
1011    ///     .build()
1012    ///     .unwrap();
1013    /// assert!(cfg.cdn_prefix.is_some());
1014    /// ```
1015    #[must_use]
1016    pub fn cdn_prefix(mut self, prefix: Option<String>) -> Self {
1017        self.config.cdn_prefix = prefix;
1018        self
1019    }
1020    /// Sets the site-wide fallback `og:image` used by generated
1021    /// taxonomy/tag pages that have no per-page image of their own.
1022    ///
1023    /// # Examples
1024    ///
1025    /// ```rust
1026    /// use ssg::cmd::SsgConfig;
1027    ///
1028    /// let cfg = SsgConfig::builder()
1029    ///     .og_image(Some("/social/default.png".into()))
1030    ///     .build()
1031    ///     .unwrap();
1032    /// assert_eq!(cfg.og_image.as_deref(), Some("/social/default.png"));
1033    /// ```
1034    #[must_use]
1035    pub fn og_image(mut self, og_image: Option<String>) -> Self {
1036        self.config.og_image = og_image;
1037        self
1038    }
1039    /// Sets the edge-headers emitter configuration (issue #550).
1040    ///
1041    /// # Examples
1042    ///
1043    /// ```rust
1044    /// use ssg::cmd::{EdgeHeadersConfig, SsgConfig};
1045    ///
1046    /// let cfg = SsgConfig::builder()
1047    ///     .edge_headers(EdgeHeadersConfig::default())
1048    ///     .build()
1049    ///     .unwrap();
1050    /// assert!(!cfg.edge_headers.is_enabled());
1051    /// ```
1052    #[must_use]
1053    pub fn edge_headers(mut self, edge: EdgeHeadersConfig) -> Self {
1054        self.config.edge_headers = edge;
1055        self
1056    }
1057    /// Sets the security tunables (v0.0.47 plan §3 item 2.3).
1058    ///
1059    /// # Examples
1060    ///
1061    /// ```rust
1062    /// use ssg::cmd::{SecurityConfig, SriAlgorithm, SsgConfig};
1063    ///
1064    /// let cfg = SsgConfig::builder()
1065    ///     .security(SecurityConfig {
1066    ///         sri_algorithm: SriAlgorithm::Sha512,
1067    ///     })
1068    ///     .build()
1069    ///     .unwrap();
1070    /// assert_eq!(cfg.security.sri_algorithm, SriAlgorithm::Sha512);
1071    /// ```
1072    #[must_use]
1073    pub const fn security(mut self, security: SecurityConfig) -> Self {
1074        self.config.security = security;
1075        self
1076    }
1077    /// Enables the View Transitions + lazy-nav client (issue #547).
1078    ///
1079    /// # Examples
1080    ///
1081    /// ```rust
1082    /// use ssg::cmd::SsgConfig;
1083    ///
1084    /// let cfg = SsgConfig::builder().transitions(true).build().unwrap();
1085    /// assert!(cfg.transitions);
1086    /// ```
1087    #[must_use]
1088    pub const fn transitions(mut self, enabled: bool) -> Self {
1089        self.config.transitions = enabled;
1090        self
1091    }
1092    /// Builds the final `SsgConfig` instance.
1093    ///
1094    /// # Examples
1095    ///
1096    /// ```rust
1097    /// use ssg::cmd::SsgConfig;
1098    ///
1099    /// let cfg = SsgConfig::builder().build().expect("default is valid");
1100    /// assert!(!cfg.site_name.is_empty());
1101    /// ```
1102    ///
1103    /// # Errors
1104    ///
1105    /// Returns [`CliError::ValidationError`] when [`SsgConfig::validate`] fails.
1106    pub fn build(self) -> Result<SsgConfig, CliError> {
1107        self.config.validate()?;
1108        Ok(self.config)
1109    }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115    use crate::cmd::Cli;
1116    use std::fs::File;
1117    use std::io::Write;
1118    use tempfile::tempdir;
1119
1120    /// Region-free variant of `assert!(matches!(err, <Variant>))` —
1121    /// `matches!` would leave its never-taken false arm uncovered.
1122    fn assert_err_variant<T: std::fmt::Debug>(
1123        result: Result<T, CliError>,
1124        variant: &str,
1125    ) {
1126        let err = result.expect_err("expected an error");
1127        let repr = format!("{err:?}");
1128        assert!(repr.starts_with(variant), "expected {variant}, got {repr}");
1129    }
1130
1131    #[test]
1132    fn discovery_finds_ssg_toml_in_the_working_directory() {
1133        // Issue #730: without `--config` the loader went straight to the
1134        // defaults, so a project sitting next to its own `ssg.toml` was built
1135        // against `http://localhost:8000` and every canonical URL in the
1136        // published site pointed at the developer's machine.
1137        let dir = tempdir().unwrap();
1138        fs::write(dir.path().join("ssg.toml"), "site_name = \"x\"\n").unwrap();
1139
1140        let found = SsgConfig::discover_config_file_in(dir.path());
1141        assert_eq!(found, Some(dir.path().join("ssg.toml")));
1142    }
1143
1144    #[test]
1145    fn discovery_falls_back_to_config_toml() {
1146        let dir = tempdir().unwrap();
1147        fs::write(dir.path().join("config.toml"), "site_name = \"x\"\n")
1148            .unwrap();
1149
1150        let found = SsgConfig::discover_config_file_in(dir.path());
1151        assert_eq!(found, Some(dir.path().join("config.toml")));
1152    }
1153
1154    #[test]
1155    fn discovery_prefers_ssg_toml_over_config_toml() {
1156        let dir = tempdir().unwrap();
1157        fs::write(dir.path().join("ssg.toml"), "site_name = \"a\"\n").unwrap();
1158        fs::write(dir.path().join("config.toml"), "site_name = \"b\"\n")
1159            .unwrap();
1160
1161        let found = SsgConfig::discover_config_file_in(dir.path());
1162        assert_eq!(found, Some(dir.path().join("ssg.toml")));
1163    }
1164
1165    #[test]
1166    fn discovery_returns_none_in_an_empty_directory() {
1167        let dir = tempdir().unwrap();
1168        // With no file present the only remaining source is $SSG_CONFIG, and
1169        // a stale variable must not resurrect a file that is not there.
1170        let found = SsgConfig::discover_config_file_in(dir.path());
1171        assert!(
1172            found.is_none() || std::env::var_os("SSG_CONFIG").is_some(),
1173            "discovered {found:?} in an empty directory"
1174        );
1175    }
1176
1177    #[test]
1178    fn discovered_config_sets_a_real_canonical_host() {
1179        // The regression the issue is really about: the canonical host must
1180        // not be localhost once a config file declares one.
1181        let dir = tempdir().unwrap();
1182        fs::write(
1183            dir.path().join("ssg.toml"),
1184            concat!(
1185                "site_name = \"Kaishi\"\n",
1186                "site_title = \"Kaishi\"\n",
1187                "site_description = \"A site.\"\n",
1188                "language = \"en-GB\"\n",
1189                "base_url = \"https://example.com\"\n",
1190                "content_dir = \"content\"\n",
1191                "output_dir = \"public\"\n",
1192                "template_dir = \"templates\"\n",
1193            ),
1194        )
1195        .unwrap();
1196
1197        let found = SsgConfig::discover_config_file_in(dir.path())
1198            .expect("config discovered");
1199        let cfg = SsgConfig::from_file(&found).expect("config loads");
1200
1201        assert_eq!(cfg.base_url, "https://example.com");
1202        assert!(
1203            !cfg.base_url.contains("localhost"),
1204            "canonical host fell back to localhost: {}",
1205            cfg.base_url
1206        );
1207    }
1208
1209    #[test]
1210    fn test_from_file_reads_json_config() {
1211        // `--config config/ssg.json` previously died on the opening brace
1212        // because every config was parsed as TOML regardless of extension.
1213        let dir = tempdir().unwrap();
1214        let path = dir.path().join("ssg.json");
1215        fs::write(
1216            &path,
1217            r#"{
1218  "site_name": "Kaishi",
1219  "site_title": "Kaishi",
1220  "site_description": "A site.",
1221  "language": "en-GB",
1222  "base_url": "https://example.com",
1223  "content_dir": "content",
1224  "output_dir": "public",
1225  "template_dir": "templates"
1226}"#,
1227        )
1228        .unwrap();
1229
1230        let config = SsgConfig::from_file(&path).expect("JSON config loads");
1231        assert_eq!(config.site_name, "Kaishi");
1232        assert_eq!(config.template_dir, Path::new("templates"));
1233    }
1234
1235    #[test]
1236    fn test_from_file_reports_bad_json_as_json() {
1237        let dir = tempdir().unwrap();
1238        let path = dir.path().join("ssg.json");
1239        fs::write(&path, "{ not json").unwrap();
1240        let err = SsgConfig::from_file(&path).expect_err("malformed JSON");
1241        assert!(
1242            format!("{err}").contains("JSON parsing error"),
1243            "expected a JSON diagnostic, got: {err}"
1244        );
1245    }
1246
1247    #[test]
1248    fn test_from_file_still_reads_toml_config() {
1249        let dir = tempdir().unwrap();
1250        let path = dir.path().join("ssg.toml");
1251        fs::write(
1252            &path,
1253            concat!(
1254                "site_name = \"Kaishi\"\n",
1255                "site_title = \"Kaishi\"\n",
1256                "site_description = \"A site.\"\n",
1257                "language = \"en-GB\"\n",
1258                "base_url = \"https://example.com\"\n",
1259                "content_dir = \"content\"\n",
1260                "output_dir = \"public\"\n",
1261                "template_dir = \"_layouts\"\n",
1262            ),
1263        )
1264        .unwrap();
1265        let config = SsgConfig::from_file(&path).expect("TOML config loads");
1266        assert_eq!(config.template_dir, Path::new("_layouts"));
1267    }
1268
1269    #[test]
1270    fn test_config_validation() {
1271        let config = SsgConfig::builder().site_name(String::new()).build();
1272        assert_err_variant(config, "ValidationError");
1273    }
1274
1275    #[test]
1276    fn test_config_file_size_limit() {
1277        let temp_dir = tempdir().unwrap();
1278        let config_path = temp_dir.path().join("large.toml");
1279        let mut file = File::create(&config_path).unwrap();
1280
1281        write!(file, "{}", "x".repeat(MAX_CONFIG_SIZE + 1)).unwrap();
1282
1283        assert_err_variant(
1284            SsgConfig::from_file(&config_path),
1285            "ValidationError",
1286        );
1287    }
1288
1289    #[test]
1290    fn test_config_from_str() {
1291        let config_str = r#"
1292    site_name = "test"
1293    content_dir = "./examples/content"
1294    output_dir = "./examples/public"
1295    template_dir = "./examples/templates"
1296    base_url = "http://example.com"
1297    site_title = "Test Site"
1298    site_description = "Test Description"
1299    language = "en-GB"
1300    "#;
1301
1302        let config: Result<SsgConfig, _> = config_str.parse();
1303        assert!(config.is_ok());
1304    }
1305
1306    #[test]
1307    fn test_config_builder_all_fields() {
1308        let temp_dir = tempdir().unwrap();
1309        let serve_dir = temp_dir.path().join("serve");
1310
1311        fs::create_dir_all(&serve_dir).unwrap();
1312
1313        let config = SsgConfig::builder()
1314            .site_name("test".to_string())
1315            .base_url("http://example.com".to_string())
1316            .content_dir(PathBuf::from("./examples/content"))
1317            .output_dir(PathBuf::from("./examples/public"))
1318            .template_dir(PathBuf::from("./examples/templates"))
1319            .serve_dir(Some(serve_dir))
1320            .site_title("Test Site".to_string())
1321            .site_description("Test Desc".to_string())
1322            .language("en-GB".to_string())
1323            .build();
1324
1325        assert!(config.is_ok());
1326    }
1327
1328    #[test]
1329    fn test_invalid_config_file() {
1330        let temp_dir = tempdir().unwrap();
1331        let config_path = temp_dir.path().join("invalid.toml");
1332        let mut file = File::create(&config_path).unwrap();
1333        write!(file, "invalid toml content").unwrap();
1334
1335        assert_err_variant(SsgConfig::from_file(&config_path), "TomlError");
1336    }
1337
1338    #[test]
1339    fn test_from_matches() {
1340        let matches = Cli::build().get_matches_from(vec!["ssg"]);
1341        let config = SsgConfig::from_matches(&matches);
1342        assert!(config.is_ok());
1343    }
1344
1345    #[test]
1346    fn test_config_builder_empty_required_fields() {
1347        let config = SsgConfig::builder()
1348            .site_name(String::new())
1349            .site_title(String::new())
1350            .build();
1351        assert_err_variant(config, "ValidationError");
1352    }
1353
1354    #[test]
1355    fn test_config_file_not_found() {
1356        let non_existent = Path::new("non_existent.toml");
1357        assert_err_variant(SsgConfig::from_file(non_existent), "IoError");
1358    }
1359
1360    #[test]
1361    fn test_from_matches_with_config_file() {
1362        let temp_dir = tempdir().unwrap();
1363        let config_path = temp_dir.path().join("config.toml");
1364        let config_content = r#"
1365site_name = "from-file"
1366content_dir = "./examples/content"
1367output_dir = "./examples/public"
1368template_dir = "./examples/templates"
1369base_url = "http://example.com"
1370site_title = "File Site"
1371site_description = "From file"
1372language = "en-GB"
1373"#;
1374        fs::write(&config_path, config_content).unwrap();
1375
1376        let cmd = Cli::build();
1377        let matches = cmd.get_matches_from(vec![
1378            "ssg",
1379            "--config",
1380            config_path.to_str().unwrap(),
1381        ]);
1382        let config = SsgConfig::from_matches(&matches).unwrap();
1383        assert_eq!(config.site_name, "from-file");
1384    }
1385
1386    #[test]
1387    fn test_override_with_cli_all_flags() {
1388        let cmd = Cli::build();
1389        let matches = cmd.get_matches_from(vec![
1390            "ssg",
1391            "--new",
1392            "cli-site",
1393            "--content",
1394            "./examples/content",
1395            "--output",
1396            "./examples/public",
1397            "--template",
1398            "./examples/templates",
1399            "--serve",
1400            "./examples/public",
1401        ]);
1402        let config = SsgConfig::from_matches(&matches).unwrap();
1403        assert_eq!(config.site_name, "cli-site");
1404        assert_eq!(config.content_dir, PathBuf::from("./examples/content"));
1405        assert_eq!(config.output_dir, PathBuf::from("./examples/public"));
1406        assert_eq!(config.template_dir, PathBuf::from("./examples/templates"));
1407        assert!(config.serve_dir.is_some());
1408    }
1409
1410    #[test]
1411    fn test_override_with_watch_flag() {
1412        let cmd = Cli::build();
1413        let matches = cmd.get_matches_from(vec!["ssg", "--watch"]);
1414        let config = SsgConfig::from_matches(&matches).unwrap();
1415        assert!(!config.site_name.is_empty());
1416    }
1417
1418    #[test]
1419    fn test_validate_empty_url() {
1420        let config = SsgConfig::builder()
1421            .site_name("test".to_string())
1422            .base_url(String::new())
1423            .build();
1424        assert!(config.is_ok());
1425    }
1426
1427    // -----------------------------------------------------------------
1428    // SsgConfig::from_file -- valid TOML
1429    // -----------------------------------------------------------------
1430
1431    #[test]
1432    fn test_config_from_file_valid_toml() {
1433        let temp_dir = tempdir().unwrap();
1434        let config_path = temp_dir.path().join("valid.toml");
1435        let toml_content = r#"
1436site_name = "TestSite"
1437content_dir = "./examples/content"
1438output_dir = "./examples/public"
1439template_dir = "./examples/templates"
1440base_url = "http://test.example.com"
1441site_title = "Test Title"
1442site_description = "A test site"
1443language = "en-GB"
1444"#;
1445        fs::write(&config_path, toml_content).unwrap();
1446
1447        let config = SsgConfig::from_file(&config_path).unwrap();
1448        assert_eq!(config.site_name, "TestSite");
1449        assert_eq!(config.site_title, "Test Title");
1450        assert_eq!(config.base_url, "http://test.example.com");
1451    }
1452
1453    // -----------------------------------------------------------------
1454    // SsgConfigBuilder::i18n / cdn_prefix
1455    // -----------------------------------------------------------------
1456
1457    #[cfg(feature = "i18n")]
1458    #[test]
1459    fn builder_sets_i18n() {
1460        let i18n_cfg = crate::i18n::I18nConfig {
1461            default_locale: "en".into(),
1462            locales: vec!["en".into(), "fr".into()],
1463            url_prefix: crate::i18n::UrlPrefixStrategy::SubPath,
1464        };
1465        let cfg = SsgConfig::builder()
1466            .site_name("t".to_string())
1467            .base_url("http://example.com".to_string())
1468            .i18n(Some(i18n_cfg.clone()))
1469            .build()
1470            .unwrap();
1471        assert!(cfg.i18n.is_some());
1472        assert_eq!(cfg.i18n.as_ref().unwrap().default_locale, "en");
1473    }
1474
1475    #[test]
1476    fn builder_sets_cdn_prefix() {
1477        let cfg = SsgConfig::builder()
1478            .site_name("t".to_string())
1479            .base_url("http://example.com".to_string())
1480            .cdn_prefix(Some("https://cdn.example.com".into()))
1481            .build()
1482            .unwrap();
1483        assert_eq!(cfg.cdn_prefix.as_deref(), Some("https://cdn.example.com"));
1484    }
1485
1486    #[test]
1487    fn builder_cdn_prefix_none_is_default() {
1488        let cfg = SsgConfig::builder()
1489            .site_name("t".to_string())
1490            .base_url("http://example.com".to_string())
1491            .cdn_prefix(None)
1492            .build()
1493            .unwrap();
1494        assert!(cfg.cdn_prefix.is_none());
1495    }
1496
1497    // -----------------------------------------------------------------
1498    // [security] sri_algorithm (v0.0.47 plan §3 item 2.3)
1499    // -----------------------------------------------------------------
1500
1501    #[test]
1502    fn security_section_absent_defaults_to_sha384() {
1503        let config_str = r#"
1504    site_name = "test"
1505    content_dir = "./examples/content"
1506    output_dir = "./examples/public"
1507    template_dir = "./examples/templates"
1508    base_url = "http://example.com"
1509    site_title = "Test Site"
1510    site_description = "Test Description"
1511    language = "en-GB"
1512    "#;
1513        let cfg: SsgConfig = config_str.parse().unwrap();
1514        assert_eq!(cfg.security.sri_algorithm, SriAlgorithm::Sha384);
1515    }
1516
1517    #[test]
1518    fn security_sri_algorithm_parses_all_enum_values() {
1519        for (raw, expected) in [
1520            ("sha256", SriAlgorithm::Sha256),
1521            ("sha384", SriAlgorithm::Sha384),
1522            ("sha512", SriAlgorithm::Sha512),
1523        ] {
1524            let config_str = format!(
1525                r#"
1526    site_name = "test"
1527    content_dir = "./examples/content"
1528    output_dir = "./examples/public"
1529    template_dir = "./examples/templates"
1530    base_url = "http://example.com"
1531    site_title = "Test Site"
1532    site_description = "Test Description"
1533    language = "en-GB"
1534
1535    [security]
1536    sri_algorithm = "{raw}"
1537    "#
1538            );
1539            let cfg: SsgConfig = config_str.parse().unwrap();
1540            assert_eq!(cfg.security.sri_algorithm, expected, "raw = {raw}");
1541        }
1542    }
1543
1544    #[test]
1545    fn security_sri_algorithm_rejects_unknown_value() {
1546        let config_str = r#"
1547    site_name = "test"
1548    content_dir = "./examples/content"
1549    output_dir = "./examples/public"
1550    template_dir = "./examples/templates"
1551    base_url = "http://example.com"
1552    site_title = "Test Site"
1553    site_description = "Test Description"
1554    language = "en-GB"
1555
1556    [security]
1557    sri_algorithm = "md5"
1558    "#;
1559        let cfg: Result<SsgConfig, CliError> = config_str.parse();
1560        assert_err_variant(cfg, "TomlError");
1561    }
1562
1563    // -----------------------------------------------------------------
1564    // Error-path propagation
1565    // -----------------------------------------------------------------
1566
1567    #[test]
1568    fn from_matches_rejects_invalid_content_override() {
1569        // An invalid --content path fails override_with_cli's
1570        // re-validation, covering both `?` propagation sites.
1571        let matches =
1572            Cli::build().get_matches_from(vec!["ssg", "--content", "bad<dir"]);
1573        assert_err_variant(SsgConfig::from_matches(&matches), "InvalidPath");
1574    }
1575
1576    #[test]
1577    fn from_matches_propagates_missing_config_file_error() {
1578        let matches = Cli::build().get_matches_from(vec![
1579            "ssg",
1580            "--config",
1581            "/nonexistent/ssg-test-config.toml",
1582        ]);
1583        assert_err_variant(SsgConfig::from_matches(&matches), "IoError");
1584    }
1585
1586    /// `theme` in a config file resolves `template_dir`, and does so
1587    /// relative to the config file rather than the working directory.
1588    #[test]
1589    fn a_theme_in_the_config_resolves_the_template_dir() {
1590        let tmp = tempdir().expect("tempdir");
1591        fs::create_dir_all(tmp.path().join("themes/quill/_layouts"))
1592            .expect("theme");
1593        let cfg_path = tmp.path().join("ssg.toml");
1594        fs::write(
1595            &cfg_path,
1596            "site_name = \"s\"\nsite_title = \"t\"\n\
1597             site_description = \"d\"\nbase_url = \"https://example.com\"\n\
1598             language = \"en-GB\"\ncontent_dir = \"content\"\n\
1599             output_dir = \"public\"\ntheme = \"quill\"\n",
1600        )
1601        .expect("write config");
1602
1603        let cfg = SsgConfig::from_file(&cfg_path).expect("load");
1604        assert_eq!(cfg.theme.as_deref(), Some("quill"));
1605        assert!(
1606            cfg.template_dir.ends_with("quill/_layouts"),
1607            "template_dir should come from the theme, got {}",
1608            cfg.template_dir.display()
1609        );
1610    }
1611
1612    /// Naming both is how a project overrides one layout without
1613    /// forking the theme, so an explicit `template_dir` is not
1614    /// overwritten by theme resolution.
1615    #[test]
1616    fn an_explicit_template_dir_survives_theme_resolution() {
1617        let tmp = tempdir().expect("tempdir");
1618        fs::create_dir_all(tmp.path().join("themes/quill/_layouts"))
1619            .expect("theme");
1620        let mut cfg = SsgConfig::default();
1621        cfg.theme = Some("quill".to_string());
1622        cfg.template_dir = PathBuf::from("my-own-layouts");
1623        cfg.apply_theme(tmp.path()).expect("apply");
1624        assert_eq!(cfg.template_dir, PathBuf::from("my-own-layouts"));
1625    }
1626
1627    /// The regression that made `--theme` a no-op: a config naming a
1628    /// theme has already had `template_dir` resolved, so a second
1629    /// `apply_theme` saw a non-default value and declined to touch it.
1630    /// `ssg build --theme stablo` over `theme = "quill"` then produced a
1631    /// byte-identical quill site and said nothing.
1632    #[test]
1633    fn a_second_theme_replaces_one_already_resolved() {
1634        let tmp = tempdir().expect("tempdir");
1635        for name in ["quill", "stablo"] {
1636            fs::create_dir_all(
1637                tmp.path().join("themes").join(name).join("_layouts"),
1638            )
1639            .expect("theme");
1640        }
1641        let mut cfg = SsgConfig::default();
1642        cfg.theme = Some("quill".to_string());
1643        cfg.apply_theme(tmp.path()).expect("first");
1644        assert!(cfg.template_dir.ends_with("quill/_layouts"));
1645
1646        // What the CLI override does: reset, then resolve the new name.
1647        cfg.theme = Some("stablo".to_string());
1648        cfg.template_dir = default_template_dir();
1649        cfg.apply_theme(tmp.path()).expect("second");
1650        assert!(
1651            cfg.template_dir.ends_with("stablo/_layouts"),
1652            "the second theme must win, got {}",
1653            cfg.template_dir.display()
1654        );
1655    }
1656
1657    /// A config naming only a theme must parse: `template_dir` is what
1658    /// the theme is there to supply.
1659    #[test]
1660    fn a_config_without_template_dir_parses() {
1661        let cfg: SsgConfig = toml::from_str(
1662            "site_name = \"s\"\nsite_title = \"t\"\n\
1663             site_description = \"d\"\nbase_url = \"https://example.com\"\n\
1664             language = \"en-GB\"\ncontent_dir = \"content\"\n\
1665             output_dir = \"public\"\n",
1666        )
1667        .expect("a config without template_dir should parse");
1668        assert_eq!(cfg.template_dir, PathBuf::from("templates"));
1669    }
1670
1671    #[test]
1672    fn from_subcommand_matches_rejects_invalid_content_override() {
1673        let (_inv, matches) =
1674            Cli::parse_and_dispatch(["ssg", "build", "--content", "bad<dir"])
1675                .unwrap();
1676        let sub = matches.subcommand_matches("build").unwrap();
1677        assert_err_variant(
1678            SsgConfig::from_subcommand_matches(sub),
1679            "InvalidPath",
1680        );
1681    }
1682
1683    #[test]
1684    fn from_subcommand_matches_dev_without_serve_keeps_none() {
1685        // `dev` exposes --serve; leaving it unset covers the inner
1686        // `if let Some(serve_dir)` miss branch.
1687        let (_inv, matches) = Cli::parse_and_dispatch(["ssg", "dev"]).unwrap();
1688        let sub = matches.subcommand_matches("dev").unwrap();
1689        let cfg = SsgConfig::from_subcommand_matches(sub).unwrap();
1690        assert!(cfg.serve_dir.is_none());
1691    }
1692
1693    #[test]
1694    fn from_file_fails_when_path_is_a_directory() {
1695        // metadata() succeeds but read_to_string() fails, covering the
1696        // read error propagation distinct from the not-found case.
1697        let dir = tempdir().unwrap();
1698        assert_err_variant(SsgConfig::from_file(dir.path()), "IoError");
1699    }
1700
1701    #[test]
1702    fn from_file_propagates_validation_failure() {
1703        let dir = tempdir().unwrap();
1704        let path = dir.path().join("invalid-fields.toml");
1705        fs::write(
1706            &path,
1707            r#"
1708site_name = ""
1709content_dir = "./examples/content"
1710output_dir = "./examples/public"
1711template_dir = "./examples/templates"
1712base_url = "http://example.com"
1713site_title = "T"
1714site_description = "D"
1715language = "en-GB"
1716"#,
1717        )
1718        .unwrap();
1719        assert_err_variant(SsgConfig::from_file(&path), "ValidationError");
1720    }
1721
1722    #[test]
1723    fn from_str_propagates_validation_failure() {
1724        let config_str = r#"
1725    site_name = ""
1726    content_dir = "./examples/content"
1727    output_dir = "./examples/public"
1728    template_dir = "./examples/templates"
1729    base_url = "http://example.com"
1730    site_title = "T"
1731    site_description = "D"
1732    language = "en-GB"
1733    "#;
1734        let cfg: Result<SsgConfig, CliError> = config_str.parse();
1735        assert_err_variant(cfg, "ValidationError");
1736    }
1737
1738    #[test]
1739    fn validate_rejects_invalid_base_url() {
1740        let cfg = SsgConfig::builder()
1741            .site_name("t".to_string())
1742            .base_url("ftp://example.com".to_string())
1743            .build();
1744        assert_err_variant(cfg, "InvalidUrl");
1745    }
1746
1747    #[test]
1748    fn validate_rejects_invalid_content_dir() {
1749        let cfg = SsgConfig::builder()
1750            .site_name("t".to_string())
1751            .content_dir(PathBuf::from("bad<content"))
1752            .build();
1753        assert_err_variant(cfg, "InvalidPath");
1754    }
1755
1756    #[test]
1757    fn validate_rejects_invalid_output_dir() {
1758        let cfg = SsgConfig::builder()
1759            .site_name("t".to_string())
1760            .output_dir(PathBuf::from("bad<output"))
1761            .build();
1762        assert_err_variant(cfg, "InvalidPath");
1763    }
1764
1765    #[test]
1766    fn validate_rejects_invalid_template_dir() {
1767        let cfg = SsgConfig::builder()
1768            .site_name("t".to_string())
1769            .template_dir(PathBuf::from("bad<template"))
1770            .build();
1771        assert_err_variant(cfg, "InvalidPath");
1772    }
1773
1774    #[test]
1775    fn validate_rejects_invalid_serve_dir() {
1776        let cfg = SsgConfig::builder()
1777            .site_name("t".to_string())
1778            .serve_dir(Some(PathBuf::from("bad<serve")))
1779            .build();
1780        assert_err_variant(cfg, "InvalidPath");
1781    }
1782
1783    #[test]
1784    fn builder_transitions_flag_round_trips() {
1785        let on = SsgConfig::builder().transitions(true).build().unwrap();
1786        assert!(on.transitions);
1787        let off = SsgConfig::builder().transitions(false).build().unwrap();
1788        assert!(!off.transitions);
1789    }
1790
1791    // -----------------------------------------------------------------
1792    // SsgConfig::from_subcommand_matches
1793    // -----------------------------------------------------------------
1794
1795    #[test]
1796    fn from_subcommand_matches_returns_defaults_when_no_overrides() {
1797        let (_inv, matches) =
1798            Cli::parse_and_dispatch(["ssg", "build"]).unwrap();
1799        let sub = matches.subcommand_matches("build").unwrap();
1800        let cfg = SsgConfig::from_subcommand_matches(sub).unwrap();
1801        // Defaults preserved.
1802        assert_eq!(cfg.content_dir, PathBuf::from("content"));
1803        assert_eq!(cfg.output_dir, PathBuf::from("public"));
1804        assert_eq!(cfg.template_dir, PathBuf::from("templates"));
1805        assert!(cfg.serve_dir.is_none());
1806    }
1807
1808    #[test]
1809    fn from_subcommand_matches_applies_content_output_template_overrides() {
1810        let (_inv, matches) = Cli::parse_and_dispatch([
1811            "ssg",
1812            "build",
1813            "--content",
1814            "/c",
1815            "--output",
1816            "/o",
1817            "--template",
1818            "/t",
1819        ])
1820        .unwrap();
1821        let sub = matches.subcommand_matches("build").unwrap();
1822        let cfg = SsgConfig::from_subcommand_matches(sub).unwrap();
1823        assert_eq!(cfg.content_dir, PathBuf::from("/c"));
1824        assert_eq!(cfg.output_dir, PathBuf::from("/o"));
1825        assert_eq!(cfg.template_dir, PathBuf::from("/t"));
1826    }
1827
1828    #[test]
1829    fn from_subcommand_matches_picks_up_serve_for_dev_subcommand() {
1830        let (_inv, matches) =
1831            Cli::parse_and_dispatch(["ssg", "dev", "--serve", "/srv"]).unwrap();
1832        let sub = matches.subcommand_matches("dev").unwrap();
1833        let cfg = SsgConfig::from_subcommand_matches(sub).unwrap();
1834        assert_eq!(cfg.serve_dir, Some(PathBuf::from("/srv")));
1835    }
1836
1837    #[test]
1838    fn from_subcommand_matches_check_subcommand_has_no_serve() {
1839        // `check` doesn't expose `--serve` — code path goes through
1840        // try_contains_id == false branch.
1841        let (_inv, matches) =
1842            Cli::parse_and_dispatch(["ssg", "check"]).unwrap();
1843        let sub = matches.subcommand_matches("check").unwrap();
1844        let cfg = SsgConfig::from_subcommand_matches(sub).unwrap();
1845        assert!(cfg.serve_dir.is_none());
1846    }
1847
1848    #[test]
1849    fn from_subcommand_matches_loads_config_file_when_present() {
1850        let dir = tempdir().unwrap();
1851        let cfg_path = dir.path().join("c.toml");
1852        fs::write(
1853            &cfg_path,
1854            r#"
1855site_name = "FromSub"
1856content_dir = "./examples/content"
1857output_dir = "./examples/public"
1858template_dir = "./examples/templates"
1859base_url = "http://sub.example.com"
1860site_title = "Sub Title"
1861site_description = "Sub Desc"
1862language = "en-GB"
1863"#,
1864        )
1865        .unwrap();
1866
1867        let (_inv, matches) = Cli::parse_and_dispatch([
1868            "ssg",
1869            "build",
1870            "--config",
1871            cfg_path.to_str().unwrap(),
1872        ])
1873        .unwrap();
1874        let sub = matches.subcommand_matches("build").unwrap();
1875        let cfg = SsgConfig::from_subcommand_matches(sub).unwrap();
1876        assert_eq!(cfg.site_name, "FromSub");
1877        assert_eq!(cfg.base_url, "http://sub.example.com");
1878    }
1879}