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}