Skip to main content

ssg/
lib.rs

1#![forbid(unsafe_code)]
2#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
3// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
4// SPDX-License-Identifier: Apache-2.0 OR MIT
5#![doc = include_str!("../README.md")]
6#![doc(
7    html_favicon_url = "https://cloudcdn.pro/static-site-generator/v1/favicon.ico",
8    html_logo_url = "https://cloudcdn.pro/static-site-generator/v1/logos/static-site-generator.svg",
9    html_root_url = "https://docs.rs/ssg"
10)]
11#![crate_name = "ssg"]
12#![crate_type = "lib"]
13
14/// Fault injection macro. When the `test-fault-injection` feature is
15/// enabled, delegates to the `fail` crate's real `fail_point!`. In
16/// normal builds this compiles to nothing.
17#[cfg(feature = "test-fault-injection")]
18macro_rules! fail_point {
19    ($name:expr) => {
20        fail::fail_point!($name);
21    };
22    ($name:expr, $body:expr) => {
23        fail::fail_point!($name, $body);
24    };
25}
26#[cfg(not(feature = "test-fault-injection"))]
27macro_rules! fail_point {
28    ($name:expr) => {};
29    ($name:expr, $body:expr) => {};
30}
31
32/// Test-only utilities shared across unit test modules.
33#[cfg(test)]
34#[allow(unreachable_pub, clippy::unwrap_used, clippy::expect_used)]
35pub(crate) mod test_support {
36    use std::sync::Once;
37
38    static LOGGER: Once = Once::new();
39
40    /// Raises `log::max_level()` to Trace so `log::info!` / `log::warn!`
41    /// macro bodies execute their format arguments and are counted by
42    /// LLVM region coverage. We only bump the filter level; no logger
43    /// backend is installed, so it does not conflict with tests that
44    /// install their own (e.g. the `env_logger` init test in lib.rs).
45    /// Safe to call from any number of tests or fixtures.
46    pub fn init_logger() {
47        LOGGER.call_once(|| {
48            log::set_max_level(log::LevelFilter::Trace);
49        });
50    }
51}
52
53// Standard library imports
54use std::{
55    fs,
56    path::{Path, PathBuf},
57};
58
59use crate::cmd::{Cli, CliInvocation, SsgConfig};
60
61// Third-party imports
62use log::{debug, info};
63
64/// Returns the current time as an ISO 8601 UTC string.
65///
66/// # Examples
67///
68/// ```rust
69/// use ssg::now_iso;
70///
71/// let stamp = now_iso();
72/// // Format is YYYY-MM-DDTHH:MM:SSZ — always 20 chars.
73/// assert_eq!(stamp.len(), 20);
74/// assert!(stamp.ends_with('Z'));
75/// assert_eq!(&stamp[4..5], "-");
76/// ```
77#[must_use]
78#[allow(clippy::many_single_char_names)]
79pub fn now_iso() -> String {
80    use std::time::{SystemTime, UNIX_EPOCH};
81    let dur = SystemTime::now()
82        .duration_since(UNIX_EPOCH)
83        .unwrap_or_default();
84    let secs = dur.as_secs();
85    let (sec, min, hour) = (secs % 60, (secs / 60) % 60, (secs / 3600) % 24);
86    let days = secs / 86400;
87    let (year, month, day) = days_to_ymd(days);
88    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z")
89}
90
91/// Civil days algorithm (Howard Hinnant) — converts days since Unix epoch to (Y, M, D).
92const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
93    let z = days + 719_468;
94    let era = z / 146_097;
95    let doe = z - era * 146_097;
96    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
97    let y = yoe + era * 400;
98    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
99    let mp = (5 * doy + 2) / 153;
100    let d = doy - (153 * mp + 2) / 5 + 1;
101    let m = if mp < 10 { mp + 3 } else { mp - 9 };
102    let y = if m <= 2 { y + 1 } else { y };
103    (y, m, d)
104}
105
106pub mod audit;
107pub mod cmd;
108#[path = "core/mod.rs"]
109pub(crate) mod core_group;
110pub mod error;
111#[path = "plugins/mod.rs"]
112pub(crate) mod plugins_group;
113#[path = "server/mod.rs"]
114pub(crate) mod server_group;
115pub mod theme;
116#[path = "util/mod.rs"]
117pub mod util;
118pub use error::{PathErrorExt, SsgError};
119
120// Re-export core modules for public API compatibility
121pub use crate::core_group::bench_corpus;
122pub use crate::core_group::cache;
123pub use crate::core_group::collections;
124pub use crate::core_group::content;
125pub use crate::core_group::content_stager;
126pub use crate::core_group::dates;
127pub use crate::core_group::depgraph;
128pub use crate::core_group::deploy;
129pub use crate::core_group::deploy_adapter;
130pub use crate::core_group::frontmatter;
131pub use crate::core_group::fs_ops;
132pub use crate::core_group::io_pool;
133pub use crate::core_group::logging;
134pub use crate::core_group::otel;
135pub use crate::core_group::pipeline;
136pub use crate::core_group::process;
137pub use crate::core_group::scaffold;
138pub use crate::core_group::schema;
139pub use crate::core_group::stream;
140pub use crate::core_group::streaming;
141#[cfg(feature = "templates")]
142pub use crate::core_group::template_engine;
143pub use crate::core_group::theme_manifest;
144pub use crate::core_group::urls;
145pub use crate::core_group::walk;
146
147// Re-export plugin modules
148pub use crate::plugins_group::accessibility;
149pub use crate::plugins_group::agent_api;
150pub use crate::plugins_group::ai;
151pub use crate::plugins_group::assets;
152pub use crate::plugins_group::audit as audit_plugin;
153pub use crate::plugins_group::csp;
154pub use crate::plugins_group::drafts;
155pub use crate::plugins_group::highlight;
156#[cfg(feature = "i18n")]
157pub use crate::plugins_group::i18n;
158#[cfg(feature = "image-optimization")]
159pub use crate::plugins_group::image_plugin;
160pub use crate::plugins_group::islands;
161pub use crate::plugins_group::isr_manifest;
162pub use crate::plugins_group::listings;
163pub use crate::plugins_group::llm;
164pub use crate::plugins_group::llm_cache;
165pub use crate::plugins_group::markdown_ext;
166pub use crate::plugins_group::oembed;
167pub use crate::plugins_group::og_image;
168pub use crate::plugins_group::pagination;
169pub use crate::plugins_group::plugin;
170pub use crate::plugins_group::plugins;
171pub use crate::plugins_group::postprocess;
172pub use crate::plugins_group::rpc_schema;
173pub use crate::plugins_group::sbom;
174pub use crate::plugins_group::search;
175pub use crate::plugins_group::search_index;
176pub use crate::plugins_group::seo;
177pub use crate::plugins_group::shortcodes;
178pub use crate::plugins_group::taxonomy;
179#[cfg(feature = "templates")]
180pub use crate::plugins_group::template_plugin;
181pub use crate::plugins_group::topic_clusters;
182pub use crate::plugins_group::view_transitions;
183
184// Re-export server modules
185pub use crate::server_group::dev_server;
186pub use crate::server_group::event_watch;
187pub use crate::server_group::hmr;
188pub use crate::server_group::livereload;
189pub use crate::server_group::server;
190pub use crate::server_group::watch;
191
192/// Re-exports
193pub use staticdatagen;
194
195// Re-export everything that was previously pub in lib.rs
196pub use crate::core_group::fs_ops::{
197    collect_files_recursive, copy_dir_all, copy_dir_all_async,
198    copy_dir_with_progress, is_path_within_root, is_safe_path,
199    verify_and_copy_files, verify_and_copy_files_async, verify_file_safety,
200};
201pub use crate::core_group::logging::{
202    create_log_file, log_arguments, log_initialization,
203};
204pub use crate::core_group::pipeline::{compile_site, execute_build_pipeline};
205pub use crate::server_group::server::{
206    generate_locale_redirect, handle_server, prepare_serve_dir, serve_site,
207    serve_site_with, HttpTransport, ServeTransport,
208};
209
210/// Maximum directory nesting depth for all traversal operations.
211/// Prevents stack overflow from pathological or circular directory trees.
212/// 128 levels accommodates any realistic project structure.
213pub const MAX_DIR_DEPTH: usize = 128;
214
215/// Represents the necessary directory paths for the site generator.
216#[derive(Debug, Clone)]
217pub struct Paths {
218    /// The site output directory
219    pub site: PathBuf,
220    /// The content directory
221    pub content: PathBuf,
222    /// The build directory
223    pub build: PathBuf,
224    /// The template directory
225    pub template: PathBuf,
226}
227
228impl Paths {
229    /// Creates a new builder for configuring Paths
230    ///
231    /// # Examples
232    ///
233    /// ```rust
234    /// use ssg::Paths;
235    ///
236    /// let paths = Paths::builder()
237    ///     .site("out")
238    ///     .content("docs")
239    ///     .build_dir("tmp")
240    ///     .template("tpl")
241    ///     .build()
242    ///     .expect("valid paths");
243    /// assert_eq!(paths.site.to_str(), Some("out"));
244    /// ```
245    #[must_use]
246    pub fn builder() -> PathsBuilder {
247        PathsBuilder::default()
248    }
249
250    /// Creates paths with default directories
251    ///
252    /// # Examples
253    ///
254    /// ```rust
255    /// use ssg::Paths;
256    ///
257    /// let paths = Paths::default_paths();
258    /// assert_eq!(paths.site.to_str(), Some("public"));
259    /// assert_eq!(paths.content.to_str(), Some("content"));
260    /// assert_eq!(paths.build.to_str(), Some("build"));
261    /// assert_eq!(paths.template.to_str(), Some("templates"));
262    /// ```
263    #[must_use]
264    pub fn default_paths() -> Self {
265        Self {
266            site: PathBuf::from("public"),
267            content: PathBuf::from("content"),
268            build: PathBuf::from("build"),
269            template: PathBuf::from("templates"),
270        }
271    }
272}
273// Modify the validate method in Paths impl
274impl Paths {
275    /// Validates all paths in the configuration
276    ///
277    /// # Examples
278    ///
279    /// ```rust
280    /// use ssg::Paths;
281    /// use std::path::PathBuf;
282    ///
283    /// let good = Paths::default_paths();
284    /// assert!(good.validate().is_ok());
285    ///
286    /// let bad = Paths {
287    ///     site: PathBuf::from("../escape"),
288    ///     content: PathBuf::from("content"),
289    ///     build: PathBuf::from("build"),
290    ///     template: PathBuf::from("templates"),
291    /// };
292    /// assert!(bad.validate().is_err());
293    /// ```
294    ///
295    /// # Errors
296    ///
297    /// Returns [`SsgError::PathTraversal`] if any path has a `..`
298    /// component,
299    /// [`SsgError::Validation`] for malformed paths, or
300    /// [`SsgError::SymlinkForbidden`] if a path points at a symlink.
301    pub fn validate(&self) -> Result<(), SsgError> {
302        // Check for path traversal and other security concerns
303        for (name, path) in [
304            ("site", &self.site),
305            ("content", &self.content),
306            ("build", &self.build),
307            ("template", &self.template),
308        ] {
309            // Reject any `..` *component*, the same rule
310            // `create_directories` applies to these paths through
311            // `is_safe_path`. Matching components rather than the
312            // substring means `posts/a..b` is an ordinary name.
313            if path
314                .components()
315                .any(|c| c == std::path::Component::ParentDir)
316            {
317                return Err(SsgError::PathTraversal { path: path.clone() });
318            }
319            let path_str = path.to_string_lossy();
320            if path_str.contains("//") {
321                return Err(SsgError::Validation {
322                    field: name.to_string(),
323                    message: format!(
324                        "path contains invalid double slashes: {}",
325                        path.display()
326                    ),
327                });
328            }
329
330            // If path exists, perform additional checks
331            if path.exists() {
332                let metadata = symlink_metadata_checked(path)?;
333
334                if metadata.file_type().is_symlink() {
335                    return Err(SsgError::SymlinkForbidden {
336                        path: path.clone(),
337                    });
338                }
339            }
340        }
341
342        Ok(())
343    }
344}
345
346/// Fault-injectable wrapper around [`Path::symlink_metadata`].
347///
348/// Extracted from [`Paths::validate`] so the metadata error branch can
349/// be driven by the `lib::symlink-metadata` failpoint under the
350/// `test-fault-injection` feature — once `path.exists()` has returned
351/// `true`, the call cannot otherwise be made to fail deterministically.
352fn symlink_metadata_checked(path: &Path) -> Result<fs::Metadata, SsgError> {
353    fail_point!("lib::symlink-metadata", |_| Err(SsgError::Validation {
354        field: "path".to_string(),
355        message: "injected: lib::symlink-metadata".to_string(),
356    }));
357    path.symlink_metadata().with_path(path)
358}
359
360/// Fault-injectable wrapper around [`is_safe_path`].
361///
362/// Extracted from [`create_directories`] so the `is_safe_path` error
363/// branch can be driven by the `lib::is-safe-path` failpoint under the
364/// `test-fault-injection` feature — `is_safe_path` only errors when an
365/// existing path fails `canonicalize`, which is not constructible
366/// deterministically in a test.
367fn is_safe_path_checked(path: &Path) -> Result<bool, SsgError> {
368    fail_point!("lib::is-safe-path", |_| Err(SsgError::Validation {
369        field: "path".to_string(),
370        message: "injected: lib::is-safe-path".to_string(),
371    }));
372    is_safe_path(path)
373}
374
375/// Builder for creating Paths configurations
376#[derive(Debug, Default, Clone)]
377pub struct PathsBuilder {
378    /// The site output directory
379    pub site: Option<PathBuf>,
380    /// The content directory
381    pub content: Option<PathBuf>,
382    /// The build directory
383    pub build: Option<PathBuf>,
384    /// The template directory
385    pub template: Option<PathBuf>,
386}
387
388impl PathsBuilder {
389    /// Sets the site output directory
390    ///
391    /// # Examples
392    ///
393    /// ```rust
394    /// use ssg::PathsBuilder;
395    ///
396    /// let b = PathsBuilder::default().site("dist");
397    /// assert_eq!(b.site.as_deref().and_then(|p| p.to_str()), Some("dist"));
398    /// ```
399    pub fn site<P: Into<PathBuf>>(mut self, path: P) -> Self {
400        self.site = Some(path.into());
401        self
402    }
403
404    /// Sets the content directory
405    ///
406    /// # Examples
407    ///
408    /// ```rust
409    /// use ssg::PathsBuilder;
410    ///
411    /// let b = PathsBuilder::default().content("posts");
412    /// assert_eq!(b.content.as_deref().and_then(|p| p.to_str()), Some("posts"));
413    /// ```
414    pub fn content<P: Into<PathBuf>>(mut self, path: P) -> Self {
415        self.content = Some(path.into());
416        self
417    }
418
419    /// Sets the build directory
420    ///
421    /// # Examples
422    ///
423    /// ```rust
424    /// use ssg::PathsBuilder;
425    ///
426    /// let b = PathsBuilder::default().build_dir("work");
427    /// assert_eq!(b.build.as_deref().and_then(|p| p.to_str()), Some("work"));
428    /// ```
429    pub fn build_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
430        self.build = Some(path.into());
431        self
432    }
433
434    /// Sets the template directory
435    ///
436    /// # Examples
437    ///
438    /// ```rust
439    /// use ssg::PathsBuilder;
440    ///
441    /// let b = PathsBuilder::default().template("layouts");
442    /// assert_eq!(b.template.as_deref().and_then(|p| p.to_str()), Some("layouts"));
443    /// ```
444    pub fn template<P: Into<PathBuf>>(mut self, path: P) -> Self {
445        self.template = Some(path.into());
446        self
447    }
448
449    /// Sets all paths relative to a base directory
450    ///
451    /// # Examples
452    ///
453    /// ```rust
454    /// use ssg::PathsBuilder;
455    ///
456    /// let paths = PathsBuilder::default()
457    ///     .relative_to("site")
458    ///     .build()
459    ///     .expect("valid");
460    /// assert!(paths.site.ends_with("public"));
461    /// assert!(paths.content.ends_with("content"));
462    /// ```
463    pub fn relative_to<P: AsRef<Path>>(self, base: P) -> Self {
464        let base = base.as_ref();
465        self.site(base.join("public"))
466            .content(base.join("content"))
467            .build_dir(base.join("build"))
468            .template(base.join("templates"))
469    }
470
471    /// Builds the Paths configuration
472    ///
473    /// # Returns
474    ///
475    /// * `Result<Paths>` - The configured paths if valid
476    ///
477    /// # Examples
478    ///
479    /// ```rust
480    /// use ssg::PathsBuilder;
481    ///
482    /// let paths = PathsBuilder::default().build().expect("defaults valid");
483    /// assert_eq!(paths.site.to_str(), Some("public"));
484    /// ```
485    ///
486    /// # Errors
487    ///
488    /// Returns an error if:
489    /// * Required paths are missing
490    /// * Paths are invalid or unsafe
491    /// * Unable to create necessary directories
492    pub fn build(self) -> Result<Paths, SsgError> {
493        let paths = Paths {
494            site: self.site.unwrap_or_else(|| PathBuf::from("public")),
495            content: self.content.unwrap_or_else(|| PathBuf::from("content")),
496            build: self.build.unwrap_or_else(|| PathBuf::from("build")),
497            template: self
498                .template
499                .unwrap_or_else(|| PathBuf::from("templates")),
500        };
501
502        // Validate the configuration
503        paths.validate()?;
504
505        Ok(paths)
506    }
507}
508
509/// Creates and verifies required directories for site generation.
510///
511/// Ensures all necessary directories exist and are safe to use, creating
512/// them if necessary. Also performs security checks on each directory.
513///
514/// # Arguments
515///
516/// * `paths` - Reference to a Paths struct containing required directory paths
517///
518/// # Returns
519///
520/// * `Ok(())` - If all directories are created/verified successfully
521/// * `Err` - If any directory operation fails
522///
523/// # Examples
524///
525/// ```rust
526/// use std::path::PathBuf;
527/// use ssg::{Paths, create_directories};
528///
529/// fn main() -> Result<(), ssg::SsgError> {
530///     let paths = Paths {
531///         site: PathBuf::from("public"),
532///         content: PathBuf::from("content"),
533///         build: PathBuf::from("build"),
534///         template: PathBuf::from("templates"),
535///     };
536///
537///     create_directories(&paths)?;
538///     println!("All directories ready");
539///     Ok(())
540/// }
541/// ```
542///
543/// # Security
544///
545/// Performs the following security checks:
546/// * Path traversal prevention
547/// * Permission validation
548/// * Safe path verification
549pub fn create_directories(paths: &Paths) -> Result<(), SsgError> {
550    // Path safety check FIRST — `is_safe_path` only flags `..` for
551    // non-existent paths, so we must validate before
552    // `fs::create_dir_all` materialises any traversal target on disk.
553    // Reordering also closes a TOCTOU-style gap where the previous
554    // implementation could create `..`-relative directories and then
555    // fail to detect them because they now existed.
556    if !is_safe_path_checked(&paths.content)? {
557        return Err(SsgError::PathTraversal {
558            path: paths.content.clone(),
559        });
560    }
561    if !is_safe_path_checked(&paths.build)? {
562        return Err(SsgError::PathTraversal {
563            path: paths.build.clone(),
564        });
565    }
566    if !is_safe_path_checked(&paths.site)? {
567        return Err(SsgError::PathTraversal {
568            path: paths.site.clone(),
569        });
570    }
571    if !is_safe_path_checked(&paths.template)? {
572        return Err(SsgError::PathTraversal {
573            path: paths.template.clone(),
574        });
575    }
576
577    // Materialise each directory after safety validation passes.
578    for (_name, path) in [
579        ("content", &paths.content),
580        ("build", &paths.build),
581        ("site", &paths.site),
582        ("template", &paths.template),
583    ] {
584        fs::create_dir_all(path).with_path(path)?;
585    }
586
587    Ok(())
588}
589
590/// Executes the static site generation process.
591///
592/// Parses CLI arguments via [`Cli::parse_and_dispatch`], then routes to
593/// either a subcommand handler (issue #527) or the legacy flag-driven
594/// pipeline. This function blocks indefinitely while the dev server is
595/// running.
596///
597/// # Examples
598///
599/// ```no_run
600/// // `run()` reads from real argv and may start a dev server, so it's
601/// // only ever called from `main()`. The signature is `Result<(), _>`.
602/// fn main() -> Result<(), ssg::SsgError> {
603///     ssg::run()
604/// }
605/// ```
606pub fn run() -> Result<(), SsgError> {
607    let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
608    run_with_argv(argv)
609}
610
611/// Body of [`run`], parameterised over `argv`.
612///
613/// Extracted from [`run`] (which reads the real process argv) so unit
614/// tests can drive the full parse → log-init → dispatch sequence with
615/// a controlled argument vector.
616fn run_with_argv(argv: Vec<std::ffi::OsString>) -> Result<(), SsgError> {
617    // Parse argv via the unified subcommand-aware dispatcher. clap
618    // short-circuits `--help` / `--version` inside this call so the
619    // logger banner never prints for those flags.
620    let (invocation, matches) = match Cli::parse_and_dispatch(argv) {
621        Ok(pair) => pair,
622        // clap errors render themselves and exit with the right code
623        // (0 for `--help` / `--version`, 2 for parse failures), so
624        // we delegate rather than wrap into SsgError.
625        Err(e) => e.exit(),
626    };
627
628    initialize_logging_checked()?;
629
630    // OTel build tracing — only initialises if both the `otel` feature
631    // is compiled in AND `--trace` was passed. The subcommand parser
632    // doesn't (yet) expose `--trace`; `try_contains_id` keeps the
633    // call safe on both code paths.
634    let trace_flag = if matches.try_contains_id("trace").unwrap_or(false) {
635        matches.get_flag("trace")
636    } else {
637        false
638    };
639    let _ = otel::init_if_enabled(trace_flag);
640
641    // Startup chatter belongs at debug: this also fires for `audit`
642    // and `plugins list`, which generate no site at all.
643    debug!("Starting dispatch");
644
645    dispatch_invocation(invocation, &matches)
646}
647
648/// Routes a parsed [`CliInvocation`] to the appropriate handler.
649fn dispatch_invocation(
650    invocation: CliInvocation,
651    matches: &clap::ArgMatches,
652) -> Result<(), SsgError> {
653    let announces_site = announces_site(&invocation, matches);
654
655    let result = match invocation {
656        CliInvocation::Legacy => run_legacy(matches),
657        CliInvocation::Build => run_subcommand(matches, "build", false),
658        CliInvocation::Dev => run_subcommand(matches, "dev", true),
659        CliInvocation::Check => run_check(matches),
660        CliInvocation::Audit => run_audit(matches),
661        CliInvocation::Deploy { target } => run_deploy(matches, &target),
662        CliInvocation::Plugins { json, target } => {
663            run_plugins(json, target.as_deref())
664        }
665    };
666
667    // stderr, not stdout: `ssg audit --sarif` streams machine-readable
668    // SARIF JSON on stdout, and a trailing status line corrupts it.
669    if result.is_ok() && announces_site {
670        eprintln!("Site generated successfully.");
671    }
672    result
673}
674
675/// Whether `invocation` writes a site to disk, and may therefore claim
676/// one was generated.
677///
678/// `check`, `audit` and `plugins` produce no output directory and report
679/// their own results, so announcing a generated site for them is simply
680/// false. Split out from [`dispatch_invocation`] so the classification
681/// can be tested without running a build.
682const fn generates_site(invocation: &CliInvocation) -> bool {
683    match invocation {
684        CliInvocation::Legacy
685        | CliInvocation::Build
686        | CliInvocation::Dev
687        | CliInvocation::Deploy { .. } => true,
688        // Listed explicitly rather than via `_` so a new subcommand
689        // fails to compile here instead of silently defaulting to
690        // claiming it generated a site.
691        CliInvocation::Check
692        | CliInvocation::Audit
693        | CliInvocation::Plugins { .. } => false,
694    }
695}
696
697/// Whether a successful `invocation` may print "Site generated
698/// successfully.".
699///
700/// The legacy form with `--new NAME` scaffolds a project and returns
701/// before building (see [`run_legacy`]), so it has no site to announce.
702fn announces_site(
703    invocation: &CliInvocation,
704    matches: &clap::ArgMatches,
705) -> bool {
706    let scaffolds = matches!(invocation, CliInvocation::Legacy)
707        && matches
708            .try_get_one::<String>("new")
709            .ok()
710            .flatten()
711            .is_some();
712    generates_site(invocation) && !scaffolds
713}
714
715/// Reports the plugin pipeline without building anything.
716///
717/// The manager is populated through the same `register_default_plugins` the
718/// build uses, so the listing cannot drift from what actually runs — which is
719/// the point: the README's plugin count is generated from this, and the count
720/// had already gone stale once (33 registered, "38 plugins" documented).
721fn run_plugins(json: bool, target: Option<&str>) -> Result<(), SsgError> {
722    // Which plugins register depends on configuration — the edge-headers
723    // emitter only appears when a target is set, for instance — so the real
724    // config is used when one can be found. A project without one still gets
725    // an accurate listing for the defaults rather than an error.
726    let config = SsgConfig::discover_config_file()
727        .and_then(|path| SsgConfig::from_file(&path).ok())
728        .unwrap_or_default();
729
730    let mut plugins = plugin::PluginManager::new();
731    pipeline::register_default_plugins(&mut plugins, &config, false, target);
732    let inventory = plugins.inventory();
733
734    if json {
735        let rows: Vec<_> = inventory
736            .iter()
737            .map(|p| {
738                serde_json::json!({
739                    "order": p.order,
740                    "name": p.name,
741                    "has_transform": p.has_transform,
742                    "needs_all_files": p.needs_all_files,
743                })
744            })
745            .collect();
746        let doc = serde_json::json!({
747            "count": inventory.len(),
748            "plugins": rows,
749        });
750        println!(
751            "{}",
752            serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".into())
753        );
754        return Ok(());
755    }
756
757    println!("{} plugin(s), in execution order:\n", inventory.len());
758    println!(
759        "  {:>3}  {:<28} {:>9}  {:>9}",
760        "#", "NAME", "TRANSFORM", "ALL-FILES"
761    );
762    for p in &inventory {
763        println!(
764            "  {:>3}  {:<28} {:>9}  {:>9}",
765            p.order,
766            p.name,
767            if p.has_transform { "yes" } else { "-" },
768            if p.needs_all_files { "yes" } else { "-" },
769        );
770    }
771    Ok(())
772}
773
774/// Fault-injectable wrapper around [`logging::initialize_logging`].
775///
776/// `initialize_logging` can never actually fail (it ignores
777/// `log::set_logger` races and always returns `Ok`), so the error
778/// branch of the `?` in [`run_with_argv`] is only reachable through
779/// the `lib::initialize-logging` failpoint under the
780/// `test-fault-injection` feature.
781fn initialize_logging_checked() -> Result<(), SsgError> {
782    fail_point!("lib::initialize-logging", |_| Err(SsgError::Validation {
783        field: "logging".to_string(),
784        message: "injected: lib::initialize-logging".to_string(),
785    }));
786    logging::initialize_logging()
787}
788
789/// Fault-injectable wrapper around [`plugin::PluginManager::run_on_serve`].
790///
791/// None of the default plugins' `on_serve` hooks can be made to fail
792/// from CLI-reachable inputs, so the error branches of the `?` at the
793/// serve call sites in [`run_legacy`] / [`run_subcommand`] are only
794/// reachable through the `lib::run-on-serve` failpoint under the
795/// `test-fault-injection` feature.
796fn run_on_serve_checked(
797    plugins: &plugin::PluginManager,
798    ctx: &plugin::PluginContext,
799) -> Result<(), SsgError> {
800    fail_point!("lib::run-on-serve", |_| Err(SsgError::Validation {
801        field: "serve".to_string(),
802        message: "injected: lib::run-on-serve".to_string(),
803    }));
804    plugins.run_on_serve(ctx)
805}
806
807/// Run handler for the `ssg audit` subcommand (issue #549).
808///
809/// Delegates to [`crate::cmd::audit::run_and_dispatch`], which handles
810/// gate selection, output formatting (text / JSON / `JUnit` XML), and
811/// the `--fail-on` exit-code contract.
812fn run_audit(matches: &clap::ArgMatches) -> Result<(), SsgError> {
813    let sub_m = matches.subcommand_matches("audit").ok_or_else(|| {
814        SsgError::Validation {
815            field: "subcommand".to_string(),
816            message: "missing matches for `audit`".to_string(),
817        }
818    })?;
819    cmd::audit::run_and_dispatch(sub_m, false)
820}
821
822/// Legacy code path: behaves exactly like 0.0.42 `ssg` did.
823fn run_legacy(matches: &clap::ArgMatches) -> Result<(), SsgError> {
824    // `--new NAME` scaffolds a project and stops. The flag has been
825    // declared on this parser since 0.0.42 and was never dispatched:
826    // `ssg --new mysite` parsed it, ignored it, and went on to build the
827    // current directory — failing with "I/O error at 'content'" on a
828    // machine where no project existed yet. A flag that parses and does
829    // nothing is worse than an unknown one, which at least errors.
830    if let Some(name) = matches.get_one::<String>("new") {
831        return scaffold::scaffold_project_at(name, Path::new(".")).map_err(
832            |e| SsgError::Validation {
833                field: "new".to_string(),
834                message: e.to_string(),
835            },
836        );
837    }
838
839    let config =
840        SsgConfig::from_matches(matches).map_err(|e| SsgError::Validation {
841            field: "config".to_string(),
842            message: e.to_string(),
843        })?;
844    let opts = pipeline::RunOptions::from_matches(matches);
845
846    apply_rayon_thread_pool(opts.jobs)?;
847
848    if opts.validate_only {
849        return content::validate_only(&config.content_dir).map_err(|e| {
850            SsgError::Validation {
851                field: "content".to_string(),
852                message: e.to_string(),
853            }
854        });
855    }
856
857    if !opts.quiet {
858        Cli::print_banner();
859    }
860
861    let (plugins, ctx, build_dir, site_dir) =
862        pipeline::build_pipeline(&config, &opts);
863
864    pipeline::execute_build_pipeline_with(
865        &plugins,
866        &ctx,
867        &build_dir,
868        &config.content_dir,
869        &site_dir,
870        &config.template_dir,
871        opts.quiet,
872        opts.incremental,
873    )?;
874
875    // Legacy contract: `--serve` boots the dev server.
876    if config.serve_dir.is_some() {
877        run_on_serve_checked(&plugins, &ctx)?;
878        serve_site(&site_dir)
879    } else {
880        Ok(())
881    }
882}
883
884/// Run handler shared by the `ssg build` and `ssg dev` subcommands.
885///
886/// `start_server` controls whether the dev server is booted after the
887/// build completes.
888fn run_subcommand(
889    matches: &clap::ArgMatches,
890    name: &str,
891    start_server: bool,
892) -> Result<(), SsgError> {
893    let sub_m = matches.subcommand_matches(name).ok_or_else(|| {
894        SsgError::Validation {
895            field: "subcommand".to_string(),
896            message: format!("missing matches for `{name}`"),
897        }
898    })?;
899
900    let config = build_config_from_subcommand_matches(sub_m)?;
901    let opts = pipeline::RunOptions::from_subcommand_matches(sub_m);
902
903    apply_rayon_thread_pool(opts.jobs)?;
904
905    if !opts.quiet {
906        Cli::print_banner();
907    }
908
909    let (plugins, ctx, build_dir, site_dir) =
910        pipeline::build_pipeline(&config, &opts);
911
912    pipeline::execute_build_pipeline_with(
913        &plugins,
914        &ctx,
915        &build_dir,
916        &config.content_dir,
917        &site_dir,
918        &config.template_dir,
919        opts.quiet,
920        opts.incremental,
921    )?;
922
923    if start_server {
924        run_on_serve_checked(&plugins, &ctx)?;
925        serve_site(&site_dir)
926    } else {
927        Ok(())
928    }
929}
930
931/// Run handler for the `ssg check` subcommand (issue #527 AC3).
932///
933/// Runs the full plugin pipeline with `dry_run: true` so plugins know
934/// to skip writes. Exits 0 iff every plugin's validation pass
935/// succeeded.
936fn run_check(matches: &clap::ArgMatches) -> Result<(), SsgError> {
937    let sub_m = matches.subcommand_matches("check").ok_or_else(|| {
938        SsgError::Validation {
939            field: "subcommand".to_string(),
940            message: "missing matches for `check`".to_string(),
941        }
942    })?;
943
944    let config = build_config_from_subcommand_matches(sub_m)?;
945    let opts = pipeline::RunOptions::from_subcommand_matches(sub_m);
946
947    apply_rayon_thread_pool(opts.jobs)?;
948
949    // First, validate content schemas — cheap and catches the largest
950    // class of authoring mistakes before we bother with the rest of
951    // the plugin pipeline.
952    content::validate_only(&config.content_dir).map_err(|e| {
953        SsgError::Validation {
954            field: "content".to_string(),
955            message: e.to_string(),
956        }
957    })?;
958
959    // Run the before_compile hooks under dry_run. These are the hooks
960    // that perform validation (ContentValidationPlugin,
961    // AccessibilityPlugin, SeoPlugin, JsonLdPlugin, CspPlugin). We
962    // deliberately skip after_compile / on_serve — those would write
963    // to disk.
964    let (plugins, ctx, _build_dir, _site_dir) =
965        pipeline::build_pipeline(&config, &opts);
966    let ctx = ctx.with_dry_run(true);
967    plugins.run_before_compile(&ctx)?;
968
969    if !opts.quiet {
970        println!("check: all validators passed");
971    }
972    Ok(())
973}
974
975/// Run handler for the `ssg deploy` subcommand (issue #527 AC4).
976///
977/// Builds the site, then invokes the deploy adapter for the chosen
978/// target. Stubs print a `not yet implemented` message and exit
979/// cleanly.
980fn run_deploy(
981    matches: &clap::ArgMatches,
982    target: &str,
983) -> Result<(), SsgError> {
984    let sub_m = matches.subcommand_matches("deploy").ok_or_else(|| {
985        SsgError::Validation {
986            field: "subcommand".to_string(),
987            message: "missing matches for `deploy`".to_string(),
988        }
989    })?;
990
991    let config = build_config_from_subcommand_matches(sub_m)?;
992    let opts = pipeline::RunOptions::from_subcommand_matches(sub_m);
993
994    apply_rayon_thread_pool(opts.jobs)?;
995
996    if !opts.quiet {
997        Cli::print_banner();
998    }
999
1000    let (plugins, ctx, build_dir, site_dir) =
1001        pipeline::build_pipeline(&config, &opts);
1002
1003    execute_build_pipeline(
1004        &plugins,
1005        &ctx,
1006        &build_dir,
1007        &config.content_dir,
1008        &site_dir,
1009        &config.template_dir,
1010        opts.quiet,
1011    )?;
1012
1013    let target_enum = deploy_adapter::Target::from_cli(target)?;
1014    let adapter = deploy_adapter::adapter_for(target_enum);
1015    if !opts.quiet {
1016        println!("deploy: invoking adapter `{}`", adapter.name());
1017    }
1018    adapter.deploy(&site_dir)
1019}
1020
1021/// Builds an `SsgConfig` from subcommand-style matches. The
1022/// subcommand parser uses the same flag names as the legacy parser
1023/// (`--config`, `--content`, `--output`, `--template`, etc.) but no
1024/// `--new`, so we re-use the existing override machinery.
1025fn build_config_from_subcommand_matches(
1026    sub_m: &clap::ArgMatches,
1027) -> Result<SsgConfig, SsgError> {
1028    SsgConfig::from_subcommand_matches(sub_m).map_err(|e| {
1029        SsgError::Validation {
1030            field: "config".to_string(),
1031            message: e.to_string(),
1032        }
1033    })
1034}
1035
1036/// Helper: configure the global Rayon thread pool from `--jobs`.
1037fn apply_rayon_thread_pool(jobs: Option<usize>) -> Result<(), SsgError> {
1038    if let Some(n) = jobs {
1039        rayon::ThreadPoolBuilder::new()
1040            .num_threads(n)
1041            .build_global()
1042            .map_err(|e| SsgError::Validation {
1043                field: "jobs".to_string(),
1044                message: format!("failed to configure Rayon thread pool: {e}"),
1045            })?;
1046        info!("Rayon thread pool configured with {n} threads");
1047    }
1048    Ok(())
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053    use super::*;
1054    use crate::cmd::Cli;
1055    use crate::logging::{SimpleLogger, DEFAULT_LOG_LEVEL, ENV_LOG_LEVEL};
1056    use crate::pipeline::{
1057        build_pipeline, execute_build_pipeline, resolve_build_and_site_dirs,
1058        RunOptions,
1059    };
1060    use crate::server::build_serve_address;
1061    use log::Log;
1062    use serial_test::serial;
1063    use std::env;
1064    use std::{
1065        fs::{self, File},
1066        path::PathBuf,
1067    };
1068    use tempfile::{tempdir, TempDir};
1069
1070    /// Region-friendly variant-equality check. Compares enum
1071    /// discriminants via `assert_eq!` so no permanently-untaken
1072    /// match-arm region is generated — the `matches!` macro's false
1073    /// arm can never execute in a passing test.
1074    fn assert_same_variant<T>(actual: &T, expected: &T) {
1075        assert_eq!(
1076            std::mem::discriminant(actual),
1077            std::mem::discriminant(expected)
1078        );
1079    }
1080
1081    #[test]
1082    fn test_create_log_file_success() {
1083        let temp_dir = tempdir().unwrap();
1084        let log_file_path = temp_dir.path().join("test.log");
1085
1086        let log_file =
1087            create_log_file(log_file_path.to_str().unwrap()).unwrap();
1088        assert!(log_file.metadata().unwrap().is_file());
1089    }
1090
1091    #[test]
1092    fn test_log_arguments() {
1093        let temp_dir = tempdir().unwrap();
1094        let log_file_path = temp_dir.path().join("args_log.log");
1095        let mut log_file = File::create(&log_file_path).unwrap();
1096
1097        let date = now_iso();
1098        log_arguments(&mut log_file, &date).unwrap();
1099
1100        let log_content = fs::read_to_string(log_file_path).unwrap();
1101        assert!(log_content.contains("process"));
1102    }
1103
1104    #[test]
1105    fn test_create_directories_success() {
1106        let temp_dir = tempdir().unwrap();
1107        let base_path = temp_dir.path().to_path_buf();
1108
1109        let paths = Paths {
1110            site: base_path.join("public"),
1111            content: base_path.join("content"),
1112            build: base_path.join("build"),
1113            template: base_path.join("templates"),
1114        };
1115
1116        create_directories(&paths).unwrap();
1117
1118        // Verify each directory exists
1119        assert!(paths.site.exists());
1120        assert!(paths.content.exists());
1121        assert!(paths.build.exists());
1122        assert!(paths.template.exists());
1123    }
1124
1125    #[cfg(not(target_os = "windows"))] // Unix-only: invalid paths behave differently on Windows
1126    #[test]
1127    fn test_create_directories_failure() {
1128        let invalid_paths = Paths {
1129            site: PathBuf::from("/invalid/site"),
1130            content: PathBuf::from("/invalid/content"),
1131            build: PathBuf::from("/invalid/build"),
1132            template: PathBuf::from("/invalid/template"),
1133        };
1134
1135        let result = create_directories(&invalid_paths);
1136        assert!(result.is_err());
1137    }
1138
1139    #[test]
1140    fn test_copy_dir_all() {
1141        let src_dir = tempdir().unwrap();
1142        let dst_dir = tempdir().unwrap();
1143
1144        let src_file = src_dir.path().join("test_file.txt");
1145        _ = File::create(&src_file).unwrap();
1146
1147        let result = copy_dir_all(src_dir.path(), dst_dir.path());
1148        assert!(result.is_ok());
1149        assert!(dst_dir.path().join("test_file.txt").exists());
1150    }
1151
1152    #[test]
1153    fn test_verify_and_copy_files_success() {
1154        let temp_dir = tempdir().unwrap();
1155        let base_path = temp_dir.path().to_path_buf();
1156
1157        // Create source directory and test file
1158        let src_dir = base_path.join("src");
1159        fs::create_dir_all(&src_dir).unwrap();
1160        let test_file = src_dir.join("test_file.txt");
1161        fs::write(&test_file, "test content").unwrap();
1162
1163        // Create destination directory
1164        let dst_dir = base_path.join("dst");
1165
1166        // Verify and copy files
1167        verify_and_copy_files(&src_dir, &dst_dir).unwrap();
1168
1169        // Verify the file was copied
1170        assert!(dst_dir.join("test_file.txt").exists());
1171    }
1172
1173    #[test]
1174    fn test_verify_and_copy_files_failure() {
1175        let src_dir = PathBuf::from("/invalid/src");
1176        let dst_dir = PathBuf::from("/invalid/dst");
1177
1178        let result = verify_and_copy_files(&src_dir, &dst_dir);
1179        assert!(result.is_err());
1180    }
1181
1182    #[cfg(not(target_os = "windows"))] // Unix-only: invalid paths behave differently on Windows
1183    #[test]
1184    fn test_handle_server_failure() {
1185        let temp_dir = tempdir().unwrap();
1186        let log_file_path = temp_dir.path().join("server_log.log");
1187        let mut log_file = File::create(&log_file_path).unwrap();
1188
1189        let paths = Paths {
1190            site: PathBuf::from("/invalid/site"),
1191            content: PathBuf::from("/invalid/content"),
1192            build: PathBuf::from("/invalid/build"),
1193            template: PathBuf::from("/invalid/template"),
1194        };
1195
1196        let serve_dir = temp_dir.path().join("serve");
1197        let date = now_iso();
1198        let result = handle_server(&mut log_file, &date, &paths, &serve_dir);
1199        assert!(result.is_err());
1200    }
1201
1202    #[test]
1203    fn test_is_safe_path_safe() {
1204        let temp_dir = tempdir().unwrap();
1205        let safe_path = temp_dir.path().to_path_buf().join("safe_path");
1206
1207        // Create the directory
1208        fs::create_dir_all(&safe_path).unwrap();
1209
1210        // Use the absolute path
1211        let absolute_safe_path = safe_path.canonicalize().unwrap();
1212        assert!(is_safe_path(&absolute_safe_path).unwrap());
1213    }
1214
1215    #[cfg(not(target_os = "windows"))] // Unix-only: invalid paths behave differently on Windows
1216    #[test]
1217    fn test_create_directories_partial_failure() {
1218        let temp_dir = tempdir().unwrap();
1219        let valid_path = temp_dir.path().join("valid_dir");
1220        let invalid_path = PathBuf::from("/invalid/path");
1221
1222        let paths = Paths {
1223            site: valid_path,
1224            content: invalid_path,
1225            build: temp_dir.path().join("build"),
1226            template: temp_dir.path().join("template"),
1227        };
1228
1229        let result = create_directories(&paths);
1230        assert!(result.is_err());
1231    }
1232
1233    #[test]
1234    fn test_create_directories_rejects_traversal_in_build() {
1235        // Covers create_directories' line ~508-511 — PathTraversal
1236        // bubble for the build dir.
1237        let tmp = tempdir().unwrap();
1238        let bad = tmp.path().join("..").join("escape-build");
1239        let paths = Paths {
1240            content: tmp.path().join("content"),
1241            build: bad,
1242            site: tmp.path().join("site"),
1243            template: tmp.path().join("template"),
1244        };
1245        let err = create_directories(&paths).unwrap_err();
1246        assert_same_variant(
1247            &err,
1248            &SsgError::PathTraversal {
1249                path: PathBuf::new(),
1250            },
1251        );
1252    }
1253
1254    #[test]
1255    fn test_create_directories_rejects_traversal_in_site() {
1256        // Covers lines ~513-516.
1257        let tmp = tempdir().unwrap();
1258        let bad = tmp.path().join("..").join("escape-site");
1259        let paths = Paths {
1260            content: tmp.path().join("content"),
1261            build: tmp.path().join("build"),
1262            site: bad,
1263            template: tmp.path().join("template"),
1264        };
1265        let err = create_directories(&paths).unwrap_err();
1266        assert_same_variant(
1267            &err,
1268            &SsgError::PathTraversal {
1269                path: PathBuf::new(),
1270            },
1271        );
1272    }
1273
1274    #[test]
1275    fn test_create_directories_rejects_traversal_in_template() {
1276        // Covers lines ~518-521.
1277        let tmp = tempdir().unwrap();
1278        let bad = tmp.path().join("..").join("escape-template");
1279        let paths = Paths {
1280            content: tmp.path().join("content"),
1281            build: tmp.path().join("build"),
1282            site: tmp.path().join("site"),
1283            template: bad,
1284        };
1285        let err = create_directories(&paths).unwrap_err();
1286        assert_same_variant(
1287            &err,
1288            &SsgError::PathTraversal {
1289                path: PathBuf::new(),
1290            },
1291        );
1292    }
1293
1294    #[test]
1295    fn test_copy_dir_all_nested() {
1296        let src_dir = tempdir().unwrap();
1297        let dst_dir = tempdir().unwrap();
1298
1299        let nested_dir = src_dir.path().join("nested_dir");
1300        fs::create_dir(&nested_dir).unwrap();
1301
1302        let nested_file = nested_dir.join("nested_file.txt");
1303        _ = File::create(&nested_file).unwrap();
1304
1305        copy_dir_all(src_dir.path(), dst_dir.path()).unwrap();
1306        assert!(dst_dir.path().join("nested_dir/nested_file.txt").exists());
1307    }
1308
1309    #[test]
1310    fn test_verify_and_copy_files_missing_source() {
1311        let src_path = PathBuf::from("/non_existent_dir");
1312        let dst_dir = tempdir().unwrap();
1313
1314        let result = verify_and_copy_files(&src_path, dst_dir.path());
1315        assert!(result.is_err());
1316    }
1317
1318    #[test]
1319    fn test_handle_server_missing_serve_dir() {
1320        let temp_dir = tempdir().unwrap();
1321        let log_file_path = temp_dir.path().join("server_log.log");
1322        let mut log_file = File::create(&log_file_path).unwrap();
1323
1324        let paths = Paths {
1325            site: temp_dir.path().join("site"),
1326            content: temp_dir.path().join("content"),
1327            build: temp_dir.path().join("build"),
1328            template: temp_dir.path().join("template"),
1329        };
1330
1331        let non_existent_serve_dir = PathBuf::from("/non_existent_serve_dir");
1332        let binding = now_iso();
1333        let result = handle_server(
1334            &mut log_file,
1335            &binding,
1336            &paths,
1337            &non_existent_serve_dir,
1338        );
1339        assert!(result.is_err());
1340    }
1341
1342    #[test]
1343    fn test_collect_files_recursive_empty() {
1344        let temp_dir = tempdir().unwrap();
1345        let mut files = Vec::new();
1346
1347        collect_files_recursive(temp_dir.path(), &mut files).unwrap();
1348        assert!(files.is_empty(), "{:?}", files);
1349    }
1350
1351    #[test]
1352    fn test_print_banner() {
1353        // Simply call the function to ensure it runs without errors.
1354        Cli::print_banner();
1355    }
1356
1357    #[test]
1358    fn test_collect_files_recursive_with_nested_directories() {
1359        let temp_dir = tempdir().unwrap();
1360        let nested_dir = temp_dir.path().join("nested_dir");
1361        fs::create_dir(&nested_dir).unwrap();
1362
1363        let nested_file = nested_dir.join("nested_file.txt");
1364        _ = File::create(&nested_file).unwrap();
1365
1366        let mut files = Vec::new();
1367        collect_files_recursive(temp_dir.path(), &mut files).unwrap();
1368
1369        assert!(files.contains(&nested_file));
1370        assert_eq!(files.len(), 1);
1371    }
1372
1373    #[test]
1374    fn test_handle_server_start_message() {
1375        let temp_dir = tempdir().unwrap();
1376        let log_file_path = temp_dir.path().join("server_log.log");
1377        let mut log_file = File::create(&log_file_path).unwrap();
1378
1379        let paths = Paths {
1380            site: temp_dir.path().join("site"),
1381            content: temp_dir.path().join("content"),
1382            build: temp_dir.path().join("build"),
1383            template: temp_dir.path().join("template"),
1384        };
1385
1386        let serve_dir = temp_dir.path().join("serve");
1387
1388        // Check setup conditions before calling `handle_server`
1389        fs::create_dir_all(&serve_dir).unwrap();
1390        assert!(serve_dir.exists(), "Expected serve directory to be created");
1391
1392        // Now, call `handle_server` and check for specific output or error
1393        let date = now_iso();
1394        let result = handle_server(&mut log_file, &date, &paths, &serve_dir);
1395        assert!(
1396            result.is_err(),
1397            "Expected handle_server to fail without valid setup"
1398        );
1399    }
1400
1401    #[cfg(any(unix, windows))]
1402    #[test]
1403    fn test_verify_file_safety_symlink() {
1404        let temp_dir = tempdir().unwrap();
1405        let file_path = temp_dir.path().join("test.txt");
1406        let symlink_path = temp_dir.path().join("test_link.txt");
1407
1408        // Create a regular file
1409        fs::write(&file_path, "test content").unwrap();
1410
1411        // Create a symlink
1412        #[cfg(unix)]
1413        std::os::unix::fs::symlink(&file_path, &symlink_path).unwrap();
1414        #[cfg(windows)]
1415        std::os::windows::fs::symlink_file(&file_path, &symlink_path).unwrap();
1416
1417        // Debug output
1418        println!("File exists: {}", file_path.exists());
1419        println!("Symlink exists: {}", symlink_path.exists());
1420        println!(
1421            "Is symlink: {}",
1422            symlink_path
1423                .symlink_metadata()
1424                .unwrap()
1425                .file_type()
1426                .is_symlink()
1427        );
1428
1429        // Try to verify the symlink
1430        let result = verify_file_safety(&symlink_path);
1431
1432        // Print the result for debugging
1433        println!("Result: {result:?}");
1434
1435        // Verify that we got an error
1436        assert!(result.is_err(), "Expected error for symlink, got success");
1437
1438        // Verify the error message
1439        let err = result.unwrap_err();
1440        println!("Error message: {err}");
1441        assert!(
1442            matches!(err, SsgError::SymlinkForbidden { ref path } if path == &symlink_path),
1443            "expected SsgError::SymlinkForbidden, got: {err:?}"
1444        );
1445    }
1446
1447    #[test]
1448    fn test_verify_file_safety_size() {
1449        let temp_dir = tempdir().unwrap();
1450        let large_file_path = temp_dir.path().join("large.txt");
1451
1452        // Create a large file
1453        let file = File::create(&large_file_path).unwrap();
1454        file.set_len(11 * 1024 * 1024).unwrap(); // 11MB
1455
1456        let result = verify_file_safety(&large_file_path);
1457        assert!(result.is_err(), "Expected error, got: {result:?}");
1458    }
1459
1460    #[test]
1461    fn test_verify_file_safety_regular() {
1462        let temp_dir = tempdir().unwrap();
1463        let file_path = temp_dir.path().join("regular.txt");
1464
1465        // Create a regular file
1466        fs::write(&file_path, "test content").unwrap();
1467
1468        assert!(verify_file_safety(&file_path).is_ok());
1469    }
1470
1471    /// Tests successful copying of an empty directory
1472    #[test]
1473    fn test_copy_empty_directory_async() {
1474        let src_dir = tempdir().unwrap();
1475        let dst_dir = tempdir().unwrap();
1476
1477        let result = copy_dir_all_async(src_dir.path(), dst_dir.path());
1478        assert!(result.is_ok());
1479
1480        // Verify destination directory exists
1481        assert!(dst_dir.path().exists());
1482    }
1483
1484    /// Tests copying a directory with a single file
1485    #[test]
1486    fn test_copy_single_file_async() {
1487        let src_dir = tempdir().unwrap();
1488        let dst_dir = tempdir().unwrap();
1489
1490        // Create a test file
1491        let test_file = src_dir.path().join("test.txt");
1492        fs::write(&test_file, "test content").unwrap();
1493
1494        copy_dir_all_async(src_dir.path(), dst_dir.path()).unwrap();
1495
1496        // Verify file was copied
1497        let copied_file = dst_dir.path().join("test.txt");
1498        assert!(copied_file.exists());
1499        assert_eq!(fs::read_to_string(copied_file).unwrap(), "test content");
1500    }
1501
1502    /// Tests copying a directory with nested subdirectories
1503    #[test]
1504    fn test_copy_nested_directories_async() {
1505        let src_dir = tempdir().unwrap();
1506        let dst_dir = tempdir().unwrap();
1507
1508        // Create nested directory structure
1509        let nested_dir = src_dir.path().join("nested");
1510        fs::create_dir(&nested_dir).unwrap();
1511
1512        // Create files in both root and nested directory
1513        fs::write(src_dir.path().join("root.txt"), "root content").unwrap();
1514        fs::write(nested_dir.join("nested.txt"), "nested content").unwrap();
1515
1516        copy_dir_all_async(src_dir.path(), dst_dir.path()).unwrap();
1517
1518        // Verify directory structure and contents
1519        assert!(dst_dir.path().join("nested").exists());
1520        assert!(dst_dir.path().join("root.txt").exists());
1521        assert!(dst_dir.path().join("nested/nested.txt").exists());
1522
1523        assert_eq!(
1524            fs::read_to_string(dst_dir.path().join("root.txt")).unwrap(),
1525            "root content"
1526        );
1527        assert_eq!(
1528            fs::read_to_string(dst_dir.path().join("nested/nested.txt"))
1529                .unwrap(),
1530            "nested content"
1531        );
1532    }
1533
1534    /// Tests handling of symlinks
1535    #[test]
1536    fn test_copy_with_symlink_async() {
1537        let src_dir = tempdir().unwrap();
1538        let dst_dir = tempdir().unwrap();
1539
1540        // Create a regular file
1541        let file_path = src_dir.path().join("original.txt");
1542        fs::write(&file_path, "original content").unwrap();
1543
1544        // Create a symlink
1545        #[cfg(unix)]
1546        {
1547            use std::os::unix::fs::symlink;
1548            let symlink_path = src_dir.path().join("link.txt");
1549            symlink(&file_path, &symlink_path).unwrap();
1550        }
1551        #[cfg(windows)]
1552        {
1553            use std::os::windows::fs::symlink_file;
1554            let symlink_path = src_dir.path().join("link.txt");
1555            symlink_file(&file_path, &symlink_path).unwrap();
1556        }
1557
1558        // Attempt to copy - should fail due to symlink
1559        let result = copy_dir_all_async(src_dir.path(), dst_dir.path());
1560        assert!(result.is_err());
1561    }
1562
1563    /// Tests copying large files
1564    #[test]
1565    fn test_copy_large_file_async() {
1566        let src_dir = tempdir().unwrap();
1567        let dst_dir = tempdir().unwrap();
1568
1569        // Create a large file (11MB)
1570        let large_file = src_dir.path().join("large.txt");
1571        let file = File::create(&large_file).unwrap();
1572        file.set_len(11 * 1024 * 1024).unwrap();
1573
1574        // Attempt to copy - should fail due to file size limit
1575        let result = copy_dir_all_async(src_dir.path(), dst_dir.path());
1576        assert!(result.is_err());
1577    }
1578
1579    /// Tests copying with invalid destination
1580    #[cfg(not(target_os = "windows"))] // Unix-only: invalid paths behave differently on Windows
1581    #[test]
1582    fn test_copy_invalid_destination_async() {
1583        let src_dir = tempdir().unwrap();
1584        let invalid_dst = PathBuf::from("/nonexistent/path");
1585
1586        let result = copy_dir_all_async(src_dir.path(), &invalid_dst);
1587        assert!(result.is_err());
1588    }
1589
1590    /// Tests concurrent copying of multiple files
1591    #[test]
1592    fn test_concurrent_copy_async() {
1593        let src_dir = tempdir().unwrap();
1594        let dst_dir = tempdir().unwrap();
1595
1596        // Create multiple files
1597        for i in 0..5 {
1598            fs::write(
1599                src_dir.path().join(format!("file{i}.txt")),
1600                format!("content {i}"),
1601            )
1602            .unwrap();
1603        }
1604
1605        copy_dir_all_async(src_dir.path(), dst_dir.path()).unwrap();
1606
1607        // Verify all files were copied
1608        for i in 0..5 {
1609            let copied_file = dst_dir.path().join(format!("file{i}.txt"));
1610            assert!(copied_file.exists());
1611            assert_eq!(
1612                fs::read_to_string(copied_file).unwrap(),
1613                format!("content {i}")
1614            );
1615        }
1616    }
1617
1618    /// Tests copying with maximum directory depth
1619    #[test]
1620    fn test_max_directory_depth_async() {
1621        let src_dir = tempdir().unwrap();
1622        let dst_dir = tempdir().unwrap();
1623        let max_depth = 5;
1624
1625        // Create deeply nested directory structure
1626        let mut current_dir = src_dir.path().to_path_buf();
1627        for i in 0..max_depth {
1628            current_dir = current_dir.join(format!("level{i}"));
1629            fs::create_dir(&current_dir).unwrap();
1630            fs::write(
1631                current_dir.join("file.txt"),
1632                format!("content level {i}"),
1633            )
1634            .unwrap();
1635        }
1636
1637        copy_dir_all_async(src_dir.path(), dst_dir.path()).unwrap();
1638
1639        // Verify the entire structure was copied
1640        current_dir = dst_dir.path().to_path_buf();
1641        for i in 0..max_depth {
1642            current_dir = current_dir.join(format!("level{i}"));
1643            assert!(current_dir.exists());
1644            assert!(current_dir.join("file.txt").exists());
1645            assert_eq!(
1646                fs::read_to_string(current_dir.join("file.txt")).unwrap(),
1647                format!("content level {i}")
1648            );
1649        }
1650    }
1651
1652    #[test]
1653    fn test_verify_and_copy_files_async_missing_source() {
1654        let temp_dir = tempdir().unwrap();
1655        let src_dir = temp_dir.path().join("nonexistent");
1656        let dst_dir = temp_dir.path().join("dst");
1657
1658        let error = verify_and_copy_files_async(&src_dir, &dst_dir)
1659            .unwrap_err()
1660            .to_string();
1661
1662        assert!(
1663            error.contains("does not exist"),
1664            "Expected error message about non-existent source, got: {error}"
1665        );
1666    }
1667
1668    #[test]
1669    fn test_paths_builder_default() {
1670        let paths = Paths::builder().build().unwrap();
1671        assert_eq!(paths.site, PathBuf::from("public"));
1672        assert_eq!(paths.content, PathBuf::from("content"));
1673        assert_eq!(paths.build, PathBuf::from("build"));
1674        assert_eq!(paths.template, PathBuf::from("templates"));
1675    }
1676
1677    #[test]
1678    fn test_resolve_build_and_site_dirs_without_serve_dir() {
1679        let mut config = SsgConfig::default();
1680        config.output_dir = PathBuf::from("docs");
1681        config.serve_dir = None;
1682
1683        let (build_dir, site_dir) = resolve_build_and_site_dirs(&config);
1684
1685        assert_eq!(site_dir, PathBuf::from("docs"));
1686        assert_eq!(build_dir, PathBuf::from("docs.build-tmp"));
1687        assert_ne!(build_dir, site_dir);
1688    }
1689
1690    #[test]
1691    fn test_resolve_build_and_site_dirs_with_distinct_serve_dir() {
1692        let mut config = SsgConfig::default();
1693        config.output_dir = PathBuf::from("docs");
1694        config.serve_dir = Some(PathBuf::from("public"));
1695
1696        let (build_dir, site_dir) = resolve_build_and_site_dirs(&config);
1697
1698        assert_eq!(build_dir, PathBuf::from("docs"));
1699        assert_eq!(site_dir, PathBuf::from("public"));
1700        assert_ne!(build_dir, site_dir);
1701    }
1702
1703    #[test]
1704    fn test_resolve_build_and_site_dirs_with_same_serve_and_output_dir() {
1705        let mut config = SsgConfig::default();
1706        config.output_dir = PathBuf::from("docs");
1707        config.serve_dir = Some(PathBuf::from("docs"));
1708
1709        let (build_dir, site_dir) = resolve_build_and_site_dirs(&config);
1710
1711        assert_eq!(site_dir, PathBuf::from("docs"));
1712        assert_eq!(build_dir, PathBuf::from("docs.build-tmp"));
1713        assert_ne!(build_dir, site_dir);
1714    }
1715
1716    #[test]
1717    fn test_paths_builder_custom() {
1718        let temp_dir = tempdir().unwrap();
1719        let paths = Paths::builder()
1720            .site(temp_dir.path().join("custom_public"))
1721            .content(temp_dir.path().join("custom_content"))
1722            .build_dir(temp_dir.path().join("custom_build"))
1723            .template(temp_dir.path().join("custom_templates"))
1724            .build()
1725            .unwrap();
1726
1727        assert_eq!(paths.site, temp_dir.path().join("custom_public"));
1728        assert_eq!(paths.content, temp_dir.path().join("custom_content"));
1729        assert_eq!(paths.build, temp_dir.path().join("custom_build"));
1730        assert_eq!(paths.template, temp_dir.path().join("custom_templates"));
1731    }
1732
1733    #[test]
1734    fn test_paths_builder_relative() {
1735        let temp_dir = tempdir().unwrap();
1736
1737        // Create the directories first
1738        fs::create_dir_all(temp_dir.path().join("public")).unwrap();
1739        fs::create_dir_all(temp_dir.path().join("content")).unwrap();
1740        fs::create_dir_all(temp_dir.path().join("build")).unwrap();
1741        fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
1742
1743        let paths = Paths::builder()
1744            .relative_to(temp_dir.path())
1745            .build()
1746            .unwrap();
1747
1748        assert_eq!(paths.site, temp_dir.path().join("public"));
1749        assert_eq!(paths.content, temp_dir.path().join("content"));
1750        assert_eq!(paths.build, temp_dir.path().join("build"));
1751        assert_eq!(paths.template, temp_dir.path().join("templates"));
1752    }
1753
1754    #[test]
1755    fn test_paths_validation() {
1756        // Test directory traversal
1757        let err = Paths::builder().site("../invalid").build().unwrap_err();
1758        assert_same_variant(
1759            &err,
1760            &SsgError::PathTraversal {
1761                path: PathBuf::new(),
1762            },
1763        );
1764
1765        // Test double slashes
1766        let err = Paths::builder().site("invalid//path").build().unwrap_err();
1767        assert_same_variant(
1768            &err,
1769            &SsgError::Validation {
1770                field: String::new(),
1771                message: String::new(),
1772            },
1773        );
1774
1775        // Test symlinks if possible
1776        #[cfg(unix)]
1777        {
1778            use std::os::unix::fs::symlink;
1779            let temp_dir = tempdir().unwrap();
1780            let real_path = temp_dir.path().join("real");
1781            let symlink_path = temp_dir.path().join("symlink");
1782
1783            fs::create_dir(&real_path).unwrap();
1784            symlink(&real_path, &symlink_path).unwrap();
1785
1786            let err = Paths::builder().site(symlink_path).build().unwrap_err();
1787            assert_same_variant(
1788                &err,
1789                &SsgError::SymlinkForbidden {
1790                    path: PathBuf::new(),
1791                },
1792            );
1793        }
1794    }
1795
1796    #[test]
1797    fn test_paths_default_paths() {
1798        let paths = Paths::default_paths();
1799        assert_eq!(paths.site, PathBuf::from("public"));
1800        assert_eq!(paths.content, PathBuf::from("content"));
1801        assert_eq!(paths.build, PathBuf::from("build"));
1802        assert_eq!(paths.template, PathBuf::from("templates"));
1803    }
1804
1805    // Add a new test for non-existent but valid paths
1806    #[test]
1807    fn test_paths_nonexistent_valid() {
1808        let temp_dir = tempdir().unwrap();
1809        let valid_path = temp_dir.path().join("new_directory");
1810
1811        let paths = Paths::builder().site(valid_path.clone()).build().unwrap();
1812
1813        assert_eq!(paths.site, valid_path);
1814    }
1815
1816    #[test]
1817    #[serial(env_log)]
1818    fn test_initialize_logging_with_custom_level() {
1819        env::set_var(ENV_LOG_LEVEL, "debug");
1820        assert!(logging::initialize_logging().is_ok());
1821        env::remove_var(ENV_LOG_LEVEL);
1822    }
1823
1824    #[test]
1825    fn test_paths_builder_with_all_invalid_paths() {
1826        let result = Paths::builder()
1827            .site("../invalid")
1828            .content("content//invalid")
1829            .build_dir("build/../invalid")
1830            .template("template//invalid")
1831            .build();
1832
1833        assert!(result.is_err());
1834    }
1835
1836    #[test]
1837    fn test_paths_builder_clone() {
1838        let builder = PathsBuilder::default();
1839        let cloned = builder;
1840        assert!(cloned.site.is_none());
1841        assert!(cloned.content.is_none());
1842        assert!(cloned.build.is_none());
1843        assert!(cloned.template.is_none());
1844    }
1845
1846    #[test]
1847    fn test_paths_clone() {
1848        let paths = Paths::default_paths();
1849        let cloned = paths.clone();
1850
1851        assert_eq!(paths.site, cloned.site);
1852        assert_eq!(paths.content, cloned.content);
1853        assert_eq!(paths.build, cloned.build);
1854        assert_eq!(paths.template, cloned.template);
1855    }
1856
1857    #[test]
1858    fn test_async_copy_with_empty_source() {
1859        let temp_dir = tempdir().unwrap();
1860        let src_dir = temp_dir.path().join("empty_src");
1861        let dst_dir = temp_dir.path().join("empty_dst");
1862
1863        fs::create_dir(&src_dir).unwrap();
1864
1865        let result = verify_and_copy_files_async(&src_dir, &dst_dir);
1866        assert!(result.is_ok());
1867        assert!(dst_dir.exists());
1868    }
1869
1870    #[test]
1871    fn test_paths_validation_all_aspects() {
1872        let temp_dir = tempdir().unwrap();
1873
1874        // Test with absolute paths
1875        let result = Paths::builder()
1876            .site(temp_dir.path().join("site"))
1877            .content(temp_dir.path().join("content"))
1878            .build_dir(temp_dir.path().join("build"))
1879            .template(temp_dir.path().join("template"))
1880            .build();
1881
1882        assert!(result.is_ok());
1883
1884        // Test with multiple validation issues
1885        let result = Paths::builder()
1886            .site("../site")
1887            .content("content//test")
1888            .build_dir("build/../../test")
1889            .template("template//test")
1890            .build();
1891
1892        assert!(result.is_err());
1893    }
1894
1895    #[test]
1896    fn test_log_initialization_with_empty_log_file() {
1897        let temp_dir = tempdir().unwrap();
1898        let log_path = temp_dir.path().join("empty.log");
1899        let mut log_file = File::create(&log_path).unwrap();
1900
1901        let date = now_iso();
1902        log_initialization(&mut log_file, &date).unwrap();
1903
1904        let content = fs::read_to_string(&log_path).unwrap();
1905        assert!(!content.is_empty(), "should not be empty");
1906        assert!(content.contains("process"));
1907    }
1908
1909    #[test]
1910    fn test_verify_and_copy_files_async_with_nested_empty_dirs() {
1911        let temp_dir = tempdir().unwrap();
1912        let src_dir = temp_dir.path().join("src");
1913        let dst_dir = temp_dir.path().join("dst");
1914
1915        // Create nested empty directory structure
1916        fs::create_dir_all(src_dir.join("a/b/c")).unwrap();
1917        fs::create_dir_all(src_dir.join("d/e/f")).unwrap();
1918
1919        verify_and_copy_files_async(&src_dir, &dst_dir).unwrap();
1920
1921        assert!(dst_dir.join("a/b/c").exists());
1922        assert!(dst_dir.join("d/e/f").exists());
1923    }
1924
1925    #[test]
1926    fn test_validate_nonexistent_paths() {
1927        let paths = Paths {
1928            site: PathBuf::from("nonexistent/site"),
1929            content: PathBuf::from("nonexistent/content"),
1930            build: PathBuf::from("nonexistent/build"),
1931            template: PathBuf::from("nonexistent/template"),
1932        };
1933
1934        // Non-existent paths should be valid if they don't contain unsafe patterns
1935        assert!(paths.validate().is_ok());
1936    }
1937
1938    #[test]
1939    fn test_copy_dir_all_async_with_empty_dirs() {
1940        let temp_dir = tempdir().unwrap();
1941        let src_dir = temp_dir.path().join("src");
1942        let dst_dir = temp_dir.path().join("dst");
1943
1944        fs::create_dir_all(src_dir.join("empty1")).unwrap();
1945        fs::create_dir_all(src_dir.join("empty2/empty3")).unwrap();
1946
1947        copy_dir_all_async(&src_dir, &dst_dir).unwrap();
1948
1949        assert!(dst_dir.join("empty1").exists());
1950        assert!(dst_dir.join("empty2/empty3").exists());
1951    }
1952
1953    #[test]
1954    #[serial(env_log)]
1955    fn test_log_level_from_env() {
1956        // Seed the variable so the restore branch at the end of the
1957        // test always executes, then save the current value.
1958        env::set_var(ENV_LOG_LEVEL, "info");
1959        let original_value = env::var(ENV_LOG_LEVEL).ok();
1960
1961        // Helper function to get processed log level
1962        fn get_processed_log_level() -> String {
1963            let log_level = env::var(ENV_LOG_LEVEL)
1964                .unwrap_or_else(|_| DEFAULT_LOG_LEVEL.to_string());
1965
1966            match log_level.to_lowercase().as_str() {
1967                "error" => "error",
1968                "warn" => "warn",
1969                "info" => "info",
1970                "debug" => "debug",
1971                "trace" => "trace",
1972                _ => "info", // Default to info for invalid values
1973            }
1974            .to_string()
1975        }
1976
1977        // Test various log level settings
1978        let test_levels = vec![
1979            ("DEBUG", "debug"),
1980            ("ERROR", "error"),
1981            ("WARN", "warn"),
1982            ("INFO", "info"),
1983            ("TRACE", "trace"),
1984            ("INVALID", "info"), // Should default to info
1985        ];
1986
1987        for (input, expected) in test_levels {
1988            env::set_var(ENV_LOG_LEVEL, input);
1989            let processed_level = get_processed_log_level();
1990            assert_eq!(
1991                processed_level, expected,
1992                "Expected log level '{expected}' for input '{input}', but got '{processed_level}'"
1993            );
1994        }
1995
1996        // With the variable unset, the fallback closure supplies the
1997        // default level.
1998        env::remove_var(ENV_LOG_LEVEL);
1999        assert_eq!(get_processed_log_level(), DEFAULT_LOG_LEVEL);
2000
2001        // Restore the original environment variable state
2002        env::remove_var(ENV_LOG_LEVEL);
2003        if let Some(value) = original_value {
2004            env::set_var(ENV_LOG_LEVEL, value);
2005        }
2006    }
2007
2008    /// Test for default log level when environment variable is not set
2009    #[test]
2010    #[serial(env_log)]
2011    fn test_default_log_level() {
2012        // Seed the variable so the restore branch at the end of the
2013        // test always executes, then save the current value.
2014        env::set_var(ENV_LOG_LEVEL, "info");
2015        let original_value = env::var(ENV_LOG_LEVEL).ok();
2016        env::remove_var(ENV_LOG_LEVEL);
2017
2018        let log_level = env::var(ENV_LOG_LEVEL)
2019            .unwrap_or_else(|_| DEFAULT_LOG_LEVEL.to_string())
2020            .to_lowercase();
2021        assert_eq!(log_level, DEFAULT_LOG_LEVEL.to_lowercase());
2022
2023        env::remove_var(ENV_LOG_LEVEL);
2024        if let Some(value) = original_value {
2025            env::set_var(ENV_LOG_LEVEL, value);
2026        }
2027    }
2028
2029    /// Test the logic for translating string log levels to `LevelFilter` values
2030    #[test]
2031    fn test_log_level_translation() {
2032        use log::LevelFilter;
2033        let test_cases = vec![
2034            ("error", LevelFilter::Error),
2035            ("warn", LevelFilter::Warn),
2036            ("info", LevelFilter::Info),
2037            ("debug", LevelFilter::Debug),
2038            ("trace", LevelFilter::Trace),
2039            ("invalid", LevelFilter::Info),
2040            ("", LevelFilter::Info),
2041        ];
2042
2043        for (input, expected) in test_cases {
2044            let level = match input.to_lowercase().as_str() {
2045                "error" => LevelFilter::Error,
2046                "warn" => LevelFilter::Warn,
2047                "info" => LevelFilter::Info,
2048                "debug" => LevelFilter::Debug,
2049                "trace" => LevelFilter::Trace,
2050                _ => LevelFilter::Info,
2051            };
2052
2053            assert_eq!(
2054                level, expected,
2055                "Log level mismatch for input: '{input}' - expected {expected:?}, got {level:?}"
2056            );
2057        }
2058    }
2059
2060    /// Test environment variable handling with cleanup
2061    #[test]
2062    #[serial(env_log)]
2063    fn test_env_log_level_handling() {
2064        // Seed the variable so the restore branch at the end of the
2065        // test always executes, then save the original state.
2066        env::set_var(ENV_LOG_LEVEL, "info");
2067        let original_value = env::var(ENV_LOG_LEVEL).ok();
2068
2069        let test_cases = vec![
2070            (Some("DEBUG"), "debug"),
2071            (Some("ERROR"), "error"),
2072            (Some("WARN"), "warn"),
2073            (Some("INFO"), "info"),
2074            (Some("TRACE"), "trace"),
2075            (Some("INVALID"), "info"),
2076            (None, "info"),
2077        ];
2078
2079        for (env_value, expected) in test_cases {
2080            // Clear any existing env var
2081            env::remove_var(ENV_LOG_LEVEL);
2082
2083            // Set new value if provided
2084            if let Some(value) = env_value {
2085                env::set_var(ENV_LOG_LEVEL, value);
2086            }
2087
2088            let log_level = env::var(ENV_LOG_LEVEL)
2089                .unwrap_or_else(|_| DEFAULT_LOG_LEVEL.to_string())
2090                .to_lowercase();
2091
2092            let actual = match log_level.as_str() {
2093                "error" => "error",
2094                "warn" => "warn",
2095                "info" => "info",
2096                "debug" => "debug",
2097                "trace" => "trace",
2098                _ => "info",
2099            };
2100
2101            assert_eq!(
2102                actual, expected,
2103                "Log level mismatch for env value: {env_value:?}"
2104            );
2105        }
2106
2107        // Restore original state
2108        env::remove_var(ENV_LOG_LEVEL);
2109        if let Some(value) = original_value {
2110            env::set_var(ENV_LOG_LEVEL, value);
2111        }
2112    }
2113
2114    #[test]
2115    fn test_initialize_logging_custom_levels() {
2116        // Verify that the expected log level strings are valid
2117        let valid_levels = ["debug", "warn", "error", "trace", "info"];
2118        for level in &valid_levels {
2119            assert!(
2120                ["trace", "debug", "info", "warn", "error"].contains(level),
2121                "unexpected log level: {level}"
2122            );
2123        }
2124        // Verify our default is valid
2125        assert!(["trace", "debug", "info", "warn", "error"]
2126            .contains(&DEFAULT_LOG_LEVEL),);
2127    }
2128
2129    #[test]
2130    fn parse_log_level_recognises_all_supported_levels() {
2131        use log::LevelFilter;
2132        assert_eq!(logging::parse_log_level("error"), LevelFilter::Error);
2133        assert_eq!(logging::parse_log_level("warn"), LevelFilter::Warn);
2134        assert_eq!(logging::parse_log_level("info"), LevelFilter::Info);
2135        assert_eq!(logging::parse_log_level("debug"), LevelFilter::Debug);
2136        assert_eq!(logging::parse_log_level("trace"), LevelFilter::Trace);
2137    }
2138
2139    #[test]
2140    fn parse_log_level_is_case_insensitive() {
2141        use log::LevelFilter;
2142        assert_eq!(logging::parse_log_level("ERROR"), LevelFilter::Error);
2143        assert_eq!(logging::parse_log_level("Warn"), LevelFilter::Warn);
2144        assert_eq!(logging::parse_log_level("TraCe"), LevelFilter::Trace);
2145    }
2146
2147    #[test]
2148    fn parse_log_level_unknown_value_falls_back_to_info() {
2149        use log::LevelFilter;
2150        assert_eq!(logging::parse_log_level("nonsense"), LevelFilter::Info);
2151        assert_eq!(logging::parse_log_level(""), LevelFilter::Info);
2152        assert_eq!(logging::parse_log_level("verbose"), LevelFilter::Info);
2153    }
2154
2155    #[test]
2156    fn test_concurrent_operations() {
2157        let temp_dir = TempDir::new().unwrap();
2158        let src_dir = temp_dir.path().join("src");
2159        let dst_dir = temp_dir.path().join("dst");
2160
2161        // Create source directory
2162        fs::create_dir_all(&src_dir).unwrap();
2163
2164        // Create files
2165        for i in 0..100 {
2166            fs::write(
2167                src_dir.join(format!("file_{i}.txt")),
2168                format!("content {i}"),
2169            )
2170            .unwrap();
2171        }
2172
2173        // Verify source files
2174        let mut src_files = Vec::new();
2175        collect_files_recursive(&src_dir, &mut src_files).unwrap();
2176        assert_eq!(src_files.len(), 100);
2177
2178        // Create destination directory
2179        fs::create_dir_all(&dst_dir).unwrap();
2180
2181        // Copy files using verify_and_copy_files
2182        verify_and_copy_files(&src_dir, &dst_dir).unwrap();
2183
2184        // Verify destination files
2185        let mut dst_files = Vec::new();
2186        collect_files_recursive(&dst_dir, &mut dst_files).unwrap();
2187
2188        assert_eq!(dst_files.len(), 100);
2189
2190        // Verify file contents
2191        for i in 0..100 {
2192            let dst_path = dst_dir.join(format!("file_{i}.txt"));
2193            assert!(dst_path.exists());
2194
2195            let content = fs::read_to_string(&dst_path).unwrap();
2196            assert_eq!(
2197                content,
2198                format!("content {i}"),
2199                "Content mismatch for file {}",
2200                i
2201            );
2202        }
2203    }
2204
2205    #[test]
2206    fn test_verify_and_copy_files_basic() {
2207        let temp_dir = TempDir::new().unwrap();
2208        let src_dir = temp_dir.path().join("src");
2209        let dst_dir = temp_dir.path().join("dst");
2210
2211        fs::create_dir_all(&src_dir).unwrap();
2212
2213        // Create a test file
2214        fs::write(src_dir.join("test.txt"), "test content").unwrap();
2215
2216        // Copy files
2217        verify_and_copy_files(&src_dir, &dst_dir).unwrap();
2218
2219        // Verify file was copied
2220        assert!(dst_dir.join("test.txt").exists());
2221        assert_eq!(
2222            fs::read_to_string(dst_dir.join("test.txt")).unwrap(),
2223            "test content"
2224        );
2225    }
2226
2227    #[test]
2228    fn test_copy_dir_with_progress_empty_source() {
2229        let src_dir = tempdir().unwrap();
2230        let dst_dir = tempdir().unwrap();
2231
2232        // Call the function with an empty source directory
2233        copy_dir_with_progress(src_dir.path(), dst_dir.path()).unwrap();
2234
2235        // Verify that the destination directory exists and is empty
2236        assert!(dst_dir.path().exists());
2237        assert!(fs::read_dir(dst_dir.path()).unwrap().next().is_none());
2238    }
2239
2240    #[test]
2241    fn test_copy_dir_with_progress_source_does_not_exist() {
2242        let src_dir = Path::new("/nonexistent");
2243        let dst_dir = tempdir().unwrap();
2244
2245        let result = copy_dir_with_progress(src_dir, dst_dir.path());
2246        assert!(result.is_err());
2247    }
2248
2249    #[test]
2250    fn test_copy_dir_with_progress_single_file() {
2251        let src_dir = tempdir().unwrap();
2252        let dst_dir = tempdir().unwrap();
2253
2254        fs::write(src_dir.path().join("file1.txt"), "content").unwrap();
2255
2256        copy_dir_with_progress(src_dir.path(), dst_dir.path()).unwrap();
2257
2258        let copied_file = dst_dir.path().join("file1.txt");
2259        assert!(copied_file.exists());
2260        assert_eq!(fs::read_to_string(copied_file).unwrap(), "content");
2261    }
2262
2263    #[test]
2264    fn test_copy_dir_with_progress_nested_directories() {
2265        let src_dir = tempdir().unwrap();
2266        let dst_dir = tempdir().unwrap();
2267
2268        let nested_dir = src_dir.path().join("nested");
2269        fs::create_dir(&nested_dir).unwrap();
2270        fs::write(nested_dir.join("file.txt"), "nested content").unwrap();
2271
2272        copy_dir_with_progress(src_dir.path(), dst_dir.path()).unwrap();
2273
2274        let copied_nested_file = dst_dir.path().join("nested/file.txt");
2275        assert!(copied_nested_file.exists());
2276        assert_eq!(
2277            fs::read_to_string(copied_nested_file).unwrap(),
2278            "nested content"
2279        );
2280    }
2281
2282    #[cfg(not(target_os = "windows"))] // Unix-only: invalid paths behave differently on Windows
2283    #[test]
2284    fn test_copy_dir_with_progress_destination_creation_failure() {
2285        let src_dir = tempdir().unwrap();
2286        let dst_dir = Path::new("/invalid_path");
2287
2288        let result = copy_dir_with_progress(src_dir.path(), dst_dir);
2289        assert!(result.is_err());
2290    }
2291
2292    #[test]
2293    fn test_verify_and_copy_files_single_file() {
2294        let temp_dir = tempdir().unwrap();
2295        let src_file = temp_dir.path().join("single.txt");
2296        fs::write(&src_file, "content").unwrap();
2297        let dst_dir = temp_dir.path().join("dst");
2298        // Calling with a file as src triggers verify_file_safety branch
2299        // then copy_dir_all fails because src is a file, not a directory
2300        let result = verify_and_copy_files(&src_file, &dst_dir);
2301        assert!(result.is_err());
2302    }
2303
2304    #[test]
2305    fn test_is_safe_path_traversal_nonexistent() {
2306        assert!(!is_safe_path(Path::new("../../etc/passwd")).unwrap());
2307    }
2308
2309    #[test]
2310    fn test_copy_dir_with_progress_nested() {
2311        let src_dir = tempdir().unwrap();
2312        let dst_dir = tempdir().unwrap();
2313        // Create nested structure with files
2314        let sub = src_dir.path().join("sub");
2315        fs::create_dir(&sub).unwrap();
2316        fs::write(src_dir.path().join("root.txt"), "root").unwrap();
2317        fs::write(sub.join("nested.txt"), "nested").unwrap();
2318        copy_dir_with_progress(src_dir.path(), dst_dir.path()).unwrap();
2319        assert!(dst_dir.path().join("root.txt").exists());
2320        assert!(dst_dir.path().join("sub/nested.txt").exists());
2321    }
2322
2323    #[test]
2324    fn test_copy_dir_all_parallel_threshold() {
2325        let src_dir = tempdir().unwrap();
2326        let dst_dir = tempdir().unwrap();
2327        // Create >= 16 files to trigger parallel path
2328        for i in 0..20 {
2329            fs::write(
2330                src_dir.path().join(format!("file{i}.txt")),
2331                format!("content {i}"),
2332            )
2333            .unwrap();
2334        }
2335        copy_dir_all(src_dir.path(), dst_dir.path()).unwrap();
2336        for i in 0..20 {
2337            assert!(dst_dir.path().join(format!("file{i}.txt")).exists());
2338        }
2339    }
2340
2341    #[test]
2342    fn test_collect_files_recursive_depth_exceeded() {
2343        let temp_dir = tempdir().unwrap();
2344        // Create a directory deeper than MAX_DIR_DEPTH
2345        let mut path = temp_dir.path().to_path_buf();
2346        for i in 0..=MAX_DIR_DEPTH {
2347            path = path.join(format!("d{i}"));
2348            fs::create_dir(&path).unwrap();
2349        }
2350        let mut files = Vec::new();
2351        let result = collect_files_recursive(temp_dir.path(), &mut files);
2352        assert!(result.is_err());
2353        assert!(result.unwrap_err().to_string().contains("maximum depth"));
2354    }
2355
2356    #[test]
2357    fn test_copy_dir_all_depth_exceeded() {
2358        let src_dir = tempdir().unwrap();
2359        let dst_dir = tempdir().unwrap();
2360        let mut path = src_dir.path().to_path_buf();
2361        for i in 0..=MAX_DIR_DEPTH {
2362            path = path.join(format!("d{i}"));
2363            fs::create_dir(&path).unwrap();
2364        }
2365        let result = copy_dir_all(src_dir.path(), dst_dir.path());
2366        assert!(result.is_err());
2367        assert!(result.unwrap_err().to_string().contains("maximum depth"));
2368    }
2369
2370    #[test]
2371    fn test_verify_and_copy_files_async_depth_exceeded() {
2372        let temp_dir = tempdir().unwrap();
2373        let src = temp_dir.path().join("src");
2374        let dst = temp_dir.path().join("dst");
2375        let mut path = src.clone();
2376        for i in 0..=MAX_DIR_DEPTH {
2377            path = path.join(format!("d{i}"));
2378            fs::create_dir_all(&path).unwrap();
2379        }
2380        let result = verify_and_copy_files_async(&src, &dst);
2381        assert!(result.is_err());
2382        assert!(result.unwrap_err().to_string().contains("maximum depth"));
2383    }
2384
2385    #[test]
2386    fn test_copy_dir_all_async_depth_exceeded() {
2387        let temp_dir = tempdir().unwrap();
2388        let src = temp_dir.path().join("src");
2389        let dst = temp_dir.path().join("dst");
2390        let mut path = src.clone();
2391        for i in 0..=MAX_DIR_DEPTH {
2392            path = path.join(format!("d{i}"));
2393            fs::create_dir_all(&path).unwrap();
2394        }
2395        let result = copy_dir_all_async(&src, &dst);
2396        assert!(result.is_err());
2397        assert!(result.unwrap_err().to_string().contains("maximum depth"));
2398    }
2399
2400    #[test]
2401    fn test_verify_file_safety_nonexistent() {
2402        let result = verify_file_safety(Path::new("/nonexistent/file.txt"));
2403        assert!(result.is_err());
2404    }
2405
2406    #[test]
2407    fn test_copy_dir_with_progress_nonexistent_source() {
2408        let dst = env::temp_dir().join("ssg_copy_dir_dst");
2409        let result =
2410            copy_dir_with_progress(Path::new("/nonexistent/source"), &dst);
2411        assert!(result.is_err());
2412    }
2413
2414    #[test]
2415    fn test_verify_and_copy_files_async_with_files() {
2416        let temp_dir = tempdir().unwrap();
2417        let src = temp_dir.path().join("src");
2418        let dst = temp_dir.path().join("dst");
2419
2420        // Create source with nested dirs + files
2421        fs::create_dir_all(src.join("sub1/sub2")).unwrap();
2422        fs::write(src.join("root.txt"), "root").unwrap();
2423        fs::write(src.join("sub1/a.txt"), "a").unwrap();
2424        fs::write(src.join("sub1/sub2/b.txt"), "b").unwrap();
2425
2426        verify_and_copy_files_async(&src, &dst).unwrap();
2427
2428        assert_eq!(fs::read_to_string(dst.join("root.txt")).unwrap(), "root");
2429        assert_eq!(fs::read_to_string(dst.join("sub1/a.txt")).unwrap(), "a");
2430        assert_eq!(
2431            fs::read_to_string(dst.join("sub1/sub2/b.txt")).unwrap(),
2432            "b"
2433        );
2434    }
2435
2436    #[test]
2437    fn test_copy_dir_with_progress_with_files() {
2438        let src_dir = tempdir().unwrap();
2439        let dst_dir = tempdir().unwrap();
2440
2441        // Create nested structure
2442        let sub1 = src_dir.path().join("a");
2443        let sub2 = sub1.join("b");
2444        fs::create_dir_all(&sub2).unwrap();
2445        fs::write(src_dir.path().join("file1.txt"), "f1").unwrap();
2446        fs::write(sub1.join("file2.txt"), "f2").unwrap();
2447        fs::write(sub2.join("file3.txt"), "f3").unwrap();
2448
2449        copy_dir_with_progress(src_dir.path(), dst_dir.path()).unwrap();
2450
2451        assert_eq!(
2452            fs::read_to_string(dst_dir.path().join("file1.txt")).unwrap(),
2453            "f1"
2454        );
2455        assert_eq!(
2456            fs::read_to_string(dst_dir.path().join("a/file2.txt")).unwrap(),
2457            "f2"
2458        );
2459        assert_eq!(
2460            fs::read_to_string(dst_dir.path().join("a/b/file3.txt")).unwrap(),
2461            "f3"
2462        );
2463    }
2464
2465    #[cfg(unix)]
2466    #[test]
2467    fn test_is_safe_path_broken_symlink() {
2468        let temp_dir = tempdir().unwrap();
2469        let target = temp_dir.path().join("nonexistent_target");
2470        let link = temp_dir.path().join("broken_link");
2471
2472        std::os::unix::fs::symlink(&target, &link).unwrap();
2473        let result = is_safe_path(&link).unwrap();
2474        assert!(result);
2475    }
2476
2477    #[cfg(unix)]
2478    #[test]
2479    fn test_paths_validate_symlink() {
2480        let temp_dir = tempdir().unwrap();
2481        let real = temp_dir.path().join("real");
2482        let link = temp_dir.path().join("link");
2483
2484        fs::create_dir(&real).unwrap();
2485        std::os::unix::fs::symlink(&real, &link).unwrap();
2486
2487        let paths = Paths {
2488            site: link,
2489            content: PathBuf::from("content"),
2490            build: PathBuf::from("build"),
2491            template: PathBuf::from("templates"),
2492        };
2493        let err = paths.validate().unwrap_err();
2494        assert_same_variant(
2495            &err,
2496            &SsgError::SymlinkForbidden {
2497                path: PathBuf::new(),
2498            },
2499        );
2500    }
2501
2502    #[test]
2503    fn test_copy_dir_with_progress_depth_exceeded() {
2504        let src_dir = tempdir().unwrap();
2505        let dst_dir = tempdir().unwrap();
2506        let mut path = src_dir.path().to_path_buf();
2507        for i in 0..=MAX_DIR_DEPTH {
2508            path = path.join(format!("d{i}"));
2509            fs::create_dir(&path).unwrap();
2510        }
2511        let result = copy_dir_with_progress(src_dir.path(), dst_dir.path());
2512        assert!(result.is_err());
2513        assert!(result.unwrap_err().to_string().contains("maximum depth"));
2514    }
2515
2516    #[test]
2517    fn test_verify_and_copy_files_source_is_file() {
2518        let temp_dir = tempdir().unwrap();
2519        let src_file = temp_dir.path().join("source.txt");
2520        let dst_dir = temp_dir.path().join("dst");
2521        fs::write(&src_file, "hello").unwrap();
2522
2523        let result = verify_and_copy_files(&src_file, &dst_dir);
2524        assert!(result.is_err());
2525    }
2526
2527    #[test]
2528    fn test_compile_site_error() {
2529        // v0.0.46: staticdatagen 0.0.10 + the trimmed content_stager
2530        // treat empty content + empty templates as "no work to do"
2531        // (clean Ok), so we can't reproduce the v0.0.45 "happens to
2532        // error" pattern with empty dirs. Pass a real *file* where the
2533        // content directory is expected — `staticdatagen::add` opens
2534        // it via `read_dir` which fails on a non-directory.
2535        let temp_dir = tempdir().unwrap();
2536        let build = temp_dir.path().join("build");
2537        let content_file = temp_dir.path().join("content_file");
2538        let site = temp_dir.path().join("site");
2539        let template = temp_dir.path().join("template");
2540        fs::create_dir_all(&build).unwrap();
2541        fs::write(&content_file, "not a directory").unwrap();
2542        fs::create_dir_all(&site).unwrap();
2543        fs::create_dir_all(&template).unwrap();
2544
2545        let result = compile_site(&build, &content_file, &site, &template);
2546        assert!(
2547            result.is_err(),
2548            "compile_site should propagate the io error when \
2549             content_dir is a file, got: {result:?}"
2550        );
2551    }
2552
2553    #[test]
2554    fn test_compile_site_propagates_compile_error() {
2555        // v0.0.46: exercises the `compile(...).map_err(...)` closure
2556        // in `pipeline::compile_site` — the branch that fires when
2557        // `staticdatagen::compile` returns Err *after* the stager has
2558        // succeeded. Pass a `build_dir` that's a regular file: the
2559        // stager doesn't touch `build_dir` directly (it stages under
2560        // `std::env::temp_dir()`), so it succeeds; `compile` then
2561        // can't write into the non-directory and the closure wraps
2562        // the io error into an `SsgError`.
2563        let temp_dir = tempdir().unwrap();
2564        let build_file = temp_dir.path().join("build_file");
2565        let content = temp_dir.path().join("content");
2566        let site = temp_dir.path().join("site");
2567        let template = temp_dir.path().join("template");
2568        fs::write(&build_file, "not a directory").unwrap();
2569        fs::create_dir_all(&content).unwrap();
2570        fs::create_dir_all(&site).unwrap();
2571        fs::create_dir_all(&template).unwrap();
2572
2573        let result = compile_site(&build_file, &content, &site, &template);
2574        assert!(
2575            result.is_err(),
2576            "compile_site should propagate compile()'s error when \
2577             build_dir is a file, got: {result:?}"
2578        );
2579    }
2580
2581    #[test]
2582    fn test_prepare_serve_dir_same_as_site() {
2583        let temp_dir = tempdir().unwrap();
2584        let site_dir = temp_dir.path().join("site");
2585        fs::create_dir_all(&site_dir).unwrap();
2586        fs::write(site_dir.join("index.html"), "<html/>").unwrap();
2587
2588        let paths = Paths {
2589            site: site_dir.clone(),
2590            content: PathBuf::from("content"),
2591            build: PathBuf::from("build"),
2592            template: PathBuf::from("templates"),
2593        };
2594
2595        // When serve_dir == site, no copy should happen
2596        prepare_serve_dir(&paths, &site_dir).unwrap();
2597        assert!(site_dir.join("index.html").exists());
2598    }
2599
2600    #[test]
2601    fn test_prepare_serve_dir_different() {
2602        let temp_dir = tempdir().unwrap();
2603        let site_dir = temp_dir.path().join("site");
2604        let serve_dir = temp_dir.path().join("serve");
2605        fs::create_dir_all(&site_dir).unwrap();
2606        fs::write(site_dir.join("index.html"), "<html/>").unwrap();
2607
2608        let paths = Paths {
2609            site: site_dir,
2610            content: PathBuf::from("content"),
2611            build: PathBuf::from("build"),
2612            template: PathBuf::from("templates"),
2613        };
2614
2615        prepare_serve_dir(&paths, &serve_dir).unwrap();
2616        assert!(serve_dir.join("index.html").exists());
2617    }
2618
2619    #[test]
2620    fn test_create_directories_all_valid() {
2621        let temp_dir = tempdir().unwrap();
2622        let paths = Paths {
2623            site: temp_dir.path().join("s"),
2624            content: temp_dir.path().join("c"),
2625            build: temp_dir.path().join("b"),
2626            template: temp_dir.path().join("t"),
2627        };
2628        create_directories(&paths).unwrap();
2629        assert!(paths.site.exists());
2630        assert!(paths.build.exists());
2631    }
2632
2633    #[test]
2634    fn test_is_safe_path_existing_valid() {
2635        let temp_dir = tempdir().unwrap();
2636        let dir = temp_dir.path().join("valid");
2637        fs::create_dir(&dir).unwrap();
2638        let canonical = dir.canonicalize().unwrap();
2639        assert!(is_safe_path(&canonical).unwrap());
2640    }
2641
2642    // -----------------------------------------------------------------
2643    // RunOptions / build_pipeline / execute_build_pipeline
2644    // -----------------------------------------------------------------
2645
2646    #[test]
2647    fn run_options_from_matches_extracts_quiet_drafts_and_deploy() {
2648        let cli = Cli::build();
2649        let matches = cli
2650            .try_get_matches_from(vec![
2651                "ssg", "--quiet", "--drafts", "--deploy", "netlify",
2652            ])
2653            .expect("matches");
2654        let opts = RunOptions::from_matches(&matches);
2655        assert!(opts.quiet);
2656        assert!(opts.include_drafts);
2657        assert_eq!(opts.deploy_target.as_deref(), Some("netlify"));
2658    }
2659
2660    #[test]
2661    fn run_options_from_matches_defaults_when_flags_absent() {
2662        let cli = Cli::build();
2663        let matches = cli.try_get_matches_from(vec!["ssg"]).expect("matches");
2664        let opts = RunOptions::from_matches(&matches);
2665        assert!(!opts.quiet);
2666        assert!(!opts.include_drafts);
2667        assert!(opts.deploy_target.is_none());
2668    }
2669
2670    #[test]
2671    fn build_pipeline_assembles_manager_context_and_dirs() {
2672        let temp = tempdir().unwrap();
2673        let mut config = SsgConfig::default();
2674        config.content_dir = temp.path().join("content");
2675        config.output_dir = temp.path().join("public");
2676        config.template_dir = temp.path().join("templates");
2677        let opts = RunOptions {
2678            quiet: true,
2679            include_drafts: false,
2680            deploy_target: None,
2681            validate_only: false,
2682            jobs: None,
2683            max_memory_mb: None,
2684            ai_fix: false,
2685            ai_fix_dry_run: false,
2686            incremental: false,
2687            no_llm_cache: false,
2688
2689            isr: false,
2690        };
2691
2692        let (plugins, ctx, build_dir, site_dir) =
2693            build_pipeline(&config, &opts);
2694
2695        assert!(plugins.len() >= 10);
2696        assert_ne!(build_dir, site_dir);
2697        assert_eq!(site_dir, temp.path().join("public"));
2698        assert_eq!(ctx.content_dir, temp.path().join("content"));
2699    }
2700
2701    #[test]
2702    fn build_pipeline_with_deploy_target_registers_deploy_plugin() {
2703        let temp = tempdir().unwrap();
2704        let mut config = SsgConfig::default();
2705        config.content_dir = temp.path().join("content");
2706        config.output_dir = temp.path().join("public");
2707
2708        let opts_no_deploy = RunOptions {
2709            quiet: true,
2710            include_drafts: false,
2711            deploy_target: None,
2712            validate_only: false,
2713            jobs: None,
2714            max_memory_mb: None,
2715            ai_fix: false,
2716            ai_fix_dry_run: false,
2717            incremental: false,
2718            no_llm_cache: false,
2719
2720            isr: false,
2721        };
2722        let (no_deploy, _, _, _) = build_pipeline(&config, &opts_no_deploy);
2723
2724        let opts_deploy = RunOptions {
2725            quiet: true,
2726            include_drafts: false,
2727            deploy_target: Some("netlify".to_string()),
2728            validate_only: false,
2729            jobs: None,
2730            max_memory_mb: None,
2731            ai_fix: false,
2732            ai_fix_dry_run: false,
2733            incremental: false,
2734            no_llm_cache: false,
2735
2736            isr: false,
2737        };
2738        let (with_deploy, _, _, _) = build_pipeline(&config, &opts_deploy);
2739
2740        assert_eq!(with_deploy.len(), no_deploy.len() + 1);
2741    }
2742
2743    #[test]
2744    fn build_pipeline_with_unknown_deploy_target_logs_and_skips() {
2745        let temp = tempdir().unwrap();
2746        let mut config = SsgConfig::default();
2747        config.content_dir = temp.path().join("content");
2748        config.output_dir = temp.path().join("public");
2749
2750        let opts = RunOptions {
2751            quiet: true,
2752            include_drafts: false,
2753            deploy_target: Some("nonsense-platform".to_string()),
2754            validate_only: false,
2755            jobs: None,
2756            max_memory_mb: None,
2757            ai_fix: false,
2758            ai_fix_dry_run: false,
2759            incremental: false,
2760            no_llm_cache: false,
2761
2762            isr: false,
2763        };
2764        let (plugins, _, _, _) = build_pipeline(&config, &opts);
2765        let names = plugins.names();
2766        assert!(!names.iter().any(|n| n == &"deploy"));
2767    }
2768
2769    #[test]
2770    fn build_pipeline_with_each_known_deploy_target_registers_one_plugin() {
2771        for target in ["netlify", "vercel", "cloudflare", "github"] {
2772            let temp = tempdir().unwrap();
2773            let mut config = SsgConfig::default();
2774            config.content_dir = temp.path().join("content");
2775            config.output_dir = temp.path().join("public");
2776
2777            let opts = RunOptions {
2778                quiet: true,
2779                include_drafts: false,
2780                deploy_target: Some(target.to_string()),
2781                validate_only: false,
2782                jobs: None,
2783                max_memory_mb: None,
2784                ai_fix: false,
2785                ai_fix_dry_run: false,
2786                incremental: false,
2787                no_llm_cache: false,
2788
2789                isr: false,
2790            };
2791            let (plugins, _, _, _) = build_pipeline(&config, &opts);
2792            assert!(
2793                plugins.names().iter().any(|n| n == &"deploy"),
2794                "deploy plugin should be registered for target `{target}`"
2795            );
2796        }
2797    }
2798
2799    // -----------------------------------------------------------------
2800    // ServeTransport / serve_site_with
2801    // -----------------------------------------------------------------
2802
2803    /// Test transport that records its calls without starting an
2804    /// HTTP server.
2805    #[derive(Debug, Default)]
2806    struct RecordingTransport {
2807        calls: std::sync::Mutex<Vec<(String, String)>>,
2808    }
2809
2810    impl ServeTransport for RecordingTransport {
2811        fn start(&self, addr: &str, root: &str) -> Result<(), SsgError> {
2812            self.calls
2813                .lock()
2814                .unwrap()
2815                .push((addr.to_string(), root.to_string()));
2816            Ok(())
2817        }
2818    }
2819
2820    /// Test transport that always errors — verifies the error is
2821    /// propagated through `serve_site_with`.
2822    #[derive(Debug, Default)]
2823    struct FailingTransport;
2824
2825    impl ServeTransport for FailingTransport {
2826        fn start(&self, _addr: &str, _root: &str) -> Result<(), SsgError> {
2827            Err(SsgError::Validation {
2828                field: "transport".to_string(),
2829                message: "transport failed".to_string(),
2830            })
2831        }
2832    }
2833
2834    #[test]
2835    fn build_serve_address_resolves_path_to_addr_root_pair() {
2836        let (addr, root) = build_serve_address(Path::new("./public")).unwrap();
2837        assert_eq!(
2838            addr,
2839            format!("{}:{}", cmd::DEFAULT_HOST, cmd::DEFAULT_PORT)
2840        );
2841        assert_eq!(root, "./public");
2842    }
2843
2844    #[test]
2845    fn verify_and_copy_files_destination_create_dir_failure_propagates() {
2846        let temp = tempdir().unwrap();
2847        let blocker = temp.path().join("blocker.txt");
2848        fs::write(&blocker, "i am a file, not a directory").unwrap();
2849
2850        let bad_dst = blocker.join("sub");
2851        let result = verify_and_copy_files(temp.path(), &bad_dst);
2852        assert!(result.is_err());
2853        let err = result.unwrap_err();
2854        assert!(
2855            matches!(err, SsgError::Io { ref path, .. } if path == &bad_dst),
2856            "expected SsgError::Io for bad_dst, got: {err:?}"
2857        );
2858    }
2859
2860    #[cfg(not(target_os = "windows"))] // Unix-specific: path behaviour / error messages differ on Windows
2861    #[test]
2862    fn create_directories_unsafe_path_bails() {
2863        let temp = tempdir().unwrap();
2864        let blocker = temp.path().join("blocker.txt");
2865        fs::write(&blocker, "x").unwrap();
2866
2867        let unsafe_path = blocker.join("..").join("subdir");
2868
2869        let paths = Paths {
2870            site: temp.path().join("s"),
2871            content: unsafe_path,
2872            build: temp.path().join("b"),
2873            template: temp.path().join("t"),
2874        };
2875        let result = create_directories(&paths);
2876        assert!(result.is_err());
2877    }
2878
2879    #[test]
2880    fn copy_dir_with_progress_read_dir_failure_propagates() {
2881        let temp = tempdir().unwrap();
2882        let src_file = temp.path().join("not-a-dir.txt");
2883        fs::write(&src_file, "content").unwrap();
2884        let dst = temp.path().join("dst");
2885
2886        let result = copy_dir_with_progress(&src_file, &dst);
2887        assert!(result.is_err());
2888        let err = result.unwrap_err();
2889        assert!(
2890            matches!(err, SsgError::Io { ref path, .. } if path == &src_file),
2891            "expected SsgError::Io for src_file, got: {err:?}"
2892        );
2893    }
2894
2895    #[test]
2896    fn verify_and_copy_files_async_destination_create_dir_failure_propagates() {
2897        let temp = tempdir().unwrap();
2898        let blocker = temp.path().join("async-blocker.txt");
2899        fs::write(&blocker, "blocker").unwrap();
2900
2901        let bad_dst = blocker.join("sub");
2902        let result = verify_and_copy_files_async(temp.path(), &bad_dst);
2903        assert!(result.is_err());
2904        let err = result.unwrap_err();
2905        assert!(
2906            matches!(err, SsgError::Io { ref path, .. } if path == &bad_dst),
2907            "expected SsgError::Io for bad_dst, got: {err:?}"
2908        );
2909    }
2910
2911    #[test]
2912    #[cfg(unix)]
2913    fn build_serve_address_rejects_invalid_utf8_path() {
2914        use std::ffi::OsStr;
2915        use std::os::unix::ffi::OsStrExt;
2916
2917        let invalid_bytes = b"site_\xff_invalid";
2918        let path = Path::new(OsStr::from_bytes(invalid_bytes));
2919        let err = build_serve_address(path).unwrap_err();
2920        assert!(format!("{err:?}").contains("invalid UTF-8"));
2921    }
2922
2923    #[test]
2924    #[cfg(unix)]
2925    fn serve_site_shim_propagates_invalid_utf8_path_error() {
2926        use std::ffi::OsStr;
2927        use std::os::unix::ffi::OsStrExt;
2928        let invalid = b"\xfe\xfe_bad";
2929        let path = Path::new(OsStr::from_bytes(invalid));
2930        let err = serve_site(path).unwrap_err();
2931        assert!(format!("{err:?}").contains("invalid UTF-8"));
2932    }
2933
2934    #[test]
2935    fn serve_site_with_recording_transport_records_addr_and_root() {
2936        let transport = RecordingTransport::default();
2937        serve_site_with(Path::new("./public"), &transport).unwrap();
2938        let calls = transport.calls.lock().unwrap();
2939        assert_eq!(calls.len(), 1);
2940        assert_eq!(calls[0].1, "./public");
2941    }
2942
2943    #[test]
2944    fn serve_site_with_propagates_transport_errors() {
2945        let transport = FailingTransport;
2946        let result = serve_site_with(Path::new("./public"), &transport);
2947        assert!(result.is_err());
2948        assert!(
2949            format!("{:?}", result.unwrap_err()).contains("transport failed")
2950        );
2951    }
2952
2953    #[test]
2954    fn http_transport_implements_serve_transport_trait() {
2955        fn assert_impl<T: ServeTransport>() {}
2956        assert_impl::<HttpTransport>();
2957    }
2958
2959    // -----------------------------------------------------------------
2960    // execute_build_pipeline
2961    // -----------------------------------------------------------------
2962
2963    #[test]
2964    fn execute_build_pipeline_propagates_compile_errors() {
2965        let temp = tempdir().unwrap();
2966        let mut config = SsgConfig::default();
2967        config.content_dir = temp.path().join("missing-content");
2968        config.output_dir = temp.path().join("public");
2969        config.template_dir = temp.path().join("missing-templates");
2970        config.site_name = "broken".to_string();
2971
2972        let opts = RunOptions {
2973            quiet: true,
2974            include_drafts: false,
2975            deploy_target: None,
2976            validate_only: false,
2977            jobs: None,
2978            max_memory_mb: None,
2979            ai_fix: false,
2980            ai_fix_dry_run: false,
2981            incremental: false,
2982            no_llm_cache: false,
2983
2984            isr: false,
2985        };
2986
2987        let (plugins, ctx, build_dir, site_dir) =
2988            build_pipeline(&config, &opts);
2989
2990        let result = execute_build_pipeline(
2991            &plugins,
2992            &ctx,
2993            &build_dir,
2994            &config.content_dir,
2995            &site_dir,
2996            &config.template_dir,
2997            opts.quiet,
2998        );
2999        assert!(result.is_err(), "broken layout should propagate Err");
3000    }
3001
3002    /// Drives the full pipeline against the `examples/` fixtures found
3003    /// under `base`. Returns `false` after logging a skip notice when
3004    /// the fixtures are absent, so both branches are unit-testable.
3005    fn run_example_fixture_pipeline(base: &Path, quiet: bool) -> bool {
3006        let content = base.join("examples/content/en");
3007        let template = base.join("examples/templates/en");
3008        if !content.exists() || !template.exists() {
3009            eprintln!(
3010                "skipping: examples/content/en not present in {}",
3011                base.display()
3012            );
3013            return false;
3014        }
3015
3016        let temp = tempdir().unwrap();
3017        let mut config = SsgConfig::default();
3018        config.content_dir = content;
3019        config.template_dir = template;
3020        config.output_dir = temp.path().join("public");
3021        config.site_name = "pipeline-success-test".to_string();
3022        config.base_url = "http://localhost".to_string();
3023
3024        let opts = RunOptions {
3025            quiet,
3026            include_drafts: false,
3027            deploy_target: None,
3028            validate_only: false,
3029            jobs: None,
3030            max_memory_mb: None,
3031            ai_fix: false,
3032            ai_fix_dry_run: false,
3033            incremental: false,
3034            no_llm_cache: false,
3035
3036            isr: false,
3037        };
3038
3039        let (plugins, ctx, build_dir, site_dir) =
3040            build_pipeline(&config, &opts);
3041
3042        execute_build_pipeline(
3043            &plugins,
3044            &ctx,
3045            &build_dir,
3046            &config.content_dir,
3047            &site_dir,
3048            &config.template_dir,
3049            opts.quiet,
3050        )
3051        .unwrap();
3052
3053        // Evaluate both eagerly (`|` not `||`) so each check executes.
3054        let output_present = site_dir.exists() | build_dir.exists();
3055        assert!(output_present);
3056        true
3057    }
3058
3059    #[test]
3060    fn execute_build_pipeline_succeeds_against_real_example_fixtures() {
3061        let cwd = env::current_dir().unwrap();
3062        let _ = run_example_fixture_pipeline(&cwd, true);
3063    }
3064
3065    #[test]
3066    fn execute_build_pipeline_verbose_success_hits_println_arm() {
3067        let cwd = env::current_dir().unwrap();
3068        let _ = run_example_fixture_pipeline(&cwd, false);
3069    }
3070
3071    #[test]
3072    fn example_fixture_pipeline_skips_when_fixtures_missing() {
3073        let temp = tempdir().unwrap();
3074        assert!(!run_example_fixture_pipeline(temp.path(), true));
3075    }
3076
3077    #[test]
3078    fn execute_build_pipeline_verbose_propagates_compile_errors() {
3079        let temp = tempdir().unwrap();
3080        let mut config = SsgConfig::default();
3081        config.content_dir = temp.path().join("missing");
3082        config.output_dir = temp.path().join("public");
3083        config.template_dir = temp.path().join("missing-templates");
3084        config.site_name = "broken-verbose".to_string();
3085
3086        let opts = RunOptions {
3087            quiet: false,
3088            include_drafts: false,
3089            deploy_target: None,
3090            validate_only: false,
3091            jobs: None,
3092            max_memory_mb: None,
3093            ai_fix: false,
3094            ai_fix_dry_run: false,
3095            incremental: false,
3096            no_llm_cache: false,
3097
3098            isr: false,
3099        };
3100
3101        let (plugins, ctx, build_dir, site_dir) =
3102            build_pipeline(&config, &opts);
3103
3104        let _ = execute_build_pipeline(
3105            &plugins,
3106            &ctx,
3107            &build_dir,
3108            &config.content_dir,
3109            &site_dir,
3110            &config.template_dir,
3111            opts.quiet,
3112        );
3113    }
3114
3115    #[test]
3116    fn build_pipeline_with_drafts_flag_registers_draft_plugin() {
3117        let temp = tempdir().unwrap();
3118        let mut config = SsgConfig::default();
3119        config.content_dir = temp.path().join("content");
3120        config.output_dir = temp.path().join("public");
3121
3122        let opts = RunOptions {
3123            quiet: true,
3124            include_drafts: true,
3125            deploy_target: None,
3126            validate_only: false,
3127            jobs: None,
3128            max_memory_mb: None,
3129            ai_fix: false,
3130            ai_fix_dry_run: false,
3131            incremental: false,
3132            no_llm_cache: false,
3133
3134            isr: false,
3135        };
3136        let (plugins, _, _, _) = build_pipeline(&config, &opts);
3137        assert!(plugins.names().iter().any(|n| n == &"drafts"));
3138    }
3139
3140    // -----------------------------------------------------------------
3141    // now_iso / days_to_ymd coverage
3142    // -----------------------------------------------------------------
3143
3144    #[test]
3145    fn now_iso_returns_valid_iso8601_format() {
3146        let ts = now_iso();
3147        assert_eq!(ts.len(), 20, "ISO timestamp should be 20 chars: {ts}");
3148        assert!(ts.ends_with('Z'), "should end with Z: {ts}");
3149        assert_eq!(&ts[4..5], "-");
3150        assert_eq!(&ts[7..8], "-");
3151        assert_eq!(&ts[10..11], "T");
3152        assert_eq!(&ts[13..14], ":");
3153        assert_eq!(&ts[16..17], ":");
3154        let year: u64 = ts[0..4].parse().unwrap();
3155        assert!(year >= 2020, "year should be recent: {year}");
3156    }
3157
3158    #[test]
3159    fn days_to_ymd_epoch() {
3160        let (y, m, d) = days_to_ymd(0);
3161        assert_eq!((y, m, d), (1970, 1, 1));
3162    }
3163
3164    #[test]
3165    fn days_to_ymd_known_date_2026_04_13() {
3166        let (y, m, d) = days_to_ymd(20_556);
3167        assert_eq!((y, m, d), (2026, 4, 13));
3168    }
3169
3170    #[test]
3171    fn days_to_ymd_leap_day() {
3172        let (y, m, d) = days_to_ymd(11_016);
3173        assert_eq!((y, m, d), (2000, 2, 29));
3174    }
3175
3176    #[test]
3177    fn days_to_ymd_y2k() {
3178        let (y, m, d) = days_to_ymd(10_957);
3179        assert_eq!((y, m, d), (2000, 1, 1));
3180    }
3181
3182    // -----------------------------------------------------------------
3183    // SimpleLogger coverage
3184    // -----------------------------------------------------------------
3185
3186    #[test]
3187    fn simple_logger_enabled_respects_max_level() {
3188        let logger = SimpleLogger;
3189        let meta = log::MetadataBuilder::new()
3190            .level(log::Level::Info)
3191            .target("test")
3192            .build();
3193        let _ = logger.enabled(&meta);
3194    }
3195
3196    #[test]
3197    fn simple_logger_flush_is_noop() {
3198        use log::Log;
3199        let logger = SimpleLogger;
3200        logger.flush();
3201    }
3202
3203    // -----------------------------------------------------------------
3204    // build_serve_address additional coverage
3205    // -----------------------------------------------------------------
3206
3207    #[test]
3208    fn build_serve_address_with_absolute_path() {
3209        let (addr, root) = build_serve_address(Path::new("/tmp/site")).unwrap();
3210        assert!(addr.contains(&cmd::DEFAULT_PORT.to_string()));
3211        assert_eq!(root, "/tmp/site");
3212    }
3213
3214    // -----------------------------------------------------------------
3215    // copy_dir_with_progress file count output
3216    // -----------------------------------------------------------------
3217
3218    #[test]
3219    fn copy_dir_with_progress_counts_files_and_dirs() {
3220        let src_dir = tempdir().unwrap();
3221        let dst_dir = tempdir().unwrap();
3222
3223        fs::write(src_dir.path().join("a.txt"), "a").unwrap();
3224        fs::write(src_dir.path().join("b.txt"), "b").unwrap();
3225        let sub = src_dir.path().join("sub");
3226        fs::create_dir(&sub).unwrap();
3227        fs::write(sub.join("c.txt"), "c").unwrap();
3228
3229        copy_dir_with_progress(src_dir.path(), dst_dir.path()).unwrap();
3230
3231        assert!(dst_dir.path().join("a.txt").exists());
3232        assert!(dst_dir.path().join("b.txt").exists());
3233        assert!(dst_dir.path().join("sub/c.txt").exists());
3234    }
3235
3236    // -----------------------------------------------------------------
3237    // days_to_ymd — additional edge cases
3238    // -----------------------------------------------------------------
3239
3240    #[test]
3241    fn days_to_ymd_end_of_year() {
3242        // Dec 31, 1970 = day 364
3243        let (y, m, d) = days_to_ymd(364);
3244        assert_eq!((y, m, d), (1970, 12, 31));
3245    }
3246
3247    #[test]
3248    fn days_to_ymd_non_leap_year_feb28() {
3249        // Feb 28, 1971 = day 58 + 365 = 423
3250        let (y, m, d) = days_to_ymd(423);
3251        assert_eq!((y, m, d), (1971, 2, 28));
3252    }
3253
3254    #[test]
3255    fn days_to_ymd_non_leap_year_mar1() {
3256        // Mar 1, 1971 = day 424
3257        let (y, m, d) = days_to_ymd(424);
3258        assert_eq!((y, m, d), (1971, 3, 1));
3259    }
3260
3261    #[test]
3262    fn days_to_ymd_century_non_leap() {
3263        // 1900 is NOT a leap year (divisible by 100, not by 400).
3264        // Mar 1, 1900 — we use a negative-offset approach:
3265        // 2000-01-01 is day 10957. 1900-01-01 is 10957 - 36524 = ???
3266        // Easier: just test a few far-future dates.
3267        // 2100-01-01 is NOT a leap year.
3268        // 2100-03-01: days = (2100-1970)*365 + leap_days + 31 + 28
3269        // Instead, let's verify round-trip for several known dates.
3270        let (y, m, d) = days_to_ymd(10_956);
3271        assert_eq!((y, m, d), (1999, 12, 31));
3272    }
3273
3274    #[test]
3275    fn days_to_ymd_large_day_count() {
3276        // Far-future date: 2100-01-01
3277        // 2100-01-01 is day 47482
3278        let (y, m, d) = days_to_ymd(47_482);
3279        assert_eq!((y, m, d), (2100, 1, 1));
3280    }
3281
3282    // -----------------------------------------------------------------
3283    // now_iso — additional format checks
3284    // -----------------------------------------------------------------
3285
3286    #[test]
3287    fn now_iso_month_and_day_within_range() {
3288        let ts = now_iso();
3289        let month: u32 = ts[5..7].parse().unwrap();
3290        let day: u32 = ts[8..10].parse().unwrap();
3291        let hour: u32 = ts[11..13].parse().unwrap();
3292        let minute: u32 = ts[14..16].parse().unwrap();
3293        let second: u32 = ts[17..19].parse().unwrap();
3294        assert!((1..=12).contains(&month), "month out of range: {month}");
3295        assert!((1..=31).contains(&day), "day out of range: {day}");
3296        assert!(hour < 24, "hour out of range: {hour}");
3297        assert!(minute < 60, "minute out of range: {minute}");
3298        assert!(second < 60, "second out of range: {second}");
3299    }
3300
3301    // -----------------------------------------------------------------
3302    // Paths — additional validation edge cases
3303    // -----------------------------------------------------------------
3304
3305    #[test]
3306    fn paths_validate_double_slash_in_content() {
3307        let paths = Paths {
3308            site: PathBuf::from("public"),
3309            content: PathBuf::from("content//nested"),
3310            build: PathBuf::from("build"),
3311            template: PathBuf::from("templates"),
3312        };
3313        let err = paths.validate().unwrap_err();
3314        assert_same_variant(
3315            &err,
3316            &SsgError::Validation {
3317                field: String::new(),
3318                message: String::new(),
3319            },
3320        );
3321    }
3322
3323    fn paths_with_content(content: &str) -> Paths {
3324        Paths {
3325            site: PathBuf::from("public"),
3326            content: PathBuf::from(content),
3327            build: PathBuf::from("build"),
3328            template: PathBuf::from("templates"),
3329        }
3330    }
3331
3332    #[test]
3333    fn paths_validate_accepts_dotted_names() {
3334        // A substring check for ".." used to reject these.
3335        for p in ["posts/a..b", "v1..v2", "..hidden"] {
3336            assert!(paths_with_content(p).validate().is_ok(), "{p}");
3337        }
3338    }
3339
3340    #[test]
3341    fn paths_validate_rejects_parent_components() {
3342        for p in ["..", "../x", "a/../../x", "a/../b", "/srv/../etc"] {
3343            let err = paths_with_content(p).validate().unwrap_err();
3344            assert!(
3345                matches!(err, SsgError::PathTraversal { .. }),
3346                "{p}: {err:?}"
3347            );
3348        }
3349    }
3350
3351    #[test]
3352    fn paths_validate_traversal_in_build() {
3353        let paths = Paths {
3354            site: PathBuf::from("public"),
3355            content: PathBuf::from("content"),
3356            build: PathBuf::from("../build"),
3357            template: PathBuf::from("templates"),
3358        };
3359        let err = paths.validate().unwrap_err();
3360        assert_same_variant(
3361            &err,
3362            &SsgError::PathTraversal {
3363                path: PathBuf::new(),
3364            },
3365        );
3366    }
3367
3368    #[test]
3369    fn paths_validate_traversal_in_template() {
3370        let paths = Paths {
3371            site: PathBuf::from("public"),
3372            content: PathBuf::from("content"),
3373            build: PathBuf::from("build"),
3374            template: PathBuf::from("../templates"),
3375        };
3376        let err = paths.validate().unwrap_err();
3377        assert_same_variant(
3378            &err,
3379            &SsgError::PathTraversal {
3380                path: PathBuf::new(),
3381            },
3382        );
3383    }
3384
3385    #[test]
3386    fn paths_validate_double_slash_in_build() {
3387        let paths = Paths {
3388            site: PathBuf::from("public"),
3389            content: PathBuf::from("content"),
3390            build: PathBuf::from("build//sub"),
3391            template: PathBuf::from("templates"),
3392        };
3393        let err = paths.validate().unwrap_err();
3394        assert_same_variant(
3395            &err,
3396            &SsgError::Validation {
3397                field: String::new(),
3398                message: String::new(),
3399            },
3400        );
3401    }
3402
3403    #[test]
3404    fn paths_validate_double_slash_in_template() {
3405        let paths = Paths {
3406            site: PathBuf::from("public"),
3407            content: PathBuf::from("content"),
3408            build: PathBuf::from("build"),
3409            template: PathBuf::from("templates//sub"),
3410        };
3411        let err = paths.validate().unwrap_err();
3412        assert_same_variant(
3413            &err,
3414            &SsgError::Validation {
3415                field: String::new(),
3416                message: String::new(),
3417            },
3418        );
3419    }
3420
3421    // -----------------------------------------------------------------
3422    // PathsBuilder — additional coverage
3423    // -----------------------------------------------------------------
3424
3425    #[test]
3426    fn paths_builder_partial_override() {
3427        let paths = Paths::builder()
3428            .site("custom_site")
3429            .template("custom_templates")
3430            .build()
3431            .unwrap();
3432        assert_eq!(paths.site, PathBuf::from("custom_site"));
3433        assert_eq!(paths.content, PathBuf::from("content"));
3434        assert_eq!(paths.build, PathBuf::from("build"));
3435        assert_eq!(paths.template, PathBuf::from("custom_templates"));
3436    }
3437
3438    #[test]
3439    fn paths_debug_format() {
3440        let paths = Paths::default_paths();
3441        let debug = format!("{paths:?}");
3442        assert!(debug.contains("site"));
3443        assert!(debug.contains("content"));
3444    }
3445
3446    // -----------------------------------------------------------------
3447    // RunOptions — additional flag combinations
3448    // -----------------------------------------------------------------
3449
3450    #[test]
3451    fn run_options_from_matches_extracts_validate_flag() {
3452        let cli = Cli::build();
3453        let matches = cli
3454            .try_get_matches_from(vec!["ssg", "--validate"])
3455            .expect("matches");
3456        let opts = RunOptions::from_matches(&matches);
3457        assert!(opts.validate_only);
3458        assert!(!opts.quiet);
3459        assert!(!opts.include_drafts);
3460    }
3461
3462    #[test]
3463    fn run_options_from_matches_extracts_jobs_flag() {
3464        let cli = Cli::build();
3465        let matches = cli
3466            .try_get_matches_from(vec!["ssg", "--jobs", "8"])
3467            .expect("matches");
3468        let opts = RunOptions::from_matches(&matches);
3469        assert_eq!(opts.jobs, Some(8));
3470    }
3471
3472    #[test]
3473    fn run_options_from_matches_extracts_max_memory_flag() {
3474        let cli = Cli::build();
3475        let matches = cli
3476            .try_get_matches_from(vec!["ssg", "--max-memory", "256"])
3477            .expect("matches");
3478        let opts = RunOptions::from_matches(&matches);
3479        assert_eq!(opts.max_memory_mb, Some(256));
3480    }
3481
3482    #[test]
3483    fn run_options_from_matches_extracts_ai_fix_flags() {
3484        let cli = Cli::build();
3485        let matches = cli
3486            .try_get_matches_from(vec!["ssg", "--ai-fix", "--ai-fix-dry-run"])
3487            .expect("matches");
3488        let opts = RunOptions::from_matches(&matches);
3489        assert!(opts.ai_fix);
3490        assert!(opts.ai_fix_dry_run);
3491    }
3492
3493    #[test]
3494    fn run_options_from_matches_all_flags_combined() {
3495        let cli = Cli::build();
3496        let matches = cli
3497            .try_get_matches_from(vec![
3498                "ssg",
3499                "--quiet",
3500                "--drafts",
3501                "--deploy",
3502                "vercel",
3503                "--validate",
3504                "--jobs",
3505                "4",
3506                "--max-memory",
3507                "1024",
3508                "--ai-fix",
3509                "--ai-fix-dry-run",
3510            ])
3511            .expect("matches");
3512        let opts = RunOptions::from_matches(&matches);
3513        assert!(opts.quiet);
3514        assert!(opts.include_drafts);
3515        assert_eq!(opts.deploy_target.as_deref(), Some("vercel"));
3516        assert!(opts.validate_only);
3517        assert_eq!(opts.jobs, Some(4));
3518        assert_eq!(opts.max_memory_mb, Some(1024));
3519        assert!(opts.ai_fix);
3520        assert!(opts.ai_fix_dry_run);
3521    }
3522
3523    // -----------------------------------------------------------------
3524    // build_pipeline — memory budget propagation
3525    // -----------------------------------------------------------------
3526
3527    #[test]
3528    fn build_pipeline_propagates_max_memory_to_context() {
3529        let temp = tempdir().unwrap();
3530        let mut config = SsgConfig::default();
3531        config.content_dir = temp.path().join("content");
3532        config.output_dir = temp.path().join("public");
3533        config.template_dir = temp.path().join("templates");
3534
3535        let opts = RunOptions {
3536            quiet: true,
3537            include_drafts: false,
3538            deploy_target: None,
3539            validate_only: false,
3540            jobs: None,
3541            max_memory_mb: Some(128),
3542            ai_fix: false,
3543            ai_fix_dry_run: false,
3544            incremental: false,
3545            no_llm_cache: false,
3546
3547            isr: false,
3548        };
3549
3550        let (_plugins, ctx, _build_dir, _site_dir) =
3551            build_pipeline(&config, &opts);
3552
3553        assert!(
3554            ctx.memory_budget.is_some(),
3555            "memory_budget should be set when max_memory_mb is provided"
3556        );
3557    }
3558
3559    #[test]
3560    fn build_pipeline_no_memory_budget_when_not_specified() {
3561        let temp = tempdir().unwrap();
3562        let mut config = SsgConfig::default();
3563        config.content_dir = temp.path().join("content");
3564        config.output_dir = temp.path().join("public");
3565        config.template_dir = temp.path().join("templates");
3566
3567        let opts = RunOptions {
3568            quiet: true,
3569            include_drafts: false,
3570            deploy_target: None,
3571            validate_only: false,
3572            jobs: None,
3573            max_memory_mb: None,
3574            ai_fix: false,
3575            ai_fix_dry_run: false,
3576            incremental: false,
3577            no_llm_cache: false,
3578
3579            isr: false,
3580        };
3581
3582        let (_plugins, ctx, _build_dir, _site_dir) =
3583            build_pipeline(&config, &opts);
3584
3585        assert!(
3586            ctx.memory_budget.is_none(),
3587            "memory_budget should be None when max_memory_mb not provided"
3588        );
3589    }
3590
3591    // -----------------------------------------------------------------
3592    // build_pipeline — deploy targets: vercel, cloudflare, github
3593    // -----------------------------------------------------------------
3594
3595    #[test]
3596    fn build_pipeline_with_vercel_deploy_target() {
3597        let temp = tempdir().unwrap();
3598        let mut config = SsgConfig::default();
3599        config.content_dir = temp.path().join("content");
3600        config.output_dir = temp.path().join("public");
3601
3602        let opts = RunOptions {
3603            quiet: true,
3604            include_drafts: false,
3605            deploy_target: Some("vercel".to_string()),
3606            validate_only: false,
3607            jobs: None,
3608            max_memory_mb: None,
3609            ai_fix: false,
3610            ai_fix_dry_run: false,
3611            incremental: false,
3612            no_llm_cache: false,
3613
3614            isr: false,
3615        };
3616        let (plugins, _, _, _) = build_pipeline(&config, &opts);
3617        assert!(plugins.names().iter().any(|n| n == &"deploy"));
3618    }
3619
3620    #[test]
3621    fn build_pipeline_with_cloudflare_deploy_target() {
3622        let temp = tempdir().unwrap();
3623        let mut config = SsgConfig::default();
3624        config.content_dir = temp.path().join("content");
3625        config.output_dir = temp.path().join("public");
3626
3627        let opts = RunOptions {
3628            quiet: true,
3629            include_drafts: false,
3630            deploy_target: Some("cloudflare".to_string()),
3631            validate_only: false,
3632            jobs: None,
3633            max_memory_mb: None,
3634            ai_fix: false,
3635            ai_fix_dry_run: false,
3636            incremental: false,
3637            no_llm_cache: false,
3638
3639            isr: false,
3640        };
3641        let (plugins, _, _, _) = build_pipeline(&config, &opts);
3642        assert!(plugins.names().iter().any(|n| n == &"deploy"));
3643    }
3644
3645    #[test]
3646    fn build_pipeline_with_github_deploy_target() {
3647        let temp = tempdir().unwrap();
3648        let mut config = SsgConfig::default();
3649        config.content_dir = temp.path().join("content");
3650        config.output_dir = temp.path().join("public");
3651
3652        let opts = RunOptions {
3653            quiet: true,
3654            include_drafts: false,
3655            deploy_target: Some("github".to_string()),
3656            validate_only: false,
3657            jobs: None,
3658            max_memory_mb: None,
3659            ai_fix: false,
3660            ai_fix_dry_run: false,
3661            incremental: false,
3662            no_llm_cache: false,
3663
3664            isr: false,
3665        };
3666        let (plugins, _, _, _) = build_pipeline(&config, &opts);
3667        assert!(plugins.names().iter().any(|n| n == &"deploy"));
3668    }
3669
3670    // -----------------------------------------------------------------
3671    // resolve_build_and_site_dirs — additional edge cases
3672    // -----------------------------------------------------------------
3673
3674    #[test]
3675    fn resolve_build_and_site_dirs_serve_dir_none_uses_output_dir_as_site() {
3676        let mut config = SsgConfig::default();
3677        config.output_dir = PathBuf::from("my-output");
3678        config.serve_dir = None;
3679
3680        let (_build_dir, site_dir) = resolve_build_and_site_dirs(&config);
3681        assert_eq!(site_dir, PathBuf::from("my-output"));
3682    }
3683
3684    #[test]
3685    fn resolve_build_and_site_dirs_always_produces_distinct_dirs() {
3686        // Even when serve_dir == output_dir, build != site
3687        let mut config = SsgConfig::default();
3688        config.output_dir = PathBuf::from("same");
3689        config.serve_dir = Some(PathBuf::from("same"));
3690
3691        let (build_dir, site_dir) = resolve_build_and_site_dirs(&config);
3692        assert_ne!(build_dir, site_dir);
3693        assert_eq!(site_dir, PathBuf::from("same"));
3694        assert!(build_dir.to_string_lossy().contains("build-tmp"));
3695    }
3696
3697    // -----------------------------------------------------------------
3698    // generate_locale_redirect coverage
3699    // -----------------------------------------------------------------
3700
3701    #[test]
3702    fn generate_locale_redirect_creates_index_html() {
3703        let temp = tempdir().unwrap();
3704        let locales = vec!["en".to_string(), "fr".to_string()];
3705        generate_locale_redirect(temp.path(), &locales, "en").unwrap();
3706
3707        let index = temp.path().join("index.html");
3708        assert!(index.exists());
3709        let content = fs::read_to_string(&index).unwrap();
3710        assert!(content.contains("ssg-locale-redirect"));
3711        assert!(content.contains("\"en\""));
3712        assert!(content.contains("\"fr\""));
3713    }
3714
3715    #[test]
3716    fn generate_locale_redirect_does_not_overwrite_user_index() {
3717        let temp = tempdir().unwrap();
3718        let user_html = "<html><body>My site</body></html>";
3719        fs::write(temp.path().join("index.html"), user_html).unwrap();
3720
3721        let locales = vec!["en".to_string()];
3722        generate_locale_redirect(temp.path(), &locales, "en").unwrap();
3723
3724        let content =
3725            fs::read_to_string(temp.path().join("index.html")).unwrap();
3726        assert_eq!(content, user_html, "user index.html should be preserved");
3727    }
3728
3729    #[test]
3730    fn generate_locale_redirect_overwrites_own_index() {
3731        let temp = tempdir().unwrap();
3732        let old_redirect = "<!-- ssg-locale-redirect --><html>old</html>";
3733        fs::write(temp.path().join("index.html"), old_redirect).unwrap();
3734
3735        let locales = vec!["de".to_string(), "en".to_string()];
3736        generate_locale_redirect(temp.path(), &locales, "de").unwrap();
3737
3738        let content =
3739            fs::read_to_string(temp.path().join("index.html")).unwrap();
3740        assert!(content.contains("ssg-locale-redirect"));
3741        assert!(content.contains("\"de\""));
3742    }
3743
3744    // ── Subcommand handler unit coverage (issue #527) ───────────────
3745
3746    #[test]
3747    fn apply_rayon_thread_pool_none_is_no_op() {
3748        // `None` path must be Ok and must not touch the global pool.
3749        assert!(apply_rayon_thread_pool(None).is_ok());
3750    }
3751
3752    #[test]
3753    fn apply_rayon_thread_pool_some_either_succeeds_or_signals_already_set() {
3754        // The Rayon global pool can only be initialised once per
3755        // process. Earlier tests may have already initialised it via
3756        // `RunOptions` / pipeline tests, in which case a second
3757        // call returns an SsgError::Validation. Either outcome is
3758        // acceptable here — we just need to walk the `Some(n)` branch.
3759        // The `Ok` arm is exercised deterministically by
3760        // `apply_rayon_thread_pool_succeeds_in_fresh_process`, which
3761        // re-runs this test in a child process where nothing has
3762        // touched Rayon yet.
3763        test_support::init_logger();
3764        let result = apply_rayon_thread_pool(Some(1));
3765        match result {
3766            Ok(()) => {}
3767            Err(e) => assert_same_variant(
3768                &e,
3769                &SsgError::Validation {
3770                    field: String::new(),
3771                    message: String::new(),
3772                },
3773            ),
3774        }
3775    }
3776
3777    #[test]
3778    fn apply_rayon_thread_pool_succeeds_in_fresh_process() {
3779        // Re-run the sibling test in a fresh process, where the global
3780        // Rayon pool has never been initialised, so `build_global`
3781        // succeeds and the `Ok` arm executes.
3782        let exe = env::current_exe().unwrap();
3783        let status = std::process::Command::new(exe)
3784            .args([
3785                "--exact",
3786                "tests::apply_rayon_thread_pool_some_either_succeeds_or_signals_already_set",
3787                "--test-threads=1",
3788            ])
3789            .status()
3790            .unwrap();
3791        assert!(status.success());
3792    }
3793
3794    #[test]
3795    fn build_config_from_subcommand_matches_routes_through_to_config() {
3796        let (_inv, matches) =
3797            Cli::parse_and_dispatch(["ssg", "build"]).unwrap();
3798        let sub = matches.subcommand_matches("build").unwrap();
3799        let cfg = build_config_from_subcommand_matches(sub).unwrap();
3800        // Default content_dir is `content`.
3801        assert_eq!(cfg.content_dir, PathBuf::from("content"));
3802    }
3803
3804    #[test]
3805    fn build_config_from_subcommand_matches_propagates_validation_errors() {
3806        // Point `--config` at a non-existent file — SsgConfig::from_file
3807        // returns CliError, the wrapper maps that onto
3808        // SsgError::Validation.
3809        let (_inv, matches) = Cli::parse_and_dispatch([
3810            "ssg",
3811            "build",
3812            "--config",
3813            "/definitely/does/not/exist.toml",
3814        ])
3815        .unwrap();
3816        let sub = matches.subcommand_matches("build").unwrap();
3817        let err = build_config_from_subcommand_matches(sub).unwrap_err();
3818        assert_same_variant(
3819            &err,
3820            &SsgError::Validation {
3821                field: String::new(),
3822                message: String::new(),
3823            },
3824        );
3825    }
3826
3827    #[test]
3828    fn dispatch_invocation_check_with_missing_subcommand_returns_validation_error(
3829    ) {
3830        // Build a top-level matches that has no `check` subcommand so
3831        // run_check's `ok_or_else` arm fires.
3832        let matches =
3833            Cli::subcommand_app().try_get_matches_from(["ssg"]).unwrap();
3834        let err =
3835            dispatch_invocation(CliInvocation::Check, &matches).unwrap_err();
3836        assert!(
3837            matches!(err, SsgError::Validation { field, .. } if field == "subcommand")
3838        );
3839    }
3840
3841    #[test]
3842    fn dispatch_invocation_build_with_missing_subcommand_returns_validation_error(
3843    ) {
3844        let matches =
3845            Cli::subcommand_app().try_get_matches_from(["ssg"]).unwrap();
3846        let err =
3847            dispatch_invocation(CliInvocation::Build, &matches).unwrap_err();
3848        assert!(
3849            matches!(err, SsgError::Validation { field, .. } if field == "subcommand")
3850        );
3851    }
3852
3853    #[test]
3854    fn dispatch_invocation_dev_with_missing_subcommand_returns_validation_error(
3855    ) {
3856        let matches =
3857            Cli::subcommand_app().try_get_matches_from(["ssg"]).unwrap();
3858        let err =
3859            dispatch_invocation(CliInvocation::Dev, &matches).unwrap_err();
3860        assert!(
3861            matches!(err, SsgError::Validation { field, .. } if field == "subcommand")
3862        );
3863    }
3864
3865    #[test]
3866    fn dispatch_invocation_deploy_with_missing_subcommand_returns_validation_error(
3867    ) {
3868        let matches =
3869            Cli::subcommand_app().try_get_matches_from(["ssg"]).unwrap();
3870        let err = dispatch_invocation(
3871            CliInvocation::Deploy {
3872                target: "none".to_string(),
3873            },
3874            &matches,
3875        )
3876        .unwrap_err();
3877        assert!(
3878            matches!(err, SsgError::Validation { field, .. } if field == "subcommand")
3879        );
3880    }
3881
3882    /// Mutex used to serialise tests that exercise `run_check` /
3883    /// `run_legacy` so they don't race on the global Rayon thread pool
3884    /// init.
3885    fn ssg_check_lock() -> &'static std::sync::Mutex<()> {
3886        use std::sync::Mutex;
3887        use std::sync::OnceLock;
3888        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
3889        LOCK.get_or_init(|| Mutex::new(()))
3890    }
3891
3892    /// Acquires the serialisation lock, recovering from poisoning so a
3893    /// panicking sibling test cannot cascade failures.
3894    fn ssg_check_guard() -> std::sync::MutexGuard<'static, ()> {
3895        ssg_check_lock().lock().unwrap_or_else(|p| p.into_inner())
3896    }
3897
3898    #[test]
3899    fn ssg_check_guard_recovers_from_poisoned_lock() {
3900        // Poison the lock from a scratch thread, then verify the
3901        // guard helper's `into_inner` recovery arm actually runs.
3902        let poisoner = std::thread::spawn(|| {
3903            let _g = ssg_check_lock().lock().unwrap();
3904            panic!("deliberate poison for ssg_check_guard test");
3905        });
3906        assert!(poisoner.join().is_err());
3907        let _g = ssg_check_guard();
3908    }
3909
3910    #[test]
3911    fn run_check_with_empty_content_dir_passes() {
3912        let _g = ssg_check_guard();
3913
3914        let content = tempdir().unwrap();
3915        let templates = tempdir().unwrap();
3916        let output = tempdir().unwrap();
3917        let argv = [
3918            "ssg",
3919            "check",
3920            "--content",
3921            content.path().to_str().unwrap(),
3922            "--template",
3923            templates.path().to_str().unwrap(),
3924            "--output",
3925            output.path().to_str().unwrap(),
3926            "--quiet",
3927        ];
3928        let (inv, matches) = Cli::parse_and_dispatch(argv).unwrap();
3929        assert_same_variant(&inv, &CliInvocation::Check);
3930        // run_check is the unit-under-test. It must complete cleanly
3931        // for an empty (no-schema, no-content) site.
3932        let result = dispatch_invocation(inv, &matches);
3933        assert!(result.is_ok(), "run_check failed: {result:?}");
3934    }
3935
3936    #[test]
3937    fn only_site_producing_invocations_claim_a_generated_site() {
3938        // Regression: `main` announced "Site generated successfully."
3939        // for every Ok result, so `ssg audit` and `ssg plugins list`
3940        // claimed a site they never wrote.
3941        for inv in [
3942            CliInvocation::Legacy,
3943            CliInvocation::Build,
3944            CliInvocation::Dev,
3945            CliInvocation::Deploy {
3946                target: "none".to_string(),
3947            },
3948        ] {
3949            assert!(
3950                generates_site(&inv),
3951                "{inv:?} writes a site and must report it"
3952            );
3953        }
3954
3955        for inv in [
3956            CliInvocation::Check,
3957            CliInvocation::Audit,
3958            CliInvocation::Plugins {
3959                json: false,
3960                target: None,
3961            },
3962        ] {
3963            assert!(
3964                !generates_site(&inv),
3965                "{inv:?} writes no site and must not claim one"
3966            );
3967        }
3968    }
3969
3970    #[test]
3971    fn legacy_new_scaffolds_and_does_not_claim_a_site() {
3972        // Regression: `ssg --new NAME` scaffolds a project and exits
3973        // without building, yet printed "Site generated successfully.",
3974        // so a build script passing a leftover `-n=docs` reported
3975        // success with nothing written to its output directory.
3976        let (inv, matches) =
3977            Cli::parse_and_dispatch(["ssg", "--new", "mysite"]).unwrap();
3978        assert_same_variant(&inv, &CliInvocation::Legacy);
3979        assert!(!announces_site(&inv, &matches), "--new builds no site");
3980
3981        let (inv, matches) =
3982            Cli::parse_and_dispatch(["ssg", "-c", "content", "-o", "public"])
3983                .unwrap();
3984        assert_same_variant(&inv, &CliInvocation::Legacy);
3985        assert!(
3986            announces_site(&inv, &matches),
3987            "a legacy build writes a site"
3988        );
3989    }
3990
3991    #[test]
3992    fn deploy_claims_a_site_for_every_supported_target() {
3993        // `Deploy` carries a field, so a `matches!` arm for it is easy
3994        // to get wrong. Every target still builds a site first.
3995        for target in cmd::DEPLOY_TARGETS {
3996            let inv = CliInvocation::Deploy {
3997                target: (*target).to_string(),
3998            };
3999            assert!(generates_site(&inv), "deploy --target {target}");
4000        }
4001    }
4002
4003    #[test]
4004    fn run_legacy_with_validate_flag_short_circuits_to_validate_only() {
4005        let _g = ssg_check_guard();
4006
4007        let content = tempdir().unwrap();
4008        let templates = tempdir().unwrap();
4009        let output = tempdir().unwrap();
4010        let argv = [
4011            "ssg",
4012            "--content",
4013            content.path().to_str().unwrap(),
4014            "--template",
4015            templates.path().to_str().unwrap(),
4016            "--output",
4017            output.path().to_str().unwrap(),
4018            "--quiet",
4019            "--validate",
4020        ];
4021        let (inv, matches) = Cli::parse_and_dispatch(argv).unwrap();
4022        assert_same_variant(&inv, &CliInvocation::Legacy);
4023        // The `--validate` legacy flag short-circuits before any
4024        // pipeline work happens, so this is safe to run as a unit test.
4025        let result = dispatch_invocation(inv, &matches);
4026        assert!(result.is_ok(), "run_legacy --validate failed: {result:?}");
4027    }
4028
4029    #[test]
4030    fn run_subcommand_build_with_empty_dirs_completes() {
4031        // Drives run_subcommand("build") end-to-end through
4032        // dispatch_invocation. Empty content/templates dirs means the
4033        // build is a no-op but every line of the dispatcher body is
4034        // executed.
4035        let _g = ssg_check_guard();
4036
4037        let content = tempdir().unwrap();
4038        let templates = tempdir().unwrap();
4039        let output = tempdir().unwrap();
4040        let argv = [
4041            "ssg",
4042            "build",
4043            "--content",
4044            content.path().to_str().unwrap(),
4045            "--template",
4046            templates.path().to_str().unwrap(),
4047            "--output",
4048            output.path().to_str().unwrap(),
4049            "--quiet",
4050        ];
4051        let (inv, matches) = Cli::parse_and_dispatch(argv).unwrap();
4052        assert_same_variant(&inv, &CliInvocation::Build);
4053        let result = dispatch_invocation(inv, &matches);
4054        // Build may produce warnings on empty input but should not
4055        // hard-fail.
4056        let _ = result;
4057    }
4058
4059    #[test]
4060    fn run_deploy_with_none_target_invokes_noop_adapter() {
4061        // run_deploy with --target none uses the no-op adapter, which
4062        // is purely a print + Ok, so the test can drive the full body
4063        // including pipeline execution + adapter dispatch without
4064        // hitting any network.
4065        let _g = ssg_check_guard();
4066
4067        let content = tempdir().unwrap();
4068        let templates = tempdir().unwrap();
4069        let output = tempdir().unwrap();
4070        let argv = [
4071            "ssg",
4072            "deploy",
4073            "--target",
4074            "none",
4075            "--content",
4076            content.path().to_str().unwrap(),
4077            "--template",
4078            templates.path().to_str().unwrap(),
4079            "--output",
4080            output.path().to_str().unwrap(),
4081            "--quiet",
4082        ];
4083        let (inv, matches) = Cli::parse_and_dispatch(argv).unwrap();
4084        assert_same_variant(
4085            &inv,
4086            &CliInvocation::Deploy {
4087                target: String::new(),
4088            },
4089        );
4090        let result = dispatch_invocation(inv, &matches);
4091        let _ = result;
4092    }
4093
4094    #[test]
4095    fn run_legacy_happy_path_with_empty_content_dir() {
4096        // Same content/templates as run_check but without --validate, so
4097        // the full legacy code path executes (build_pipeline +
4098        // execute_build_pipeline_with). Empty dirs make the build a
4099        // no-op, no server is started because --serve is not set.
4100        let _g = ssg_check_guard();
4101
4102        let content = tempdir().unwrap();
4103        let templates = tempdir().unwrap();
4104        let output = tempdir().unwrap();
4105        let argv = [
4106            "ssg",
4107            "--content",
4108            content.path().to_str().unwrap(),
4109            "--template",
4110            templates.path().to_str().unwrap(),
4111            "--output",
4112            output.path().to_str().unwrap(),
4113            "--quiet",
4114        ];
4115        let (inv, matches) = Cli::parse_and_dispatch(argv).unwrap();
4116        assert_same_variant(&inv, &CliInvocation::Legacy);
4117        let result = dispatch_invocation(inv, &matches);
4118        let _ = result;
4119    }
4120    // ── run_with_argv / run() entry-point coverage ──────────────────
4121
4122    /// Builds an owned `OsString` argv from string literals.
4123    fn os_argv(args: &[&str]) -> Vec<std::ffi::OsString> {
4124        args.iter().map(std::ffi::OsString::from).collect()
4125    }
4126
4127    #[test]
4128    fn run_with_argv_legacy_validate_short_circuits_cleanly() {
4129        let _g = ssg_check_guard();
4130
4131        let content = tempdir().unwrap();
4132        let templates = tempdir().unwrap();
4133        let output = tempdir().unwrap();
4134        let argv = os_argv(&[
4135            "ssg",
4136            "--content",
4137            content.path().to_str().unwrap(),
4138            "--template",
4139            templates.path().to_str().unwrap(),
4140            "--output",
4141            output.path().to_str().unwrap(),
4142            "--quiet",
4143            "--validate",
4144        ]);
4145        // Legacy parser defines `--trace`, so the `try_contains_id`
4146        // branch that calls `get_flag` executes here.
4147        let result = run_with_argv(argv);
4148        assert!(result.is_ok());
4149    }
4150
4151    #[test]
4152    fn run_with_argv_subcommand_check_lacks_trace_id() {
4153        let _g = ssg_check_guard();
4154
4155        let content = tempdir().unwrap();
4156        let templates = tempdir().unwrap();
4157        let output = tempdir().unwrap();
4158        let argv = os_argv(&[
4159            "ssg",
4160            "check",
4161            "--content",
4162            content.path().to_str().unwrap(),
4163            "--template",
4164            templates.path().to_str().unwrap(),
4165            "--output",
4166            output.path().to_str().unwrap(),
4167            "--quiet",
4168        ]);
4169        // The subcommand parser does not define `--trace`, so this
4170        // walks the `unwrap_or(false)` fallback branch.
4171        let result = run_with_argv(argv);
4172        assert!(result.is_ok());
4173    }
4174
4175    /// Environment gate for [`run_entrypoint_child`].
4176    const RUN_CHILD_ENV: &str = "SSG_TEST_RUN_ENTRYPOINT_CHILD";
4177
4178    #[test]
4179    fn run_entrypoint_child() {
4180        // Inert unless spawned by
4181        // `run_exits_with_clap_error_code_in_child_process`. In the
4182        // child process, the libtest harness argv
4183        // (`--exact <name> ...`) is not valid ssg argv, so `run()`
4184        // reaches `Err(e) => e.exit()` and terminates the process
4185        // with clap's parse-failure exit code (2).
4186        if env::var(RUN_CHILD_ENV).is_err() {
4187            return;
4188        }
4189        let _ = run();
4190    }
4191
4192    #[test]
4193    fn run_exits_with_clap_error_code_in_child_process() {
4194        // Drives the real `run()` entry point (process argv +
4195        // `clap::Error::exit`) in a child process so the exit does
4196        // not tear down this harness.
4197        let exe = env::current_exe().unwrap();
4198        let output = std::process::Command::new(exe)
4199            .args([
4200                "--exact",
4201                "tests::run_entrypoint_child",
4202                "--test-threads=1",
4203            ])
4204            .env(RUN_CHILD_ENV, "1")
4205            .output()
4206            .unwrap();
4207        assert_eq!(output.status.code(), Some(2));
4208    }
4209
4210    // ── run_audit ───────────────────────────────────────────────────
4211
4212    #[test]
4213    fn dispatch_invocation_audit_with_missing_subcommand_returns_validation_error(
4214    ) {
4215        let matches =
4216            Cli::subcommand_app().try_get_matches_from(["ssg"]).unwrap();
4217        let err =
4218            dispatch_invocation(CliInvocation::Audit, &matches).unwrap_err();
4219        assert!(
4220            matches!(err, SsgError::Validation { field, .. } if field == "subcommand")
4221        );
4222    }
4223
4224    #[test]
4225    fn run_audit_explain_lists_gates_without_running_them() {
4226        // `--explain` early-exits inside `cmd::audit::run` with
4227        // `Outcome::Pass`, so this drives the full `run_audit`
4228        // wrapper without needing a built site.
4229        let (inv, matches) =
4230            Cli::parse_and_dispatch(["ssg", "audit", "--explain"]).unwrap();
4231        let result = dispatch_invocation(inv, &matches);
4232        assert!(result.is_ok());
4233    }
4234
4235    // ── run_legacy error + banner + serve branches ─────────────────
4236
4237    #[test]
4238    fn run_legacy_with_bad_config_file_maps_to_validation_error() {
4239        let _g = ssg_check_guard();
4240
4241        let (inv, matches) = Cli::parse_and_dispatch([
4242            "ssg",
4243            "--config",
4244            "/definitely/does/not/exist.toml",
4245        ])
4246        .unwrap();
4247        let err = dispatch_invocation(inv, &matches).unwrap_err();
4248        assert!(
4249            matches!(err, SsgError::Validation { field, .. } if field == "config")
4250        );
4251    }
4252
4253    #[test]
4254    fn run_legacy_validate_with_invalid_schema_propagates_error() {
4255        let _g = ssg_check_guard();
4256
4257        let content = tempdir().unwrap();
4258        fs::write(
4259            content.path().join("content.schema.toml"),
4260            "not [valid toml",
4261        )
4262        .unwrap();
4263        let (inv, matches) = Cli::parse_and_dispatch([
4264            "ssg",
4265            "--content",
4266            content.path().to_str().unwrap(),
4267            "--quiet",
4268            "--validate",
4269        ])
4270        .unwrap();
4271        let err = dispatch_invocation(inv, &matches).unwrap_err();
4272        assert!(
4273            matches!(err, SsgError::Validation { field, .. } if field == "content")
4274        );
4275    }
4276
4277    #[test]
4278    fn run_legacy_with_jobs_after_pool_init_errors() {
4279        let _g = ssg_check_guard();
4280        force_rayon_global_pool_init();
4281
4282        let content = tempdir().unwrap();
4283        let (inv, matches) = Cli::parse_and_dispatch([
4284            "ssg",
4285            "--content",
4286            content.path().to_str().unwrap(),
4287            "--jobs",
4288            "2",
4289            "--quiet",
4290        ])
4291        .unwrap();
4292        let err = dispatch_invocation(inv, &matches).unwrap_err();
4293        assert!(
4294            matches!(err, SsgError::Validation { field, .. } if field == "jobs")
4295        );
4296    }
4297
4298    #[test]
4299    fn run_legacy_nonquiet_prints_banner_and_builds() {
4300        let _g = ssg_check_guard();
4301
4302        let content = tempdir().unwrap();
4303        let templates = tempdir().unwrap();
4304        let output = tempdir().unwrap();
4305        let (inv, matches) = Cli::parse_and_dispatch([
4306            "ssg",
4307            "--content",
4308            content.path().to_str().unwrap(),
4309            "--template",
4310            templates.path().to_str().unwrap(),
4311            "--output",
4312            output.path().to_str().unwrap(),
4313        ])
4314        .unwrap();
4315        let result = dispatch_invocation(inv, &matches);
4316        assert!(result.is_ok());
4317    }
4318
4319    #[test]
4320    fn run_legacy_with_missing_content_dir_propagates_build_error() {
4321        let _g = ssg_check_guard();
4322
4323        let temp = tempdir().unwrap();
4324        let missing_content = temp.path().join("missing-content");
4325        let missing_templates = temp.path().join("missing-templates");
4326        let output = temp.path().join("public");
4327        let (inv, matches) = Cli::parse_and_dispatch([
4328            "ssg",
4329            "--content",
4330            missing_content.to_str().unwrap(),
4331            "--template",
4332            missing_templates.to_str().unwrap(),
4333            "--output",
4334            output.to_str().unwrap(),
4335            "--quiet",
4336        ])
4337        .unwrap();
4338        let result = dispatch_invocation(inv, &matches);
4339        assert!(result.is_err());
4340    }
4341
4342    #[test]
4343    fn run_legacy_serve_flag_boots_dev_server_and_returns() {
4344        let _g = ssg_check_guard();
4345
4346        // Hold the dev-server port so `http_handle::Server::start`
4347        // fails to bind and returns instead of blocking; the
4348        // `HttpTransport` shim swallows that error into `Ok(())`.
4349        let _port_guard =
4350            std::net::TcpListener::bind((cmd::DEFAULT_HOST, cmd::DEFAULT_PORT))
4351                .ok();
4352
4353        let content = tempdir().unwrap();
4354        let templates = tempdir().unwrap();
4355        let output = tempdir().unwrap();
4356        let (inv, matches) = Cli::parse_and_dispatch([
4357            "ssg",
4358            "--content",
4359            content.path().to_str().unwrap(),
4360            "--template",
4361            templates.path().to_str().unwrap(),
4362            "--output",
4363            output.path().to_str().unwrap(),
4364            "--serve",
4365            output.path().to_str().unwrap(),
4366            "--quiet",
4367        ])
4368        .unwrap();
4369        let result = dispatch_invocation(inv, &matches);
4370        assert!(result.is_ok());
4371    }
4372
4373    // ── run_subcommand (`build` / `dev`) error + serve branches ────
4374
4375    #[test]
4376    fn run_subcommand_build_with_bad_config_returns_validation_error() {
4377        let _g = ssg_check_guard();
4378
4379        let (inv, matches) = Cli::parse_and_dispatch([
4380            "ssg",
4381            "build",
4382            "--config",
4383            "/definitely/does/not/exist.toml",
4384        ])
4385        .unwrap();
4386        let err = dispatch_invocation(inv, &matches).unwrap_err();
4387        assert!(
4388            matches!(err, SsgError::Validation { field, .. } if field == "config")
4389        );
4390    }
4391
4392    /// Forces the global Rayon pool to exist so a subsequent
4393    /// `--jobs N` request must fail with `SsgError::Validation`.
4394    fn force_rayon_global_pool_init() {
4395        let _ = rayon::ThreadPoolBuilder::new().build_global();
4396    }
4397
4398    #[test]
4399    fn run_subcommand_build_with_jobs_after_pool_init_errors() {
4400        let _g = ssg_check_guard();
4401        force_rayon_global_pool_init();
4402
4403        let content = tempdir().unwrap();
4404        let (inv, matches) = Cli::parse_and_dispatch([
4405            "ssg",
4406            "build",
4407            "--content",
4408            content.path().to_str().unwrap(),
4409            "--jobs",
4410            "2",
4411            "--quiet",
4412        ])
4413        .unwrap();
4414        let err = dispatch_invocation(inv, &matches).unwrap_err();
4415        assert!(
4416            matches!(err, SsgError::Validation { field, .. } if field == "jobs")
4417        );
4418    }
4419
4420    #[test]
4421    fn run_subcommand_build_nonquiet_prints_banner() {
4422        let _g = ssg_check_guard();
4423
4424        let content = tempdir().unwrap();
4425        let templates = tempdir().unwrap();
4426        let output = tempdir().unwrap();
4427        let (inv, matches) = Cli::parse_and_dispatch([
4428            "ssg",
4429            "build",
4430            "--content",
4431            content.path().to_str().unwrap(),
4432            "--template",
4433            templates.path().to_str().unwrap(),
4434            "--output",
4435            output.path().to_str().unwrap(),
4436        ])
4437        .unwrap();
4438        let result = dispatch_invocation(inv, &matches);
4439        assert!(result.is_ok());
4440    }
4441
4442    #[test]
4443    fn run_subcommand_build_with_missing_content_dir_propagates_error() {
4444        let _g = ssg_check_guard();
4445
4446        let temp = tempdir().unwrap();
4447        let (inv, matches) = Cli::parse_and_dispatch([
4448            "ssg",
4449            "build",
4450            "--content",
4451            temp.path().join("missing-content").to_str().unwrap(),
4452            "--template",
4453            temp.path().join("missing-templates").to_str().unwrap(),
4454            "--output",
4455            temp.path().join("public").to_str().unwrap(),
4456            "--quiet",
4457        ])
4458        .unwrap();
4459        let result = dispatch_invocation(inv, &matches);
4460        assert!(result.is_err());
4461    }
4462
4463    #[test]
4464    fn run_subcommand_dev_serves_and_returns_when_port_is_held() {
4465        let _g = ssg_check_guard();
4466
4467        let _port_guard =
4468            std::net::TcpListener::bind((cmd::DEFAULT_HOST, cmd::DEFAULT_PORT))
4469                .ok();
4470
4471        let content = tempdir().unwrap();
4472        let templates = tempdir().unwrap();
4473        let output = tempdir().unwrap();
4474        let (inv, matches) = Cli::parse_and_dispatch([
4475            "ssg",
4476            "dev",
4477            "--content",
4478            content.path().to_str().unwrap(),
4479            "--template",
4480            templates.path().to_str().unwrap(),
4481            "--output",
4482            output.path().to_str().unwrap(),
4483            "--quiet",
4484        ])
4485        .unwrap();
4486        assert_same_variant(&inv, &CliInvocation::Dev);
4487        let result = dispatch_invocation(inv, &matches);
4488        assert!(result.is_ok());
4489    }
4490
4491    // ── run_check error + println branches ─────────────────────────
4492
4493    #[test]
4494    fn run_check_with_bad_config_returns_validation_error() {
4495        let _g = ssg_check_guard();
4496
4497        let (inv, matches) = Cli::parse_and_dispatch([
4498            "ssg",
4499            "check",
4500            "--config",
4501            "/definitely/does/not/exist.toml",
4502        ])
4503        .unwrap();
4504        let err = dispatch_invocation(inv, &matches).unwrap_err();
4505        assert!(
4506            matches!(err, SsgError::Validation { field, .. } if field == "config")
4507        );
4508    }
4509
4510    #[test]
4511    fn run_check_with_jobs_after_pool_init_errors() {
4512        let _g = ssg_check_guard();
4513        force_rayon_global_pool_init();
4514
4515        let content = tempdir().unwrap();
4516        let (inv, matches) = Cli::parse_and_dispatch([
4517            "ssg",
4518            "check",
4519            "--content",
4520            content.path().to_str().unwrap(),
4521            "--jobs",
4522            "2",
4523            "--quiet",
4524        ])
4525        .unwrap();
4526        let err = dispatch_invocation(inv, &matches).unwrap_err();
4527        assert!(
4528            matches!(err, SsgError::Validation { field, .. } if field == "jobs")
4529        );
4530    }
4531
4532    #[test]
4533    fn run_check_with_invalid_schema_fails_validation() {
4534        let _g = ssg_check_guard();
4535
4536        let content = tempdir().unwrap();
4537        fs::write(
4538            content.path().join("content.schema.toml"),
4539            "not [valid toml",
4540        )
4541        .unwrap();
4542        let (inv, matches) = Cli::parse_and_dispatch([
4543            "ssg",
4544            "check",
4545            "--content",
4546            content.path().to_str().unwrap(),
4547            "--quiet",
4548        ])
4549        .unwrap();
4550        let err = dispatch_invocation(inv, &matches).unwrap_err();
4551        assert!(
4552            matches!(err, SsgError::Validation { field, .. } if field == "content")
4553        );
4554    }
4555
4556    #[test]
4557    fn run_check_with_invalid_utf8_markdown_fails_before_compile() {
4558        let _g = ssg_check_guard();
4559
4560        // No schema file, so `validate_only` passes; the invalid
4561        // UTF-8 markdown then fails a `before_compile` validator.
4562        let content = tempdir().unwrap();
4563        fs::write(content.path().join("fail.md"), [0xFF, 0xFE, 0xFD]).unwrap();
4564        let templates = tempdir().unwrap();
4565        let output = tempdir().unwrap();
4566        let (inv, matches) = Cli::parse_and_dispatch([
4567            "ssg",
4568            "check",
4569            "--content",
4570            content.path().to_str().unwrap(),
4571            "--template",
4572            templates.path().to_str().unwrap(),
4573            "--output",
4574            output.path().to_str().unwrap(),
4575            "--quiet",
4576        ])
4577        .unwrap();
4578        let result = dispatch_invocation(inv, &matches);
4579        assert!(result.is_err());
4580    }
4581
4582    #[test]
4583    fn run_check_nonquiet_prints_success_line() {
4584        let _g = ssg_check_guard();
4585
4586        let content = tempdir().unwrap();
4587        let templates = tempdir().unwrap();
4588        let output = tempdir().unwrap();
4589        let (inv, matches) = Cli::parse_and_dispatch([
4590            "ssg",
4591            "check",
4592            "--content",
4593            content.path().to_str().unwrap(),
4594            "--template",
4595            templates.path().to_str().unwrap(),
4596            "--output",
4597            output.path().to_str().unwrap(),
4598        ])
4599        .unwrap();
4600        let result = dispatch_invocation(inv, &matches);
4601        assert!(result.is_ok());
4602    }
4603
4604    // ── run_deploy error + banner branches ─────────────────────────
4605
4606    #[test]
4607    fn run_deploy_with_bad_config_returns_validation_error() {
4608        let _g = ssg_check_guard();
4609
4610        let (inv, matches) = Cli::parse_and_dispatch([
4611            "ssg",
4612            "deploy",
4613            "--target",
4614            "none",
4615            "--config",
4616            "/definitely/does/not/exist.toml",
4617        ])
4618        .unwrap();
4619        let err = dispatch_invocation(inv, &matches).unwrap_err();
4620        assert!(
4621            matches!(err, SsgError::Validation { field, .. } if field == "config")
4622        );
4623    }
4624
4625    #[test]
4626    fn run_deploy_with_jobs_after_pool_init_errors() {
4627        let _g = ssg_check_guard();
4628        force_rayon_global_pool_init();
4629
4630        let content = tempdir().unwrap();
4631        let (inv, matches) = Cli::parse_and_dispatch([
4632            "ssg",
4633            "deploy",
4634            "--target",
4635            "none",
4636            "--content",
4637            content.path().to_str().unwrap(),
4638            "--jobs",
4639            "2",
4640            "--quiet",
4641        ])
4642        .unwrap();
4643        let err = dispatch_invocation(inv, &matches).unwrap_err();
4644        assert!(
4645            matches!(err, SsgError::Validation { field, .. } if field == "jobs")
4646        );
4647    }
4648
4649    #[test]
4650    fn run_deploy_nonquiet_prints_banner_and_adapter_name() {
4651        let _g = ssg_check_guard();
4652
4653        let content = tempdir().unwrap();
4654        let templates = tempdir().unwrap();
4655        let output = tempdir().unwrap();
4656        let (inv, matches) = Cli::parse_and_dispatch([
4657            "ssg",
4658            "deploy",
4659            "--target",
4660            "none",
4661            "--content",
4662            content.path().to_str().unwrap(),
4663            "--template",
4664            templates.path().to_str().unwrap(),
4665            "--output",
4666            output.path().to_str().unwrap(),
4667        ])
4668        .unwrap();
4669        let result = dispatch_invocation(inv, &matches);
4670        assert!(result.is_ok());
4671    }
4672
4673    #[test]
4674    fn run_deploy_with_missing_content_dir_propagates_build_error() {
4675        let _g = ssg_check_guard();
4676
4677        let temp = tempdir().unwrap();
4678        let (inv, matches) = Cli::parse_and_dispatch([
4679            "ssg",
4680            "deploy",
4681            "--target",
4682            "none",
4683            "--content",
4684            temp.path().join("missing-content").to_str().unwrap(),
4685            "--template",
4686            temp.path().join("missing-templates").to_str().unwrap(),
4687            "--output",
4688            temp.path().join("public").to_str().unwrap(),
4689            "--quiet",
4690        ])
4691        .unwrap();
4692        let result = dispatch_invocation(inv, &matches);
4693        assert!(result.is_err());
4694    }
4695
4696    #[test]
4697    fn run_deploy_with_unknown_target_errors_after_build() {
4698        let _g = ssg_check_guard();
4699
4700        let content = tempdir().unwrap();
4701        let templates = tempdir().unwrap();
4702        let output = tempdir().unwrap();
4703        // Parse a *valid* deploy argv so the `deploy` subcommand
4704        // matches exist, then dispatch with an invalid target to hit
4705        // the `Target::from_cli` error branch after the build.
4706        let (_inv, matches) = Cli::parse_and_dispatch([
4707            "ssg",
4708            "deploy",
4709            "--target",
4710            "none",
4711            "--content",
4712            content.path().to_str().unwrap(),
4713            "--template",
4714            templates.path().to_str().unwrap(),
4715            "--output",
4716            output.path().to_str().unwrap(),
4717            "--quiet",
4718        ])
4719        .unwrap();
4720        let err = dispatch_invocation(
4721            CliInvocation::Deploy {
4722                target: "bogus".to_string(),
4723            },
4724            &matches,
4725        )
4726        .unwrap_err();
4727        assert!(
4728            matches!(err, SsgError::Validation { field, .. } if field == "deploy.target")
4729        );
4730    }
4731
4732    // ── Failpoint-driven error branches (feature-gated) ────────────
4733    //
4734    // These failpoints sit behind seams (`initialize_logging_checked`,
4735    // `run_on_serve_checked`) whose real implementations cannot fail
4736    // from CLI-reachable inputs. Serialised via `ssg_check_guard` —
4737    // only tests holding that guard can reach these failpoints, so
4738    // the process-global failpoint registry stays race-free.
4739    #[cfg(feature = "test-fault-injection")]
4740    mod failpoints {
4741        use super::*;
4742
4743        /// RAII guard that disables a failpoint on drop.
4744        struct FailGuard<'a>(&'a str);
4745        impl Drop for FailGuard<'_> {
4746            fn drop(&mut self) {
4747                let _ = fail::cfg(self.0, "off");
4748            }
4749        }
4750
4751        #[test]
4752        fn run_with_argv_propagates_injected_logging_init_failure() {
4753            let _g = ssg_check_guard();
4754            let _fp = FailGuard("lib::initialize-logging");
4755            fail::cfg("lib::initialize-logging", "return").unwrap();
4756
4757            let argv = os_argv(&["ssg", "--validate", "--quiet"]);
4758            let err = run_with_argv(argv).unwrap_err();
4759            assert!(format!("{err:?}")
4760                .contains("injected: lib::initialize-logging"));
4761        }
4762
4763        #[test]
4764        fn run_legacy_serve_propagates_injected_on_serve_failure() {
4765            let _g = ssg_check_guard();
4766            let _fp = FailGuard("lib::run-on-serve");
4767            fail::cfg("lib::run-on-serve", "return").unwrap();
4768
4769            let content = tempdir().unwrap();
4770            let templates = tempdir().unwrap();
4771            let output = tempdir().unwrap();
4772            let (inv, matches) = Cli::parse_and_dispatch([
4773                "ssg",
4774                "--content",
4775                content.path().to_str().unwrap(),
4776                "--template",
4777                templates.path().to_str().unwrap(),
4778                "--output",
4779                output.path().to_str().unwrap(),
4780                "--serve",
4781                output.path().to_str().unwrap(),
4782                "--quiet",
4783            ])
4784            .unwrap();
4785            let err = dispatch_invocation(inv, &matches).unwrap_err();
4786            assert!(format!("{err:?}").contains("injected: lib::run-on-serve"));
4787        }
4788
4789        #[test]
4790        fn run_subcommand_dev_propagates_injected_on_serve_failure() {
4791            let _g = ssg_check_guard();
4792            let _fp = FailGuard("lib::run-on-serve");
4793            fail::cfg("lib::run-on-serve", "return").unwrap();
4794
4795            let content = tempdir().unwrap();
4796            let templates = tempdir().unwrap();
4797            let output = tempdir().unwrap();
4798            let (inv, matches) = Cli::parse_and_dispatch([
4799                "ssg",
4800                "dev",
4801                "--content",
4802                content.path().to_str().unwrap(),
4803                "--template",
4804                templates.path().to_str().unwrap(),
4805                "--output",
4806                output.path().to_str().unwrap(),
4807                "--quiet",
4808            ])
4809            .unwrap();
4810            let err = dispatch_invocation(inv, &matches).unwrap_err();
4811            assert!(format!("{err:?}").contains("injected: lib::run-on-serve"));
4812        }
4813
4814        /// `Paths::validate` can only reach `symlink_metadata_checked`'s
4815        /// error arm through this failpoint: once `path.exists()` is
4816        /// `true`, `Path::symlink_metadata` cannot be made to fail
4817        /// deterministically from a CLI-reachable input.
4818        #[test]
4819        fn paths_validate_propagates_injected_symlink_metadata_failure() {
4820            let _g = ssg_check_guard();
4821            let _fp = FailGuard("lib::symlink-metadata");
4822            fail::cfg("lib::symlink-metadata", "return").unwrap();
4823
4824            let tmp = tempdir().unwrap();
4825            let paths = Paths {
4826                site: tmp.path().to_path_buf(),
4827                content: tmp.path().to_path_buf(),
4828                build: tmp.path().to_path_buf(),
4829                template: tmp.path().to_path_buf(),
4830            };
4831            let err = paths.validate().unwrap_err();
4832            assert!(
4833                format!("{err:?}").contains("injected: lib::symlink-metadata")
4834            );
4835        }
4836
4837        /// `create_directories`'s `is_safe_path` error arm only fires
4838        /// when `Path::canonicalize` fails on an *existing* path — not
4839        /// constructible deterministically from CLI-reachable inputs,
4840        /// so this is only reachable through the failpoint.
4841        #[test]
4842        fn create_directories_propagates_injected_is_safe_path_failure() {
4843            let _g = ssg_check_guard();
4844            let _fp = FailGuard("lib::is-safe-path");
4845            fail::cfg("lib::is-safe-path", "return").unwrap();
4846
4847            let tmp = tempdir().unwrap();
4848            let paths = Paths {
4849                site: tmp.path().join("site"),
4850                content: tmp.path().join("content"),
4851                build: tmp.path().join("build"),
4852                template: tmp.path().join("template"),
4853            };
4854            let err = create_directories(&paths).unwrap_err();
4855            assert!(format!("{err:?}").contains("injected: lib::is-safe-path"));
4856        }
4857    }
4858}
4859
4860#[cfg(test)]
4861mod proptests {
4862    use proptest::prelude::*;
4863
4864    proptest! {
4865        #![proptest_config(ProptestConfig::with_cases(1000))]
4866
4867        /// `frontmatter_gen::extract` must never panic on arbitrary input.
4868        #[test]
4869        fn parse_frontmatter_never_panics(input in "\\PC*") {
4870            let _ = frontmatter_gen::extract(&input);
4871        }
4872
4873        /// Compiling arbitrary Markdown via pulldown-cmark must never panic
4874        /// and must produce valid UTF-8 (guaranteed by `String`).
4875        #[test]
4876        fn compile_markdown_never_panics(input in "\\PC*") {
4877            use pulldown_cmark::{Parser, html};
4878            let parser = Parser::new(&input);
4879            let mut output = String::new();
4880            html::push_html(&mut output, parser);
4881            // output is a String — valid UTF-8 by construction.
4882            // Reaching this point without a panic is the property.
4883            drop(output);
4884        }
4885
4886        /// Reading time of non-empty text must be at least 1 minute.
4887        #[test]
4888        fn reading_time_at_least_one(input in ".{1,5000}") {
4889            let word_count = input.split_whitespace().count();
4890            let minutes = (word_count / 200).max(1);
4891            prop_assert!(minutes >= 1, "reading time was {}", minutes);
4892        }
4893    }
4894}