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