Skip to main content

ssg/
lib.rs

1#![forbid(unsafe_code)]
2// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4#![doc = include_str!("../README.md")]
5#![doc(
6    html_favicon_url = "https://cloudcdn.pro/static-site-generator/v1/favicon.ico",
7    html_logo_url = "https://cloudcdn.pro/static-site-generator/v1/logos/static-site-generator.svg",
8    html_root_url = "https://docs.rs/ssg"
9)]
10#![crate_name = "ssg"]
11#![crate_type = "lib"]
12
13/// Fault injection macro. When the `test-fault-injection` feature is
14/// enabled, delegates to the `fail` crate's real `fail_point!`. In
15/// normal builds this compiles to nothing.
16#[cfg(feature = "test-fault-injection")]
17macro_rules! fail_point {
18    ($name:expr, $body:expr) => {
19        fail::fail_point!($name, $body);
20    };
21}
22#[cfg(not(feature = "test-fault-injection"))]
23macro_rules! fail_point {
24    ($name:expr, $body:expr) => {};
25}
26
27/// Shared bounded directory walkers used by every plugin's
28/// `collect_*_files` helper.
29#[allow(unreachable_pub)]
30pub(crate) mod walk;
31
32/// Test-only utilities shared across unit test modules.
33#[cfg(test)]
34#[allow(unreachable_pub, clippy::unwrap_used, clippy::expect_used)]
35pub(crate) mod test_support {
36    use std::sync::Once;
37
38    static LOGGER: Once = Once::new();
39
40    /// Raises `log::max_level()` to Trace so `log::info!` / `log::warn!`
41    /// macro bodies execute their format arguments and are counted by
42    /// LLVM region coverage. We only bump the filter level; no logger
43    /// backend is installed, so it does not conflict with tests that
44    /// install their own (e.g. the `env_logger` init test in lib.rs).
45    /// Safe to call from any number of tests or fixtures.
46    pub fn init_logger() {
47        LOGGER.call_once(|| {
48            log::set_max_level(log::LevelFilter::Trace);
49        });
50    }
51}
52
53// Standard library imports
54use std::{
55    fs,
56    path::{Path, PathBuf},
57};
58
59use crate::cmd::{Cli, SsgConfig};
60
61// Third-party imports
62use anyhow::{Context, Result};
63use log::info;
64
65/// Returns the current time as an ISO 8601 UTC string.
66#[must_use]
67#[allow(clippy::many_single_char_names)]
68pub fn now_iso() -> String {
69    use std::time::{SystemTime, UNIX_EPOCH};
70    let dur = SystemTime::now()
71        .duration_since(UNIX_EPOCH)
72        .unwrap_or_default();
73    let secs = dur.as_secs();
74    let (sec, min, hour) = (secs % 60, (secs / 60) % 60, (secs / 3600) % 24);
75    let days = secs / 86400;
76    let (year, month, day) = days_to_ymd(days);
77    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z")
78}
79
80/// Civil days algorithm (Howard Hinnant) — converts days since Unix epoch to (Y, M, D).
81const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
82    let z = days + 719_468;
83    let era = z / 146_097;
84    let doe = z - era * 146_097;
85    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
86    let y = yoe + era * 400;
87    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
88    let mp = (5 * doy + 2) / 153;
89    let d = doy - (153 * mp + 2) / 5 + 1;
90    let m = if mp < 10 { mp + 3 } else { mp - 9 };
91    let y = if m <= 2 { y + 1 } else { y };
92    (y, m, d)
93}
94
95/// Automated WCAG accessibility checker.
96pub mod accessibility;
97/// AI-readiness content hooks (GEO/AEO).
98pub mod ai;
99/// Asset fingerprinting, SRI hashes, and minification.
100pub mod assets;
101/// Content fingerprinting for incremental builds.
102pub mod cache;
103pub mod cmd;
104/// Typed content collection API — `get_collection` / `get_entry` (issue #456).
105pub mod collections;
106/// Typed content collections with frontmatter schema validation.
107pub mod content;
108/// Content Security Policy hardening: inline extraction + SRI.
109pub mod csp;
110/// Page dependency graph for incremental rebuilds.
111pub mod depgraph;
112/// Deployment adapter generation.
113pub mod deploy;
114/// Draft content filtering.
115pub mod drafts;
116/// Shared frontmatter extraction and `.meta.json` sidecar files.
117pub mod frontmatter;
118/// File system operations: directory copying, safety validation, and traversal.
119pub mod fs_ops;
120/// Syntax highlighting for code blocks.
121pub mod highlight;
122/// Internationalisation: hreflang injection, per-locale sitemaps, lang switcher.
123pub mod i18n;
124/// Image optimization with WebP and responsive srcset.
125#[cfg(feature = "image-optimization")]
126pub mod image_plugin;
127/// Interactive islands — lazy-hydrating Web Components.
128pub mod islands;
129/// WebSocket-based live-reload script injection.
130pub mod livereload;
131/// Local LLM content augmentation plugin.
132pub mod llm;
133/// Logging infrastructure.
134pub mod logging;
135/// GitHub Flavored Markdown (GFM) extensions: tables, strikethrough, task lists.
136pub mod markdown_ext;
137/// Auto-generates Open Graph social card images from page metadata.
138pub mod og_image;
139/// Optional OpenTelemetry build-pipeline tracing (issue #422).
140///
141/// Compiles to an empty stub when the `otel` feature is off, so
142/// callers can always reference [`otel::init_if_enabled`] without
143/// `#[cfg]`-guarding every call site.
144pub mod otel;
145/// Pagination for listing pages.
146pub mod pagination;
147/// Build pipeline orchestration.
148#[allow(unreachable_pub)]
149pub(crate) mod pipeline;
150/// Lifecycle hook plugin system.
151pub mod plugin;
152/// Built-in plugins for common tasks.
153pub mod plugins;
154/// Post-processing fixes for staticdatagen output.
155pub mod postprocess;
156/// Command-line argument processing and site compilation.
157pub mod process;
158/// Build-time CycloneDX SBOM generation (issue #457).
159pub mod sbom;
160/// Project scaffolding for `--new`.
161pub mod scaffold;
162/// JSON Schema generation for configuration.
163pub mod schema;
164/// Client-side search index generator and search UI.
165pub mod search;
166/// SEO plugins: meta tags, robots.txt, and canonical URLs.
167pub mod seo;
168/// Dev server infrastructure.
169pub mod server;
170/// Shortcode expansion for Markdown content.
171pub mod shortcodes;
172/// High-performance streaming file processor.
173pub mod stream;
174/// Streaming compilation for large (100K+ page) sites.
175pub mod streaming;
176/// Taxonomy generation (tags, categories).
177pub mod taxonomy;
178/// Template engine integration (MiniJinja).
179#[cfg(feature = "templates")]
180pub mod template_engine;
181/// Template rendering plugin.
182#[cfg(feature = "templates")]
183pub mod template_plugin;
184/// File-watching for live rebuild.
185pub mod watch;
186/// Re-exports
187pub use staticdatagen;
188
189// Re-export everything that was previously pub in lib.rs
190pub use fs_ops::{
191    collect_files_recursive, copy_dir_all, copy_dir_all_async,
192    copy_dir_with_progress, is_safe_path, verify_and_copy_files,
193    verify_and_copy_files_async, verify_file_safety,
194};
195pub use logging::{create_log_file, log_arguments, log_initialization};
196pub use pipeline::{compile_site, execute_build_pipeline};
197pub use server::{
198    generate_locale_redirect, handle_server, prepare_serve_dir, serve_site,
199    serve_site_with, HttpTransport, ServeTransport,
200};
201
202/// Maximum directory nesting depth for all traversal operations.
203/// Prevents stack overflow from pathological or circular directory trees.
204/// 128 levels accommodates any realistic project structure.
205pub const MAX_DIR_DEPTH: usize = 128;
206
207/// Represents the necessary directory paths for the site generator.
208#[derive(Debug, Clone)]
209pub struct Paths {
210    /// The site output directory
211    pub site: PathBuf,
212    /// The content directory
213    pub content: PathBuf,
214    /// The build directory
215    pub build: PathBuf,
216    /// The template directory
217    pub template: PathBuf,
218}
219
220impl Paths {
221    /// Creates a new builder for configuring Paths
222    #[must_use]
223    pub fn builder() -> PathsBuilder {
224        PathsBuilder::default()
225    }
226
227    /// Creates paths with default directories
228    #[must_use]
229    pub fn default_paths() -> Self {
230        Self {
231            site: PathBuf::from("public"),
232            content: PathBuf::from("content"),
233            build: PathBuf::from("build"),
234            template: PathBuf::from("templates"),
235        }
236    }
237}
238// Modify the validate method in Paths impl
239impl Paths {
240    /// Validates all paths in the configuration
241    pub fn validate(&self) -> Result<()> {
242        // Check for path traversal and other security concerns
243        for (name, path) in [
244            ("site", &self.site),
245            ("content", &self.content),
246            ("build", &self.build),
247            ("template", &self.template),
248        ] {
249            // For non-existent paths, validate their components
250            let path_str = path.to_string_lossy();
251            if path_str.contains("..") {
252                anyhow::bail!(
253                    "{} path contains directory traversal: {}",
254                    name,
255                    path.display()
256                );
257            }
258            if path_str.contains("//") {
259                anyhow::bail!(
260                    "{} path contains invalid double slashes: {}",
261                    name,
262                    path.display()
263                );
264            }
265
266            // If path exists, perform additional checks
267            if path.exists() {
268                let metadata = path
269                    .symlink_metadata()
270                    .context(format!("Failed to get metadata for {name}"))?;
271
272                if metadata.file_type().is_symlink() {
273                    anyhow::bail!(
274                        "{} path is a symlink which is not allowed: {}",
275                        name,
276                        path.display()
277                    );
278                }
279            }
280        }
281
282        Ok(())
283    }
284}
285
286/// Builder for creating Paths configurations
287#[derive(Debug, Default, Clone)]
288pub struct PathsBuilder {
289    /// The site output directory
290    pub site: Option<PathBuf>,
291    /// The content directory
292    pub content: Option<PathBuf>,
293    /// The build directory
294    pub build: Option<PathBuf>,
295    /// The template directory
296    pub template: Option<PathBuf>,
297}
298
299impl PathsBuilder {
300    /// Sets the site output directory
301    pub fn site<P: Into<PathBuf>>(mut self, path: P) -> Self {
302        self.site = Some(path.into());
303        self
304    }
305
306    /// Sets the content directory
307    pub fn content<P: Into<PathBuf>>(mut self, path: P) -> Self {
308        self.content = Some(path.into());
309        self
310    }
311
312    /// Sets the build directory
313    pub fn build_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
314        self.build = Some(path.into());
315        self
316    }
317
318    /// Sets the template directory
319    pub fn template<P: Into<PathBuf>>(mut self, path: P) -> Self {
320        self.template = Some(path.into());
321        self
322    }
323
324    /// Sets all paths relative to a base directory
325    pub fn relative_to<P: AsRef<Path>>(self, base: P) -> Self {
326        let base = base.as_ref();
327        self.site(base.join("public"))
328            .content(base.join("content"))
329            .build_dir(base.join("build"))
330            .template(base.join("templates"))
331    }
332
333    /// Builds the Paths configuration
334    ///
335    /// # Returns
336    ///
337    /// * `Result<Paths>` - The configured paths if valid
338    ///
339    /// # Errors
340    ///
341    /// Returns an error if:
342    /// * Required paths are missing
343    /// * Paths are invalid or unsafe
344    /// * Unable to create necessary directories
345    pub fn build(self) -> Result<Paths> {
346        let paths = Paths {
347            site: self.site.unwrap_or_else(|| PathBuf::from("public")),
348            content: self.content.unwrap_or_else(|| PathBuf::from("content")),
349            build: self.build.unwrap_or_else(|| PathBuf::from("build")),
350            template: self
351                .template
352                .unwrap_or_else(|| PathBuf::from("templates")),
353        };
354
355        // Validate the configuration
356        paths.validate()?;
357
358        Ok(paths)
359    }
360}
361
362/// Creates and verifies required directories for site generation.
363///
364/// Ensures all necessary directories exist and are safe to use, creating
365/// them if necessary. Also performs security checks on each directory.
366///
367/// # Arguments
368///
369/// * `paths` - Reference to a Paths struct containing required directory paths
370///
371/// # Returns
372///
373/// * `Ok(())` - If all directories are created/verified successfully
374/// * `Err` - If any directory operation fails
375///
376/// # Examples
377///
378/// ```rust
379/// use std::path::PathBuf;
380/// use ssg::{Paths, create_directories};
381///
382/// fn main() -> anyhow::Result<()> {
383///     let paths = Paths {
384///         site: PathBuf::from("public"),
385///         content: PathBuf::from("content"),
386///         build: PathBuf::from("build"),
387///         template: PathBuf::from("templates"),
388///     };
389///
390///     create_directories(&paths)?;
391///     println!("All directories ready");
392///     Ok(())
393/// }
394/// ```
395///
396/// # Security
397///
398/// Performs the following security checks:
399/// * Path traversal prevention
400/// * Permission validation
401/// * Safe path verification
402pub fn create_directories(paths: &Paths) -> Result<()> {
403    // Ensure each directory exists, with custom error messages for each.
404    for (name, path) in [
405        ("content", &paths.content),
406        ("build", &paths.build),
407        ("site", &paths.site),
408        ("template", &paths.template),
409    ] {
410        fs::create_dir_all(path).with_context(|| {
411            format!(
412                "Failed to create or access {name} directory at path: {}",
413                path.display()
414            )
415        })?;
416    }
417
418    // Path safety check with additional context
419    if !is_safe_path(&paths.content)?
420        || !is_safe_path(&paths.build)?
421        || !is_safe_path(&paths.site)?
422        || !is_safe_path(&paths.template)?
423    {
424        anyhow::bail!("One or more paths are unsafe. Ensure paths do not contain '..' and are accessible.");
425    }
426
427    Ok(())
428}
429
430/// Executes the static site generation process.
431///
432/// Parses CLI arguments, runs the plugin pipeline around compilation,
433/// and starts a local dev server. This function blocks indefinitely
434/// while the server is running.
435pub fn run() -> Result<()> {
436    // Parse CLI arguments first so that `--help` and `--version`
437    // short-circuit *before* the logger emits its banner. clap exits
438    // the process for those flags, so we never reach the lines below.
439    let matches = Cli::build().get_matches();
440
441    logging::initialize_logging()?;
442
443    // OTel build tracing — only initialises if both the `otel` feature
444    // is compiled in AND `--trace` was passed. No-op otherwise.
445    let trace_flag = matches.get_flag("trace");
446    let _ = otel::init_if_enabled(trace_flag);
447
448    info!("Starting site generation process");
449
450    let config = SsgConfig::from_matches(&matches)?;
451    let opts = pipeline::RunOptions::from_matches(&matches);
452
453    // Configure Rayon global thread pool from --jobs flag.
454    if let Some(n) = opts.jobs {
455        rayon::ThreadPoolBuilder::new()
456            .num_threads(n)
457            .build_global()
458            .context("failed to configure Rayon thread pool")?;
459        info!("Rayon thread pool configured with {n} threads");
460    }
461
462    // --validate: validate content schemas and exit without building.
463    if opts.validate_only {
464        return content::validate_only(&config.content_dir);
465    }
466
467    if !opts.quiet {
468        Cli::print_banner();
469    }
470
471    let (plugins, ctx, build_dir, site_dir) =
472        pipeline::build_pipeline(&config, &opts);
473
474    execute_build_pipeline(
475        &plugins,
476        &ctx,
477        &build_dir,
478        &config.content_dir,
479        &site_dir,
480        &config.template_dir,
481        opts.quiet,
482    )?;
483
484    // Only start the dev server if `--serve` was explicitly requested.
485    // Without this guard the binary blocks indefinitely, breaking CI.
486    if config.serve_dir.is_some() {
487        plugins.run_on_serve(&ctx)?;
488        serve_site(&site_dir)
489    } else {
490        Ok(())
491    }
492}
493
494#[cfg(test)]
495#[allow(clippy::unwrap_used, clippy::expect_used)]
496mod tests {
497    use super::*;
498    use crate::cmd::Cli;
499    use crate::logging::{SimpleLogger, DEFAULT_LOG_LEVEL, ENV_LOG_LEVEL};
500    use crate::pipeline::{
501        build_pipeline, execute_build_pipeline, resolve_build_and_site_dirs,
502        RunOptions,
503    };
504    use crate::server::build_serve_address;
505    use anyhow::Result;
506    use log::Log;
507    use std::env;
508    use std::{
509        fs::{self, File},
510        path::PathBuf,
511    };
512    use tempfile::{tempdir, TempDir};
513
514    #[test]
515    fn test_create_log_file_success() -> Result<()> {
516        let temp_dir = tempdir()?;
517        let log_file_path = temp_dir.path().join("test.log");
518
519        let log_file = create_log_file(log_file_path.to_str().unwrap())?;
520        assert!(log_file.metadata()?.is_file());
521
522        Ok(())
523    }
524
525    #[test]
526    fn test_log_arguments() -> Result<()> {
527        let temp_dir = tempdir()?;
528        let log_file_path = temp_dir.path().join("args_log.log");
529        let mut log_file = File::create(&log_file_path)?;
530
531        let date = now_iso();
532        log_arguments(&mut log_file, &date)?;
533
534        let log_content = fs::read_to_string(log_file_path)?;
535        assert!(log_content.contains("process"));
536
537        Ok(())
538    }
539
540    #[test]
541    fn test_create_directories_success() -> Result<()> {
542        let temp_dir = tempdir()?;
543        let base_path = temp_dir.path().to_path_buf();
544
545        let paths = Paths {
546            site: base_path.join("public"),
547            content: base_path.join("content"),
548            build: base_path.join("build"),
549            template: base_path.join("templates"),
550        };
551
552        create_directories(&paths)?;
553
554        // Verify each directory exists
555        assert!(paths.site.exists());
556        assert!(paths.content.exists());
557        assert!(paths.build.exists());
558        assert!(paths.template.exists());
559
560        Ok(())
561    }
562
563    #[cfg(not(target_os = "windows"))] // Unix-only: invalid paths behave differently on Windows
564    #[test]
565    fn test_create_directories_failure() {
566        let invalid_paths = Paths {
567            site: PathBuf::from("/invalid/site"),
568            content: PathBuf::from("/invalid/content"),
569            build: PathBuf::from("/invalid/build"),
570            template: PathBuf::from("/invalid/template"),
571        };
572
573        let result = create_directories(&invalid_paths);
574        assert!(result.is_err());
575    }
576
577    #[test]
578    fn test_copy_dir_all() -> Result<()> {
579        let src_dir = tempdir()?;
580        let dst_dir = tempdir()?;
581
582        let src_file = src_dir.path().join("test_file.txt");
583        _ = File::create(&src_file)?;
584
585        let result = copy_dir_all(src_dir.path(), dst_dir.path());
586        assert!(result.is_ok());
587        assert!(dst_dir.path().join("test_file.txt").exists());
588
589        Ok(())
590    }
591
592    #[test]
593    fn test_verify_and_copy_files_success() -> Result<()> {
594        let temp_dir = tempdir()?;
595        let base_path = temp_dir.path().to_path_buf();
596
597        // Create source directory and test file
598        let src_dir = base_path.join("src");
599        fs::create_dir_all(&src_dir)?;
600        let test_file = src_dir.join("test_file.txt");
601        fs::write(&test_file, "test content")?;
602
603        // Create destination directory
604        let dst_dir = base_path.join("dst");
605
606        // Verify and copy files
607        verify_and_copy_files(&src_dir, &dst_dir)?;
608
609        // Verify the file was copied
610        assert!(dst_dir.join("test_file.txt").exists());
611
612        Ok(())
613    }
614
615    #[test]
616    fn test_verify_and_copy_files_failure() {
617        let src_dir = PathBuf::from("/invalid/src");
618        let dst_dir = PathBuf::from("/invalid/dst");
619
620        let result = verify_and_copy_files(&src_dir, &dst_dir);
621        assert!(result.is_err());
622    }
623
624    #[cfg(not(target_os = "windows"))] // Unix-only: invalid paths behave differently on Windows
625    #[test]
626    fn test_handle_server_failure() {
627        let temp_dir = tempdir().unwrap();
628        let log_file_path = temp_dir.path().join("server_log.log");
629        let mut log_file = File::create(&log_file_path).unwrap();
630
631        let paths = Paths {
632            site: PathBuf::from("/invalid/site"),
633            content: PathBuf::from("/invalid/content"),
634            build: PathBuf::from("/invalid/build"),
635            template: PathBuf::from("/invalid/template"),
636        };
637
638        let serve_dir = temp_dir.path().join("serve");
639        let date = now_iso();
640        let result = handle_server(&mut log_file, &date, &paths, &serve_dir);
641        assert!(result.is_err());
642    }
643
644    #[test]
645    fn test_is_safe_path_safe() -> Result<()> {
646        let temp_dir = tempdir()?;
647        let safe_path = temp_dir.path().to_path_buf().join("safe_path");
648
649        // Create the directory
650        fs::create_dir_all(&safe_path)?;
651
652        // Use the absolute path
653        let absolute_safe_path = safe_path.canonicalize()?;
654        assert!(is_safe_path(&absolute_safe_path)?);
655        Ok(())
656    }
657
658    #[cfg(not(target_os = "windows"))] // Unix-only: invalid paths behave differently on Windows
659    #[test]
660    fn test_create_directories_partial_failure() {
661        let temp_dir = tempdir().unwrap();
662        let valid_path = temp_dir.path().join("valid_dir");
663        let invalid_path = PathBuf::from("/invalid/path");
664
665        let paths = Paths {
666            site: valid_path,
667            content: invalid_path,
668            build: temp_dir.path().join("build"),
669            template: temp_dir.path().join("template"),
670        };
671
672        let result = create_directories(&paths);
673        assert!(result.is_err());
674    }
675
676    #[test]
677    fn test_copy_dir_all_nested() -> Result<()> {
678        let src_dir = tempdir()?;
679        let dst_dir = tempdir()?;
680
681        let nested_dir = src_dir.path().join("nested_dir");
682        fs::create_dir(&nested_dir)?;
683
684        let nested_file = nested_dir.join("nested_file.txt");
685        _ = File::create(&nested_file)?;
686
687        copy_dir_all(src_dir.path(), dst_dir.path())?;
688        assert!(dst_dir.path().join("nested_dir/nested_file.txt").exists());
689
690        Ok(())
691    }
692
693    #[test]
694    fn test_verify_and_copy_files_missing_source() {
695        let src_path = PathBuf::from("/non_existent_dir");
696        let dst_dir = tempdir().unwrap();
697
698        let result = verify_and_copy_files(&src_path, dst_dir.path());
699        assert!(result.is_err());
700    }
701
702    #[test]
703    fn test_handle_server_missing_serve_dir() {
704        let temp_dir = tempdir().unwrap();
705        let log_file_path = temp_dir.path().join("server_log.log");
706        let mut log_file = File::create(&log_file_path).unwrap();
707
708        let paths = Paths {
709            site: temp_dir.path().join("site"),
710            content: temp_dir.path().join("content"),
711            build: temp_dir.path().join("build"),
712            template: temp_dir.path().join("template"),
713        };
714
715        let non_existent_serve_dir = PathBuf::from("/non_existent_serve_dir");
716        let binding = now_iso();
717        let result = handle_server(
718            &mut log_file,
719            &binding,
720            &paths,
721            &non_existent_serve_dir,
722        );
723        assert!(result.is_err());
724    }
725
726    #[test]
727    fn test_collect_files_recursive_empty() -> Result<()> {
728        let temp_dir = tempdir()?;
729        let mut files = Vec::new();
730
731        collect_files_recursive(temp_dir.path(), &mut files)?;
732        assert!(files.is_empty());
733
734        Ok(())
735    }
736
737    #[test]
738    fn test_print_banner() {
739        // Simply call the function to ensure it runs without errors.
740        Cli::print_banner();
741    }
742
743    #[test]
744    fn test_collect_files_recursive_with_nested_directories() -> Result<()> {
745        let temp_dir = tempdir()?;
746        let nested_dir = temp_dir.path().join("nested_dir");
747        fs::create_dir(&nested_dir)?;
748
749        let nested_file = nested_dir.join("nested_file.txt");
750        _ = File::create(&nested_file)?;
751
752        let mut files = Vec::new();
753        collect_files_recursive(temp_dir.path(), &mut files)?;
754
755        assert!(files.contains(&nested_file));
756        assert_eq!(files.len(), 1);
757        Ok(())
758    }
759
760    #[test]
761    fn test_handle_server_start_message() -> Result<()> {
762        let temp_dir = tempdir()?;
763        let log_file_path = temp_dir.path().join("server_log.log");
764        let mut log_file = File::create(&log_file_path)?;
765
766        let paths = Paths {
767            site: temp_dir.path().join("site"),
768            content: temp_dir.path().join("content"),
769            build: temp_dir.path().join("build"),
770            template: temp_dir.path().join("template"),
771        };
772
773        let serve_dir = temp_dir.path().join("serve");
774
775        // Check setup conditions before calling `handle_server`
776        fs::create_dir_all(&serve_dir)?;
777        assert!(serve_dir.exists(), "Expected serve directory to be created");
778
779        // Now, call `handle_server` and check for specific output or error
780        let date = now_iso();
781        let result = handle_server(&mut log_file, &date, &paths, &serve_dir);
782        assert!(
783            result.is_err(),
784            "Expected handle_server to fail without valid setup"
785        );
786
787        Ok(())
788    }
789
790    #[cfg(any(unix, windows))]
791    #[test]
792    fn test_verify_file_safety_symlink() -> Result<()> {
793        let temp_dir = tempdir()?;
794        let file_path = temp_dir.path().join("test.txt");
795        let symlink_path = temp_dir.path().join("test_link.txt");
796
797        // Create a regular file
798        fs::write(&file_path, "test content")?;
799
800        // Create a symlink
801        #[cfg(unix)]
802        std::os::unix::fs::symlink(&file_path, &symlink_path)?;
803        #[cfg(windows)]
804        std::os::windows::fs::symlink_file(&file_path, &symlink_path)?;
805
806        // Debug output
807        println!("File exists: {}", file_path.exists());
808        println!("Symlink exists: {}", symlink_path.exists());
809        println!(
810            "Is symlink: {}",
811            symlink_path.symlink_metadata()?.file_type().is_symlink()
812        );
813
814        // Try to verify the symlink
815        let result = verify_file_safety(&symlink_path);
816
817        // Print the result for debugging
818        println!("Result: {result:?}");
819
820        // Verify that we got an error
821        assert!(result.is_err(), "Expected error for symlink, got success");
822
823        // Verify the error message
824        let err = result.unwrap_err();
825        println!("Error message: {err}");
826        assert!(
827            err.to_string().contains("Symlinks are not allowed"),
828            "Unexpected error message: {err}"
829        );
830
831        Ok(())
832    }
833
834    #[test]
835    fn test_verify_file_safety_size() -> Result<()> {
836        let temp_dir = tempdir()?;
837        let large_file_path = temp_dir.path().join("large.txt");
838
839        // Create a large file
840        let file = File::create(&large_file_path)?;
841        file.set_len(11 * 1024 * 1024)?; // 11MB
842
843        let result = verify_file_safety(&large_file_path);
844        assert!(result.is_err(), "Expected error, got: {result:?}");
845        Ok(())
846    }
847
848    #[test]
849    fn test_verify_file_safety_regular() -> Result<()> {
850        let temp_dir = tempdir()?;
851        let file_path = temp_dir.path().join("regular.txt");
852
853        // Create a regular file
854        fs::write(&file_path, "test content")?;
855
856        assert!(verify_file_safety(&file_path).is_ok());
857        Ok(())
858    }
859
860    /// Tests successful copying of an empty directory
861    #[test]
862    fn test_copy_empty_directory_async() -> Result<()> {
863        let src_dir = tempdir()?;
864        let dst_dir = tempdir()?;
865
866        let result = copy_dir_all_async(src_dir.path(), dst_dir.path());
867        assert!(result.is_ok());
868
869        // Verify destination directory exists
870        assert!(dst_dir.path().exists());
871        Ok(())
872    }
873
874    /// Tests copying a directory with a single file
875    #[test]
876    fn test_copy_single_file_async() -> Result<()> {
877        let src_dir = tempdir()?;
878        let dst_dir = tempdir()?;
879
880        // Create a test file
881        let test_file = src_dir.path().join("test.txt");
882        fs::write(&test_file, "test content")?;
883
884        copy_dir_all_async(src_dir.path(), dst_dir.path())?;
885
886        // Verify file was copied
887        let copied_file = dst_dir.path().join("test.txt");
888        assert!(copied_file.exists());
889        assert_eq!(fs::read_to_string(copied_file)?, "test content");
890
891        Ok(())
892    }
893
894    /// Tests copying a directory with nested subdirectories
895    #[test]
896    fn test_copy_nested_directories_async() -> Result<()> {
897        let src_dir = tempdir()?;
898        let dst_dir = tempdir()?;
899
900        // Create nested directory structure
901        let nested_dir = src_dir.path().join("nested");
902        fs::create_dir(&nested_dir)?;
903
904        // Create files in both root and nested directory
905        fs::write(src_dir.path().join("root.txt"), "root content")?;
906        fs::write(nested_dir.join("nested.txt"), "nested content")?;
907
908        copy_dir_all_async(src_dir.path(), dst_dir.path())?;
909
910        // Verify directory structure and contents
911        assert!(dst_dir.path().join("nested").exists());
912        assert!(dst_dir.path().join("root.txt").exists());
913        assert!(dst_dir.path().join("nested/nested.txt").exists());
914
915        assert_eq!(
916            fs::read_to_string(dst_dir.path().join("root.txt"))?,
917            "root content"
918        );
919        assert_eq!(
920            fs::read_to_string(dst_dir.path().join("nested/nested.txt"))?,
921            "nested content"
922        );
923
924        Ok(())
925    }
926
927    /// Tests handling of symlinks
928    #[test]
929    fn test_copy_with_symlink_async() -> Result<()> {
930        let src_dir = tempdir()?;
931        let dst_dir = tempdir()?;
932
933        // Create a regular file
934        let file_path = src_dir.path().join("original.txt");
935        fs::write(&file_path, "original content")?;
936
937        // Create a symlink
938        #[cfg(unix)]
939        {
940            use std::os::unix::fs::symlink;
941            let symlink_path = src_dir.path().join("link.txt");
942            symlink(&file_path, &symlink_path)?;
943        }
944        #[cfg(windows)]
945        {
946            use std::os::windows::fs::symlink_file;
947            let symlink_path = src_dir.path().join("link.txt");
948            symlink_file(&file_path, &symlink_path)?;
949        }
950
951        // Attempt to copy - should fail due to symlink
952        let result = copy_dir_all_async(src_dir.path(), dst_dir.path());
953        assert!(result.is_err());
954
955        Ok(())
956    }
957
958    /// Tests copying large files
959    #[test]
960    fn test_copy_large_file_async() -> Result<()> {
961        let src_dir = tempdir()?;
962        let dst_dir = tempdir()?;
963
964        // Create a large file (11MB)
965        let large_file = src_dir.path().join("large.txt");
966        let file = File::create(&large_file)?;
967        file.set_len(11 * 1024 * 1024)?;
968
969        // Attempt to copy - should fail due to file size limit
970        let result = copy_dir_all_async(src_dir.path(), dst_dir.path());
971        assert!(result.is_err());
972
973        Ok(())
974    }
975
976    /// Tests copying with invalid destination
977    #[cfg(not(target_os = "windows"))] // Unix-only: invalid paths behave differently on Windows
978    #[test]
979    fn test_copy_invalid_destination_async() -> Result<()> {
980        let src_dir = tempdir()?;
981        let invalid_dst = PathBuf::from("/nonexistent/path");
982
983        let result = copy_dir_all_async(src_dir.path(), &invalid_dst);
984        assert!(result.is_err());
985
986        Ok(())
987    }
988
989    /// Tests concurrent copying of multiple files
990    #[test]
991    fn test_concurrent_copy_async() -> Result<()> {
992        let src_dir = tempdir()?;
993        let dst_dir = tempdir()?;
994
995        // Create multiple files
996        for i in 0..5 {
997            fs::write(
998                src_dir.path().join(format!("file{i}.txt")),
999                format!("content {i}"),
1000            )?;
1001        }
1002
1003        copy_dir_all_async(src_dir.path(), dst_dir.path())?;
1004
1005        // Verify all files were copied
1006        for i in 0..5 {
1007            let copied_file = dst_dir.path().join(format!("file{i}.txt"));
1008            assert!(copied_file.exists());
1009            assert_eq!(
1010                fs::read_to_string(copied_file)?,
1011                format!("content {i}")
1012            );
1013        }
1014
1015        Ok(())
1016    }
1017
1018    /// Tests copying with maximum directory depth
1019    #[test]
1020    fn test_max_directory_depth_async() -> Result<()> {
1021        let src_dir = tempdir()?;
1022        let dst_dir = tempdir()?;
1023        let max_depth = 5;
1024
1025        // Create deeply nested directory structure
1026        let mut current_dir = src_dir.path().to_path_buf();
1027        for i in 0..max_depth {
1028            current_dir = current_dir.join(format!("level{i}"));
1029            fs::create_dir(&current_dir)?;
1030            fs::write(
1031                current_dir.join("file.txt"),
1032                format!("content level {i}"),
1033            )?;
1034        }
1035
1036        copy_dir_all_async(src_dir.path(), dst_dir.path())?;
1037
1038        // Verify the entire structure was copied
1039        current_dir = dst_dir.path().to_path_buf();
1040        for i in 0..max_depth {
1041            current_dir = current_dir.join(format!("level{i}"));
1042            assert!(current_dir.exists());
1043            assert!(current_dir.join("file.txt").exists());
1044            assert_eq!(
1045                fs::read_to_string(current_dir.join("file.txt"))?,
1046                format!("content level {i}")
1047            );
1048        }
1049
1050        Ok(())
1051    }
1052
1053    #[test]
1054    fn test_verify_and_copy_files_async_missing_source() -> Result<()> {
1055        let temp_dir = tempdir()?;
1056        let src_dir = temp_dir.path().join("nonexistent");
1057        let dst_dir = temp_dir.path().join("dst");
1058
1059        let error = verify_and_copy_files_async(&src_dir, &dst_dir)
1060            .unwrap_err()
1061            .to_string();
1062
1063        assert!(
1064            error.contains("does not exist"),
1065            "Expected error message about non-existent source, got: {error}"
1066        );
1067
1068        Ok(())
1069    }
1070
1071    #[test]
1072    fn test_paths_builder_default() -> Result<()> {
1073        let paths = Paths::builder().build()?;
1074        assert_eq!(paths.site, PathBuf::from("public"));
1075        assert_eq!(paths.content, PathBuf::from("content"));
1076        assert_eq!(paths.build, PathBuf::from("build"));
1077        assert_eq!(paths.template, PathBuf::from("templates"));
1078        Ok(())
1079    }
1080
1081    #[test]
1082    fn test_resolve_build_and_site_dirs_without_serve_dir() {
1083        let mut config = SsgConfig::default();
1084        config.output_dir = PathBuf::from("docs");
1085        config.serve_dir = None;
1086
1087        let (build_dir, site_dir) = resolve_build_and_site_dirs(&config);
1088
1089        assert_eq!(site_dir, PathBuf::from("docs"));
1090        assert_eq!(build_dir, PathBuf::from("docs.build-tmp"));
1091        assert_ne!(build_dir, site_dir);
1092    }
1093
1094    #[test]
1095    fn test_resolve_build_and_site_dirs_with_distinct_serve_dir() {
1096        let mut config = SsgConfig::default();
1097        config.output_dir = PathBuf::from("docs");
1098        config.serve_dir = Some(PathBuf::from("public"));
1099
1100        let (build_dir, site_dir) = resolve_build_and_site_dirs(&config);
1101
1102        assert_eq!(build_dir, PathBuf::from("docs"));
1103        assert_eq!(site_dir, PathBuf::from("public"));
1104        assert_ne!(build_dir, site_dir);
1105    }
1106
1107    #[test]
1108    fn test_resolve_build_and_site_dirs_with_same_serve_and_output_dir() {
1109        let mut config = SsgConfig::default();
1110        config.output_dir = PathBuf::from("docs");
1111        config.serve_dir = Some(PathBuf::from("docs"));
1112
1113        let (build_dir, site_dir) = resolve_build_and_site_dirs(&config);
1114
1115        assert_eq!(site_dir, PathBuf::from("docs"));
1116        assert_eq!(build_dir, PathBuf::from("docs.build-tmp"));
1117        assert_ne!(build_dir, site_dir);
1118    }
1119
1120    #[test]
1121    fn test_paths_builder_custom() -> Result<()> {
1122        let temp_dir = tempdir()?;
1123        let paths = Paths::builder()
1124            .site(temp_dir.path().join("custom_public"))
1125            .content(temp_dir.path().join("custom_content"))
1126            .build_dir(temp_dir.path().join("custom_build"))
1127            .template(temp_dir.path().join("custom_templates"))
1128            .build()?;
1129
1130        assert_eq!(paths.site, temp_dir.path().join("custom_public"));
1131        assert_eq!(paths.content, temp_dir.path().join("custom_content"));
1132        assert_eq!(paths.build, temp_dir.path().join("custom_build"));
1133        assert_eq!(paths.template, temp_dir.path().join("custom_templates"));
1134        Ok(())
1135    }
1136
1137    #[test]
1138    fn test_paths_builder_relative() -> Result<()> {
1139        let temp_dir = tempdir()?;
1140
1141        // Create the directories first
1142        fs::create_dir_all(temp_dir.path().join("public"))?;
1143        fs::create_dir_all(temp_dir.path().join("content"))?;
1144        fs::create_dir_all(temp_dir.path().join("build"))?;
1145        fs::create_dir_all(temp_dir.path().join("templates"))?;
1146
1147        let paths = Paths::builder().relative_to(temp_dir.path()).build()?;
1148
1149        assert_eq!(paths.site, temp_dir.path().join("public"));
1150        assert_eq!(paths.content, temp_dir.path().join("content"));
1151        assert_eq!(paths.build, temp_dir.path().join("build"));
1152        assert_eq!(paths.template, temp_dir.path().join("templates"));
1153        Ok(())
1154    }
1155
1156    #[test]
1157    fn test_paths_validation() -> Result<()> {
1158        // Test directory traversal
1159        let result = Paths::builder().site("../invalid").build();
1160
1161        assert!(result.is_err());
1162        assert!(
1163            result
1164                .unwrap_err()
1165                .to_string()
1166                .contains("directory traversal"),
1167            "Expected error about directory traversal"
1168        );
1169
1170        // Test double slashes
1171        let result = Paths::builder().site("invalid//path").build();
1172
1173        assert!(result.is_err());
1174        assert!(
1175            result
1176                .unwrap_err()
1177                .to_string()
1178                .contains("invalid double slashes"),
1179            "Expected error about invalid double slashes"
1180        );
1181
1182        // Test symlinks if possible
1183        #[cfg(unix)]
1184        {
1185            use std::os::unix::fs::symlink;
1186            let temp_dir = tempdir()?;
1187            let real_path = temp_dir.path().join("real");
1188            let symlink_path = temp_dir.path().join("symlink");
1189
1190            fs::create_dir(&real_path)?;
1191            symlink(&real_path, &symlink_path)?;
1192
1193            let result = Paths::builder().site(symlink_path).build();
1194
1195            assert!(result.is_err());
1196            assert!(
1197                result.unwrap_err().to_string().contains("symlink"),
1198                "Expected error about symlinks"
1199            );
1200        }
1201
1202        Ok(())
1203    }
1204
1205    #[test]
1206    fn test_paths_default_paths() {
1207        let paths = Paths::default_paths();
1208        assert_eq!(paths.site, PathBuf::from("public"));
1209        assert_eq!(paths.content, PathBuf::from("content"));
1210        assert_eq!(paths.build, PathBuf::from("build"));
1211        assert_eq!(paths.template, PathBuf::from("templates"));
1212    }
1213
1214    // Add a new test for non-existent but valid paths
1215    #[test]
1216    fn test_paths_nonexistent_valid() -> Result<()> {
1217        let temp_dir = tempdir()?;
1218        let valid_path = temp_dir.path().join("new_directory");
1219
1220        let paths = Paths::builder().site(valid_path.clone()).build()?;
1221
1222        assert_eq!(paths.site, valid_path);
1223        Ok(())
1224    }
1225
1226    #[test]
1227    fn test_initialize_logging_with_custom_level() -> Result<()> {
1228        env::set_var(ENV_LOG_LEVEL, "debug");
1229        assert!(logging::initialize_logging().is_ok());
1230        env::remove_var(ENV_LOG_LEVEL);
1231        Ok(())
1232    }
1233
1234    #[test]
1235    fn test_paths_builder_with_all_invalid_paths() -> Result<()> {
1236        let result = Paths::builder()
1237            .site("../invalid")
1238            .content("content//invalid")
1239            .build_dir("build/../invalid")
1240            .template("template//invalid")
1241            .build();
1242
1243        assert!(result.is_err());
1244        Ok(())
1245    }
1246
1247    #[test]
1248    fn test_paths_builder_clone() {
1249        let builder = PathsBuilder::default();
1250        let cloned = builder;
1251        assert!(cloned.site.is_none());
1252        assert!(cloned.content.is_none());
1253        assert!(cloned.build.is_none());
1254        assert!(cloned.template.is_none());
1255    }
1256
1257    #[test]
1258    fn test_paths_clone() -> Result<()> {
1259        let paths = Paths::default_paths();
1260        let cloned = paths.clone();
1261
1262        assert_eq!(paths.site, cloned.site);
1263        assert_eq!(paths.content, cloned.content);
1264        assert_eq!(paths.build, cloned.build);
1265        assert_eq!(paths.template, cloned.template);
1266        Ok(())
1267    }
1268
1269    #[test]
1270    fn test_async_copy_with_empty_source() -> Result<()> {
1271        let temp_dir = tempdir()?;
1272        let src_dir = temp_dir.path().join("empty_src");
1273        let dst_dir = temp_dir.path().join("empty_dst");
1274
1275        fs::create_dir(&src_dir)?;
1276
1277        let result = verify_and_copy_files_async(&src_dir, &dst_dir);
1278        assert!(result.is_ok());
1279        assert!(dst_dir.exists());
1280        Ok(())
1281    }
1282
1283    #[test]
1284    fn test_paths_validation_all_aspects() -> Result<()> {
1285        let temp_dir = tempdir()?;
1286
1287        // Test with absolute paths
1288        let result = Paths::builder()
1289            .site(temp_dir.path().join("site"))
1290            .content(temp_dir.path().join("content"))
1291            .build_dir(temp_dir.path().join("build"))
1292            .template(temp_dir.path().join("template"))
1293            .build();
1294
1295        assert!(result.is_ok());
1296
1297        // Test with multiple validation issues
1298        let result = Paths::builder()
1299            .site("../site")
1300            .content("content//test")
1301            .build_dir("build/../../test")
1302            .template("template//test")
1303            .build();
1304
1305        assert!(result.is_err());
1306        Ok(())
1307    }
1308
1309    #[test]
1310    fn test_log_initialization_with_empty_log_file() -> Result<()> {
1311        let temp_dir = tempdir()?;
1312        let log_path = temp_dir.path().join("empty.log");
1313        let mut log_file = File::create(&log_path)?;
1314
1315        let date = now_iso();
1316        log_initialization(&mut log_file, &date)?;
1317
1318        let content = fs::read_to_string(&log_path)?;
1319        assert!(!content.is_empty());
1320        assert!(content.contains("process"));
1321        Ok(())
1322    }
1323
1324    #[test]
1325    fn test_verify_and_copy_files_async_with_nested_empty_dirs() -> Result<()> {
1326        let temp_dir = tempdir()?;
1327        let src_dir = temp_dir.path().join("src");
1328        let dst_dir = temp_dir.path().join("dst");
1329
1330        // Create nested empty directory structure
1331        fs::create_dir_all(src_dir.join("a/b/c"))?;
1332        fs::create_dir_all(src_dir.join("d/e/f"))?;
1333
1334        verify_and_copy_files_async(&src_dir, &dst_dir)?;
1335
1336        assert!(dst_dir.join("a/b/c").exists());
1337        assert!(dst_dir.join("d/e/f").exists());
1338        Ok(())
1339    }
1340
1341    #[test]
1342    fn test_validate_nonexistent_paths() -> Result<()> {
1343        let paths = Paths {
1344            site: PathBuf::from("nonexistent/site"),
1345            content: PathBuf::from("nonexistent/content"),
1346            build: PathBuf::from("nonexistent/build"),
1347            template: PathBuf::from("nonexistent/template"),
1348        };
1349
1350        // Non-existent paths should be valid if they don't contain unsafe patterns
1351        assert!(paths.validate().is_ok());
1352        Ok(())
1353    }
1354
1355    #[test]
1356    fn test_copy_dir_all_async_with_empty_dirs() -> Result<()> {
1357        let temp_dir = tempdir()?;
1358        let src_dir = temp_dir.path().join("src");
1359        let dst_dir = temp_dir.path().join("dst");
1360
1361        fs::create_dir_all(src_dir.join("empty1"))?;
1362        fs::create_dir_all(src_dir.join("empty2/empty3"))?;
1363
1364        copy_dir_all_async(&src_dir, &dst_dir)?;
1365
1366        assert!(dst_dir.join("empty1").exists());
1367        assert!(dst_dir.join("empty2/empty3").exists());
1368        Ok(())
1369    }
1370
1371    #[test]
1372    fn test_log_level_from_env() {
1373        // Save the current environment variable value
1374        let original_value = env::var(ENV_LOG_LEVEL).ok();
1375
1376        // Helper function to get processed log level
1377        fn get_processed_log_level() -> String {
1378            let log_level = env::var(ENV_LOG_LEVEL)
1379                .unwrap_or_else(|_| DEFAULT_LOG_LEVEL.to_string());
1380
1381            match log_level.to_lowercase().as_str() {
1382                "error" => "error",
1383                "warn" => "warn",
1384                "info" => "info",
1385                "debug" => "debug",
1386                "trace" => "trace",
1387                _ => "info", // Default to info for invalid values
1388            }
1389            .to_string()
1390        }
1391
1392        // Test various log level settings
1393        let test_levels = vec![
1394            ("DEBUG", "debug"),
1395            ("ERROR", "error"),
1396            ("WARN", "warn"),
1397            ("INFO", "info"),
1398            ("TRACE", "trace"),
1399            ("INVALID", "info"), // Should default to info
1400        ];
1401
1402        for (input, expected) in test_levels {
1403            env::set_var(ENV_LOG_LEVEL, input);
1404            let processed_level = get_processed_log_level();
1405            assert_eq!(
1406                processed_level, expected,
1407                "Expected log level '{expected}' for input '{input}', but got '{processed_level}'"
1408            );
1409        }
1410
1411        // Restore the original environment variable state
1412        env::remove_var(ENV_LOG_LEVEL);
1413        if let Some(value) = original_value {
1414            env::set_var(ENV_LOG_LEVEL, value);
1415        }
1416    }
1417
1418    /// Test for default log level when environment variable is not set
1419    #[test]
1420    fn test_default_log_level() {
1421        let original_value = env::var(ENV_LOG_LEVEL).ok();
1422        env::remove_var(ENV_LOG_LEVEL);
1423
1424        let log_level = env::var(ENV_LOG_LEVEL)
1425            .unwrap_or_else(|_| DEFAULT_LOG_LEVEL.to_string())
1426            .to_lowercase();
1427        assert_eq!(log_level, DEFAULT_LOG_LEVEL.to_lowercase());
1428
1429        env::remove_var(ENV_LOG_LEVEL);
1430        if let Some(value) = original_value {
1431            env::set_var(ENV_LOG_LEVEL, value);
1432        }
1433    }
1434
1435    /// Test the logic for translating string log levels to `LevelFilter` values
1436    #[test]
1437    fn test_log_level_translation() {
1438        use log::LevelFilter;
1439        let test_cases = vec![
1440            ("error", LevelFilter::Error),
1441            ("warn", LevelFilter::Warn),
1442            ("info", LevelFilter::Info),
1443            ("debug", LevelFilter::Debug),
1444            ("trace", LevelFilter::Trace),
1445            ("invalid", LevelFilter::Info),
1446            ("", LevelFilter::Info),
1447        ];
1448
1449        for (input, expected) in test_cases {
1450            let level = match input.to_lowercase().as_str() {
1451                "error" => LevelFilter::Error,
1452                "warn" => LevelFilter::Warn,
1453                "info" => LevelFilter::Info,
1454                "debug" => LevelFilter::Debug,
1455                "trace" => LevelFilter::Trace,
1456                _ => LevelFilter::Info,
1457            };
1458
1459            assert_eq!(
1460                level, expected,
1461                "Log level mismatch for input: '{input}' - expected {expected:?}, got {level:?}"
1462            );
1463        }
1464    }
1465
1466    /// Test environment variable handling with cleanup
1467    #[test]
1468    fn test_env_log_level_handling() {
1469        // Save original state
1470        let original_value = env::var(ENV_LOG_LEVEL).ok();
1471
1472        let test_cases = vec![
1473            (Some("DEBUG"), "debug"),
1474            (Some("ERROR"), "error"),
1475            (Some("WARN"), "warn"),
1476            (Some("INFO"), "info"),
1477            (Some("TRACE"), "trace"),
1478            (Some("INVALID"), "info"),
1479            (None, "info"),
1480        ];
1481
1482        for (env_value, expected) in test_cases {
1483            // Clear any existing env var
1484            env::remove_var(ENV_LOG_LEVEL);
1485
1486            // Set new value if provided
1487            if let Some(value) = env_value {
1488                env::set_var(ENV_LOG_LEVEL, value);
1489            }
1490
1491            let log_level = env::var(ENV_LOG_LEVEL)
1492                .unwrap_or_else(|_| DEFAULT_LOG_LEVEL.to_string())
1493                .to_lowercase();
1494
1495            let actual = match log_level.as_str() {
1496                "error" => "error",
1497                "warn" => "warn",
1498                "info" => "info",
1499                "debug" => "debug",
1500                "trace" => "trace",
1501                _ => "info",
1502            };
1503
1504            assert_eq!(
1505                actual, expected,
1506                "Log level mismatch for env value: {env_value:?}"
1507            );
1508        }
1509
1510        // Restore original state
1511        env::remove_var(ENV_LOG_LEVEL);
1512        if let Some(value) = original_value {
1513            env::set_var(ENV_LOG_LEVEL, value);
1514        }
1515    }
1516
1517    #[test]
1518    fn test_initialize_logging_custom_levels() {
1519        // Verify that the expected log level strings are valid
1520        let valid_levels = ["debug", "warn", "error", "trace", "info"];
1521        for level in &valid_levels {
1522            assert!(
1523                ["trace", "debug", "info", "warn", "error"].contains(level),
1524                "unexpected log level: {level}"
1525            );
1526        }
1527        // Verify our default is valid
1528        assert!(["trace", "debug", "info", "warn", "error"]
1529            .contains(&DEFAULT_LOG_LEVEL),);
1530    }
1531
1532    #[test]
1533    fn parse_log_level_recognises_all_supported_levels() {
1534        use log::LevelFilter;
1535        assert_eq!(logging::parse_log_level("error"), LevelFilter::Error);
1536        assert_eq!(logging::parse_log_level("warn"), LevelFilter::Warn);
1537        assert_eq!(logging::parse_log_level("info"), LevelFilter::Info);
1538        assert_eq!(logging::parse_log_level("debug"), LevelFilter::Debug);
1539        assert_eq!(logging::parse_log_level("trace"), LevelFilter::Trace);
1540    }
1541
1542    #[test]
1543    fn parse_log_level_is_case_insensitive() {
1544        use log::LevelFilter;
1545        assert_eq!(logging::parse_log_level("ERROR"), LevelFilter::Error);
1546        assert_eq!(logging::parse_log_level("Warn"), LevelFilter::Warn);
1547        assert_eq!(logging::parse_log_level("TraCe"), LevelFilter::Trace);
1548    }
1549
1550    #[test]
1551    fn parse_log_level_unknown_value_falls_back_to_info() {
1552        use log::LevelFilter;
1553        assert_eq!(logging::parse_log_level("nonsense"), LevelFilter::Info);
1554        assert_eq!(logging::parse_log_level(""), LevelFilter::Info);
1555        assert_eq!(logging::parse_log_level("verbose"), LevelFilter::Info);
1556    }
1557
1558    #[test]
1559    fn test_concurrent_operations() -> Result<()> {
1560        let temp_dir = TempDir::new()?;
1561        let src_dir = temp_dir.path().join("src");
1562        let dst_dir = temp_dir.path().join("dst");
1563
1564        // Create source directory
1565        fs::create_dir_all(&src_dir)?;
1566
1567        // Create files
1568        for i in 0..100 {
1569            fs::write(
1570                src_dir.join(format!("file_{i}.txt")),
1571                format!("content {i}"),
1572            )?;
1573        }
1574
1575        // Verify source files
1576        let mut src_files = Vec::new();
1577        collect_files_recursive(&src_dir, &mut src_files)?;
1578        assert_eq!(src_files.len(), 100);
1579
1580        // Create destination directory
1581        fs::create_dir_all(&dst_dir)?;
1582
1583        // Copy files using verify_and_copy_files
1584        verify_and_copy_files(&src_dir, &dst_dir)?;
1585
1586        // Verify destination files
1587        let mut dst_files = Vec::new();
1588        collect_files_recursive(&dst_dir, &mut dst_files)?;
1589
1590        assert_eq!(dst_files.len(), 100);
1591
1592        // Verify file contents
1593        for i in 0..100 {
1594            let dst_path = dst_dir.join(format!("file_{i}.txt"));
1595            assert!(
1596                dst_path.exists(),
1597                "File {} does not exist in destination",
1598                dst_path.display()
1599            );
1600
1601            let content = fs::read_to_string(&dst_path)?;
1602            assert_eq!(
1603                content,
1604                format!("content {i}"),
1605                "Content mismatch for file {}",
1606                i
1607            );
1608        }
1609
1610        Ok(())
1611    }
1612
1613    #[test]
1614    fn test_verify_and_copy_files_basic() -> Result<()> {
1615        let temp_dir = TempDir::new()?;
1616        let src_dir = temp_dir.path().join("src");
1617        let dst_dir = temp_dir.path().join("dst");
1618
1619        fs::create_dir_all(&src_dir)?;
1620
1621        // Create a test file
1622        fs::write(src_dir.join("test.txt"), "test content")?;
1623
1624        // Copy files
1625        verify_and_copy_files(&src_dir, &dst_dir)?;
1626
1627        // Verify file was copied
1628        assert!(dst_dir.join("test.txt").exists());
1629        assert_eq!(
1630            fs::read_to_string(dst_dir.join("test.txt"))?,
1631            "test content"
1632        );
1633
1634        Ok(())
1635    }
1636
1637    #[test]
1638    fn test_copy_dir_with_progress_empty_source() -> Result<()> {
1639        let src_dir = tempdir()?;
1640        let dst_dir = tempdir()?;
1641
1642        // Call the function with an empty source directory
1643        copy_dir_with_progress(src_dir.path(), dst_dir.path())?;
1644
1645        // Verify that the destination directory exists and is empty
1646        assert!(dst_dir.path().exists());
1647        assert!(fs::read_dir(dst_dir.path())?.next().is_none());
1648
1649        Ok(())
1650    }
1651
1652    #[test]
1653    fn test_copy_dir_with_progress_source_does_not_exist() {
1654        let src_dir = Path::new("/nonexistent");
1655        let dst_dir = tempdir().unwrap();
1656
1657        let result = copy_dir_with_progress(src_dir, dst_dir.path());
1658        assert!(result.is_err());
1659    }
1660
1661    #[test]
1662    fn test_copy_dir_with_progress_single_file() -> Result<()> {
1663        let src_dir = tempdir()?;
1664        let dst_dir = tempdir()?;
1665
1666        fs::write(src_dir.path().join("file1.txt"), "content")?;
1667
1668        copy_dir_with_progress(src_dir.path(), dst_dir.path())?;
1669
1670        let copied_file = dst_dir.path().join("file1.txt");
1671        assert!(copied_file.exists());
1672        assert_eq!(fs::read_to_string(copied_file)?, "content");
1673
1674        Ok(())
1675    }
1676
1677    #[test]
1678    fn test_copy_dir_with_progress_nested_directories() -> Result<()> {
1679        let src_dir = tempdir()?;
1680        let dst_dir = tempdir()?;
1681
1682        let nested_dir = src_dir.path().join("nested");
1683        fs::create_dir(&nested_dir)?;
1684        fs::write(nested_dir.join("file.txt"), "nested content")?;
1685
1686        copy_dir_with_progress(src_dir.path(), dst_dir.path())?;
1687
1688        let copied_nested_file = dst_dir.path().join("nested/file.txt");
1689        assert!(copied_nested_file.exists());
1690        assert_eq!(fs::read_to_string(copied_nested_file)?, "nested content");
1691
1692        Ok(())
1693    }
1694
1695    #[cfg(not(target_os = "windows"))] // Unix-only: invalid paths behave differently on Windows
1696    #[test]
1697    fn test_copy_dir_with_progress_destination_creation_failure() {
1698        let src_dir = tempdir().unwrap();
1699        let dst_dir = Path::new("/invalid_path");
1700
1701        let result = copy_dir_with_progress(src_dir.path(), dst_dir);
1702        assert!(result.is_err());
1703    }
1704
1705    #[test]
1706    fn test_verify_and_copy_files_single_file() -> Result<()> {
1707        let temp_dir = tempdir()?;
1708        let src_file = temp_dir.path().join("single.txt");
1709        fs::write(&src_file, "content")?;
1710        let dst_dir = temp_dir.path().join("dst");
1711        // Calling with a file as src triggers verify_file_safety branch
1712        // then copy_dir_all fails because src is a file, not a directory
1713        let result = verify_and_copy_files(&src_file, &dst_dir);
1714        assert!(result.is_err());
1715        Ok(())
1716    }
1717
1718    #[test]
1719    fn test_is_safe_path_traversal_nonexistent() -> Result<()> {
1720        assert!(!is_safe_path(Path::new("../../etc/passwd"))?);
1721        Ok(())
1722    }
1723
1724    #[test]
1725    fn test_copy_dir_with_progress_nested() -> Result<()> {
1726        let src_dir = tempdir()?;
1727        let dst_dir = tempdir()?;
1728        // Create nested structure with files
1729        let sub = src_dir.path().join("sub");
1730        fs::create_dir(&sub)?;
1731        fs::write(src_dir.path().join("root.txt"), "root")?;
1732        fs::write(sub.join("nested.txt"), "nested")?;
1733        copy_dir_with_progress(src_dir.path(), dst_dir.path())?;
1734        assert!(dst_dir.path().join("root.txt").exists());
1735        assert!(dst_dir.path().join("sub/nested.txt").exists());
1736        Ok(())
1737    }
1738
1739    #[test]
1740    fn test_copy_dir_all_parallel_threshold() -> Result<()> {
1741        let src_dir = tempdir()?;
1742        let dst_dir = tempdir()?;
1743        // Create >= 16 files to trigger parallel path
1744        for i in 0..20 {
1745            fs::write(
1746                src_dir.path().join(format!("file{i}.txt")),
1747                format!("content {i}"),
1748            )?;
1749        }
1750        copy_dir_all(src_dir.path(), dst_dir.path())?;
1751        for i in 0..20 {
1752            assert!(dst_dir.path().join(format!("file{i}.txt")).exists());
1753        }
1754        Ok(())
1755    }
1756
1757    #[test]
1758    fn test_collect_files_recursive_depth_exceeded() -> Result<()> {
1759        let temp_dir = tempdir()?;
1760        // Create a directory deeper than MAX_DIR_DEPTH
1761        let mut path = temp_dir.path().to_path_buf();
1762        for i in 0..=MAX_DIR_DEPTH {
1763            path = path.join(format!("d{i}"));
1764            fs::create_dir(&path)?;
1765        }
1766        let mut files = Vec::new();
1767        let result = collect_files_recursive(temp_dir.path(), &mut files);
1768        assert!(result.is_err());
1769        assert!(result.unwrap_err().to_string().contains("maximum depth"));
1770        Ok(())
1771    }
1772
1773    #[test]
1774    fn test_copy_dir_all_depth_exceeded() -> Result<()> {
1775        let src_dir = tempdir()?;
1776        let dst_dir = tempdir()?;
1777        let mut path = src_dir.path().to_path_buf();
1778        for i in 0..=MAX_DIR_DEPTH {
1779            path = path.join(format!("d{i}"));
1780            fs::create_dir(&path)?;
1781        }
1782        let result = copy_dir_all(src_dir.path(), dst_dir.path());
1783        assert!(result.is_err());
1784        assert!(result.unwrap_err().to_string().contains("maximum depth"));
1785        Ok(())
1786    }
1787
1788    #[test]
1789    fn test_verify_and_copy_files_async_depth_exceeded() -> Result<()> {
1790        let temp_dir = tempdir()?;
1791        let src = temp_dir.path().join("src");
1792        let dst = temp_dir.path().join("dst");
1793        let mut path = src.clone();
1794        for i in 0..=MAX_DIR_DEPTH {
1795            path = path.join(format!("d{i}"));
1796            fs::create_dir_all(&path)?;
1797        }
1798        let result = verify_and_copy_files_async(&src, &dst);
1799        assert!(result.is_err());
1800        assert!(result.unwrap_err().to_string().contains("maximum depth"));
1801        Ok(())
1802    }
1803
1804    #[test]
1805    fn test_copy_dir_all_async_depth_exceeded() -> Result<()> {
1806        let temp_dir = tempdir()?;
1807        let src = temp_dir.path().join("src");
1808        let dst = temp_dir.path().join("dst");
1809        let mut path = src.clone();
1810        for i in 0..=MAX_DIR_DEPTH {
1811            path = path.join(format!("d{i}"));
1812            fs::create_dir_all(&path)?;
1813        }
1814        let result = copy_dir_all_async(&src, &dst);
1815        assert!(result.is_err());
1816        assert!(result.unwrap_err().to_string().contains("maximum depth"));
1817        Ok(())
1818    }
1819
1820    #[test]
1821    fn test_verify_file_safety_nonexistent() {
1822        let result = verify_file_safety(Path::new("/nonexistent/file.txt"));
1823        assert!(result.is_err());
1824    }
1825
1826    #[test]
1827    fn test_copy_dir_with_progress_nonexistent_source() {
1828        let dst = env::temp_dir().join("ssg_copy_dir_dst");
1829        let result =
1830            copy_dir_with_progress(Path::new("/nonexistent/source"), &dst);
1831        assert!(result.is_err());
1832    }
1833
1834    #[test]
1835    fn test_verify_and_copy_files_async_with_files() -> Result<()> {
1836        let temp_dir = tempdir()?;
1837        let src = temp_dir.path().join("src");
1838        let dst = temp_dir.path().join("dst");
1839
1840        // Create source with nested dirs + files
1841        fs::create_dir_all(src.join("sub1/sub2"))?;
1842        fs::write(src.join("root.txt"), "root")?;
1843        fs::write(src.join("sub1/a.txt"), "a")?;
1844        fs::write(src.join("sub1/sub2/b.txt"), "b")?;
1845
1846        verify_and_copy_files_async(&src, &dst)?;
1847
1848        assert_eq!(fs::read_to_string(dst.join("root.txt"))?, "root");
1849        assert_eq!(fs::read_to_string(dst.join("sub1/a.txt"))?, "a");
1850        assert_eq!(fs::read_to_string(dst.join("sub1/sub2/b.txt"))?, "b");
1851        Ok(())
1852    }
1853
1854    #[test]
1855    fn test_copy_dir_with_progress_with_files() -> Result<()> {
1856        let src_dir = tempdir()?;
1857        let dst_dir = tempdir()?;
1858
1859        // Create nested structure
1860        let sub1 = src_dir.path().join("a");
1861        let sub2 = sub1.join("b");
1862        fs::create_dir_all(&sub2)?;
1863        fs::write(src_dir.path().join("file1.txt"), "f1")?;
1864        fs::write(sub1.join("file2.txt"), "f2")?;
1865        fs::write(sub2.join("file3.txt"), "f3")?;
1866
1867        copy_dir_with_progress(src_dir.path(), dst_dir.path())?;
1868
1869        assert_eq!(fs::read_to_string(dst_dir.path().join("file1.txt"))?, "f1");
1870        assert_eq!(
1871            fs::read_to_string(dst_dir.path().join("a/file2.txt"))?,
1872            "f2"
1873        );
1874        assert_eq!(
1875            fs::read_to_string(dst_dir.path().join("a/b/file3.txt"))?,
1876            "f3"
1877        );
1878        Ok(())
1879    }
1880
1881    #[cfg(unix)]
1882    #[test]
1883    fn test_is_safe_path_broken_symlink() -> Result<()> {
1884        let temp_dir = tempdir()?;
1885        let target = temp_dir.path().join("nonexistent_target");
1886        let link = temp_dir.path().join("broken_link");
1887
1888        std::os::unix::fs::symlink(&target, &link)?;
1889        let result = is_safe_path(&link)?;
1890        assert!(result);
1891        Ok(())
1892    }
1893
1894    #[cfg(unix)]
1895    #[test]
1896    fn test_paths_validate_symlink() -> Result<()> {
1897        let temp_dir = tempdir()?;
1898        let real = temp_dir.path().join("real");
1899        let link = temp_dir.path().join("link");
1900
1901        fs::create_dir(&real)?;
1902        std::os::unix::fs::symlink(&real, &link)?;
1903
1904        let paths = Paths {
1905            site: link,
1906            content: PathBuf::from("content"),
1907            build: PathBuf::from("build"),
1908            template: PathBuf::from("templates"),
1909        };
1910        let result = paths.validate();
1911        assert!(result.is_err());
1912        assert!(result.unwrap_err().to_string().contains("symlink"));
1913        Ok(())
1914    }
1915
1916    #[test]
1917    fn test_copy_dir_with_progress_depth_exceeded() -> Result<()> {
1918        let src_dir = tempdir()?;
1919        let dst_dir = tempdir()?;
1920        let mut path = src_dir.path().to_path_buf();
1921        for i in 0..=MAX_DIR_DEPTH {
1922            path = path.join(format!("d{i}"));
1923            fs::create_dir(&path)?;
1924        }
1925        let result = copy_dir_with_progress(src_dir.path(), dst_dir.path());
1926        assert!(result.is_err());
1927        assert!(result.unwrap_err().to_string().contains("maximum depth"));
1928        Ok(())
1929    }
1930
1931    #[test]
1932    fn test_verify_and_copy_files_source_is_file() -> Result<()> {
1933        let temp_dir = tempdir()?;
1934        let src_file = temp_dir.path().join("source.txt");
1935        let dst_dir = temp_dir.path().join("dst");
1936        fs::write(&src_file, "hello")?;
1937
1938        let result = verify_and_copy_files(&src_file, &dst_dir);
1939        assert!(result.is_err());
1940        Ok(())
1941    }
1942
1943    #[test]
1944    fn test_compile_site_error() -> Result<()> {
1945        let temp_dir = tempdir()?;
1946        let build = temp_dir.path().join("build");
1947        let content = temp_dir.path().join("content");
1948        let site = temp_dir.path().join("site");
1949        let template = temp_dir.path().join("template");
1950        fs::create_dir_all(&build)?;
1951        fs::create_dir_all(&content)?;
1952        fs::create_dir_all(&site)?;
1953        fs::create_dir_all(&template)?;
1954
1955        let result = compile_site(&build, &content, &site, &template);
1956        // Compilation with empty dirs will fail
1957        assert!(result.is_err());
1958        Ok(())
1959    }
1960
1961    #[test]
1962    fn test_prepare_serve_dir_same_as_site() -> Result<()> {
1963        let temp_dir = tempdir()?;
1964        let site_dir = temp_dir.path().join("site");
1965        fs::create_dir_all(&site_dir)?;
1966        fs::write(site_dir.join("index.html"), "<html/>")?;
1967
1968        let paths = Paths {
1969            site: site_dir.clone(),
1970            content: PathBuf::from("content"),
1971            build: PathBuf::from("build"),
1972            template: PathBuf::from("templates"),
1973        };
1974
1975        // When serve_dir == site, no copy should happen
1976        prepare_serve_dir(&paths, &site_dir)?;
1977        assert!(site_dir.join("index.html").exists());
1978        Ok(())
1979    }
1980
1981    #[test]
1982    fn test_prepare_serve_dir_different() -> Result<()> {
1983        let temp_dir = tempdir()?;
1984        let site_dir = temp_dir.path().join("site");
1985        let serve_dir = temp_dir.path().join("serve");
1986        fs::create_dir_all(&site_dir)?;
1987        fs::write(site_dir.join("index.html"), "<html/>")?;
1988
1989        let paths = Paths {
1990            site: site_dir,
1991            content: PathBuf::from("content"),
1992            build: PathBuf::from("build"),
1993            template: PathBuf::from("templates"),
1994        };
1995
1996        prepare_serve_dir(&paths, &serve_dir)?;
1997        assert!(serve_dir.join("index.html").exists());
1998        Ok(())
1999    }
2000
2001    #[test]
2002    fn test_create_directories_all_valid() -> Result<()> {
2003        let temp_dir = tempdir()?;
2004        let paths = Paths {
2005            site: temp_dir.path().join("s"),
2006            content: temp_dir.path().join("c"),
2007            build: temp_dir.path().join("b"),
2008            template: temp_dir.path().join("t"),
2009        };
2010        create_directories(&paths)?;
2011        assert!(paths.site.exists());
2012        assert!(paths.build.exists());
2013        Ok(())
2014    }
2015
2016    #[test]
2017    fn test_is_safe_path_existing_valid() -> Result<()> {
2018        let temp_dir = tempdir()?;
2019        let dir = temp_dir.path().join("valid");
2020        fs::create_dir(&dir)?;
2021        let canonical = dir.canonicalize()?;
2022        assert!(is_safe_path(&canonical)?);
2023        Ok(())
2024    }
2025
2026    // -----------------------------------------------------------------
2027    // RunOptions / build_pipeline / execute_build_pipeline
2028    // -----------------------------------------------------------------
2029
2030    #[test]
2031    fn run_options_from_matches_extracts_quiet_drafts_and_deploy() {
2032        let cli = Cli::build();
2033        let matches = cli
2034            .try_get_matches_from(vec![
2035                "ssg", "--quiet", "--drafts", "--deploy", "netlify",
2036            ])
2037            .expect("matches");
2038        let opts = RunOptions::from_matches(&matches);
2039        assert!(opts.quiet);
2040        assert!(opts.include_drafts);
2041        assert_eq!(opts.deploy_target.as_deref(), Some("netlify"));
2042    }
2043
2044    #[test]
2045    fn run_options_from_matches_defaults_when_flags_absent() {
2046        let cli = Cli::build();
2047        let matches = cli.try_get_matches_from(vec!["ssg"]).expect("matches");
2048        let opts = RunOptions::from_matches(&matches);
2049        assert!(!opts.quiet);
2050        assert!(!opts.include_drafts);
2051        assert!(opts.deploy_target.is_none());
2052    }
2053
2054    #[test]
2055    fn build_pipeline_assembles_manager_context_and_dirs() {
2056        let temp = tempdir().unwrap();
2057        let mut config = SsgConfig::default();
2058        config.content_dir = temp.path().join("content");
2059        config.output_dir = temp.path().join("public");
2060        config.template_dir = temp.path().join("templates");
2061        let opts = RunOptions {
2062            quiet: true,
2063            include_drafts: false,
2064            deploy_target: None,
2065            validate_only: false,
2066            jobs: None,
2067            max_memory_mb: None,
2068            ai_fix: false,
2069            ai_fix_dry_run: false,
2070        };
2071
2072        let (plugins, ctx, build_dir, site_dir) =
2073            build_pipeline(&config, &opts);
2074
2075        assert!(plugins.len() >= 10);
2076        assert_ne!(build_dir, site_dir);
2077        assert_eq!(site_dir, temp.path().join("public"));
2078        assert_eq!(ctx.content_dir, temp.path().join("content"));
2079    }
2080
2081    #[test]
2082    fn build_pipeline_with_deploy_target_registers_deploy_plugin() {
2083        let temp = tempdir().unwrap();
2084        let mut config = SsgConfig::default();
2085        config.content_dir = temp.path().join("content");
2086        config.output_dir = temp.path().join("public");
2087
2088        let opts_no_deploy = RunOptions {
2089            quiet: true,
2090            include_drafts: false,
2091            deploy_target: None,
2092            validate_only: false,
2093            jobs: None,
2094            max_memory_mb: None,
2095            ai_fix: false,
2096            ai_fix_dry_run: false,
2097        };
2098        let (no_deploy, _, _, _) = build_pipeline(&config, &opts_no_deploy);
2099
2100        let opts_deploy = RunOptions {
2101            quiet: true,
2102            include_drafts: false,
2103            deploy_target: Some("netlify".to_string()),
2104            validate_only: false,
2105            jobs: None,
2106            max_memory_mb: None,
2107            ai_fix: false,
2108            ai_fix_dry_run: false,
2109        };
2110        let (with_deploy, _, _, _) = build_pipeline(&config, &opts_deploy);
2111
2112        assert_eq!(with_deploy.len(), no_deploy.len() + 1);
2113    }
2114
2115    #[test]
2116    fn build_pipeline_with_unknown_deploy_target_logs_and_skips() {
2117        let temp = tempdir().unwrap();
2118        let mut config = SsgConfig::default();
2119        config.content_dir = temp.path().join("content");
2120        config.output_dir = temp.path().join("public");
2121
2122        let opts = RunOptions {
2123            quiet: true,
2124            include_drafts: false,
2125            deploy_target: Some("nonsense-platform".to_string()),
2126            validate_only: false,
2127            jobs: None,
2128            max_memory_mb: None,
2129            ai_fix: false,
2130            ai_fix_dry_run: false,
2131        };
2132        let (plugins, _, _, _) = build_pipeline(&config, &opts);
2133        let names = plugins.names();
2134        assert!(!names.iter().any(|n| n == &"deploy"));
2135    }
2136
2137    #[test]
2138    fn build_pipeline_with_each_known_deploy_target_registers_one_plugin() {
2139        for target in ["netlify", "vercel", "cloudflare", "github"] {
2140            let temp = tempdir().unwrap();
2141            let mut config = SsgConfig::default();
2142            config.content_dir = temp.path().join("content");
2143            config.output_dir = temp.path().join("public");
2144
2145            let opts = RunOptions {
2146                quiet: true,
2147                include_drafts: false,
2148                deploy_target: Some(target.to_string()),
2149                validate_only: false,
2150                jobs: None,
2151                max_memory_mb: None,
2152                ai_fix: false,
2153                ai_fix_dry_run: false,
2154            };
2155            let (plugins, _, _, _) = build_pipeline(&config, &opts);
2156            assert!(
2157                plugins.names().iter().any(|n| n == &"deploy"),
2158                "deploy plugin should be registered for target `{target}`"
2159            );
2160        }
2161    }
2162
2163    // -----------------------------------------------------------------
2164    // ServeTransport / serve_site_with
2165    // -----------------------------------------------------------------
2166
2167    /// Test transport that records its calls without starting an
2168    /// HTTP server.
2169    #[derive(Debug, Default)]
2170    struct RecordingTransport {
2171        calls: std::sync::Mutex<Vec<(String, String)>>,
2172    }
2173
2174    impl ServeTransport for RecordingTransport {
2175        fn start(&self, addr: &str, root: &str) -> Result<()> {
2176            self.calls
2177                .lock()
2178                .unwrap()
2179                .push((addr.to_string(), root.to_string()));
2180            Ok(())
2181        }
2182    }
2183
2184    /// Test transport that always errors — verifies the error is
2185    /// propagated through `serve_site_with`.
2186    #[derive(Debug, Default)]
2187    struct FailingTransport;
2188
2189    impl ServeTransport for FailingTransport {
2190        fn start(&self, _addr: &str, _root: &str) -> Result<()> {
2191            Err(anyhow::anyhow!("transport failed"))
2192        }
2193    }
2194
2195    #[test]
2196    fn build_serve_address_resolves_path_to_addr_root_pair() {
2197        let (addr, root) = build_serve_address(Path::new("./public")).unwrap();
2198        assert_eq!(
2199            addr,
2200            format!("{}:{}", cmd::DEFAULT_HOST, cmd::DEFAULT_PORT)
2201        );
2202        assert_eq!(root, "./public");
2203    }
2204
2205    #[test]
2206    fn verify_and_copy_files_destination_create_dir_failure_propagates(
2207    ) -> Result<()> {
2208        let temp = tempdir()?;
2209        let blocker = temp.path().join("blocker.txt");
2210        fs::write(&blocker, "i am a file, not a directory")?;
2211
2212        let bad_dst = blocker.join("sub");
2213        let result = verify_and_copy_files(temp.path(), &bad_dst);
2214        assert!(result.is_err());
2215        let msg = format!("{:?}", result.unwrap_err());
2216        assert!(
2217            msg.contains("Failed to create or access destination"),
2218            "expected with_context message, got: {msg}"
2219        );
2220        Ok(())
2221    }
2222
2223    #[cfg(not(target_os = "windows"))] // Unix-specific: path behaviour / error messages differ on Windows
2224    #[test]
2225    fn create_directories_unsafe_path_bails() -> Result<()> {
2226        let temp = tempdir()?;
2227        let blocker = temp.path().join("blocker.txt");
2228        fs::write(&blocker, "x")?;
2229
2230        let unsafe_path = blocker.join("..").join("subdir");
2231
2232        let paths = Paths {
2233            site: temp.path().join("s"),
2234            content: unsafe_path,
2235            build: temp.path().join("b"),
2236            template: temp.path().join("t"),
2237        };
2238        let result = create_directories(&paths);
2239        assert!(result.is_err());
2240        Ok(())
2241    }
2242
2243    #[test]
2244    fn copy_dir_with_progress_read_dir_failure_propagates() -> Result<()> {
2245        let temp = tempdir()?;
2246        let src_file = temp.path().join("not-a-dir.txt");
2247        fs::write(&src_file, "content")?;
2248        let dst = temp.path().join("dst");
2249
2250        let result = copy_dir_with_progress(&src_file, &dst);
2251        assert!(result.is_err());
2252        let msg = format!("{:?}", result.unwrap_err());
2253        assert!(
2254            msg.contains("Failed to read source directory"),
2255            "expected with_context message, got: {msg}"
2256        );
2257        Ok(())
2258    }
2259
2260    #[test]
2261    fn verify_and_copy_files_async_destination_create_dir_failure_propagates(
2262    ) -> Result<()> {
2263        let temp = tempdir()?;
2264        let blocker = temp.path().join("async-blocker.txt");
2265        fs::write(&blocker, "blocker")?;
2266
2267        let bad_dst = blocker.join("sub");
2268        let result = verify_and_copy_files_async(temp.path(), &bad_dst);
2269        assert!(result.is_err());
2270        let msg = format!("{:?}", result.unwrap_err());
2271        assert!(msg.contains("Failed to create or access destination"));
2272        Ok(())
2273    }
2274
2275    #[test]
2276    #[cfg(unix)]
2277    fn build_serve_address_rejects_invalid_utf8_path() {
2278        use std::ffi::OsStr;
2279        use std::os::unix::ffi::OsStrExt;
2280
2281        let invalid_bytes = b"site_\xff_invalid";
2282        let path = Path::new(OsStr::from_bytes(invalid_bytes));
2283        let err = build_serve_address(path).unwrap_err();
2284        assert!(format!("{err:?}").contains("invalid UTF-8"));
2285    }
2286
2287    #[test]
2288    #[cfg(unix)]
2289    fn serve_site_shim_propagates_invalid_utf8_path_error() {
2290        use std::ffi::OsStr;
2291        use std::os::unix::ffi::OsStrExt;
2292        let invalid = b"\xfe\xfe_bad";
2293        let path = Path::new(OsStr::from_bytes(invalid));
2294        let err = serve_site(path).unwrap_err();
2295        assert!(format!("{err:?}").contains("invalid UTF-8"));
2296    }
2297
2298    #[test]
2299    fn serve_site_with_recording_transport_records_addr_and_root() {
2300        let transport = RecordingTransport::default();
2301        serve_site_with(Path::new("./public"), &transport).unwrap();
2302        let calls = transport.calls.lock().unwrap();
2303        assert_eq!(calls.len(), 1);
2304        assert_eq!(calls[0].1, "./public");
2305    }
2306
2307    #[test]
2308    fn serve_site_with_propagates_transport_errors() {
2309        let transport = FailingTransport;
2310        let result = serve_site_with(Path::new("./public"), &transport);
2311        assert!(result.is_err());
2312        assert!(
2313            format!("{:?}", result.unwrap_err()).contains("transport failed")
2314        );
2315    }
2316
2317    #[test]
2318    fn http_transport_implements_serve_transport_trait() {
2319        fn assert_impl<T: ServeTransport>() {}
2320        assert_impl::<HttpTransport>();
2321    }
2322
2323    // -----------------------------------------------------------------
2324    // execute_build_pipeline
2325    // -----------------------------------------------------------------
2326
2327    #[test]
2328    fn execute_build_pipeline_propagates_compile_errors() -> Result<()> {
2329        let temp = tempdir()?;
2330        let mut config = SsgConfig::default();
2331        config.content_dir = temp.path().join("missing-content");
2332        config.output_dir = temp.path().join("public");
2333        config.template_dir = temp.path().join("missing-templates");
2334        config.site_name = "broken".to_string();
2335
2336        let opts = RunOptions {
2337            quiet: true,
2338            include_drafts: false,
2339            deploy_target: None,
2340            validate_only: false,
2341            jobs: None,
2342            max_memory_mb: None,
2343            ai_fix: false,
2344            ai_fix_dry_run: false,
2345        };
2346
2347        let (plugins, ctx, build_dir, site_dir) =
2348            build_pipeline(&config, &opts);
2349
2350        let result = execute_build_pipeline(
2351            &plugins,
2352            &ctx,
2353            &build_dir,
2354            &config.content_dir,
2355            &site_dir,
2356            &config.template_dir,
2357            opts.quiet,
2358        );
2359        assert!(result.is_err(), "broken layout should propagate Err");
2360        Ok(())
2361    }
2362
2363    #[test]
2364    fn execute_build_pipeline_succeeds_against_real_example_fixtures(
2365    ) -> Result<()> {
2366        let cwd = env::current_dir()?;
2367        let content = cwd.join("examples/content/en");
2368        let template = cwd.join("examples/templates/en");
2369        if !content.exists() || !template.exists() {
2370            eprintln!(
2371                "skipping: examples/content/en not present in {}",
2372                cwd.display()
2373            );
2374            return Ok(());
2375        }
2376
2377        let temp = tempdir()?;
2378        let mut config = SsgConfig::default();
2379        config.content_dir = content;
2380        config.template_dir = template;
2381        config.output_dir = temp.path().join("public");
2382        config.site_name = "pipeline-success-test".to_string();
2383        config.base_url = "http://localhost".to_string();
2384
2385        let opts = RunOptions {
2386            quiet: true,
2387            include_drafts: false,
2388            deploy_target: None,
2389            validate_only: false,
2390            jobs: None,
2391            max_memory_mb: None,
2392            ai_fix: false,
2393            ai_fix_dry_run: false,
2394        };
2395
2396        let (plugins, ctx, build_dir, site_dir) =
2397            build_pipeline(&config, &opts);
2398
2399        execute_build_pipeline(
2400            &plugins,
2401            &ctx,
2402            &build_dir,
2403            &config.content_dir,
2404            &site_dir,
2405            &config.template_dir,
2406            opts.quiet,
2407        )?;
2408
2409        assert!(
2410            site_dir.exists() || build_dir.exists(),
2411            "expected build/site dir to exist after successful pipeline"
2412        );
2413        Ok(())
2414    }
2415
2416    #[test]
2417    fn execute_build_pipeline_verbose_success_hits_println_arm() -> Result<()> {
2418        let cwd = env::current_dir()?;
2419        let content = cwd.join("examples/content/en");
2420        let template = cwd.join("examples/templates/en");
2421        if !content.exists() || !template.exists() {
2422            return Ok(());
2423        }
2424
2425        let temp = tempdir()?;
2426        let mut config = SsgConfig::default();
2427        config.content_dir = content;
2428        config.template_dir = template;
2429        config.output_dir = temp.path().join("public");
2430        config.site_name = "verbose-success".to_string();
2431        config.base_url = "http://localhost".to_string();
2432
2433        let opts = RunOptions {
2434            quiet: false,
2435            include_drafts: false,
2436            deploy_target: None,
2437            validate_only: false,
2438            jobs: None,
2439            max_memory_mb: None,
2440            ai_fix: false,
2441            ai_fix_dry_run: false,
2442        };
2443
2444        let (plugins, ctx, build_dir, site_dir) =
2445            build_pipeline(&config, &opts);
2446        execute_build_pipeline(
2447            &plugins,
2448            &ctx,
2449            &build_dir,
2450            &config.content_dir,
2451            &site_dir,
2452            &config.template_dir,
2453            opts.quiet,
2454        )?;
2455        Ok(())
2456    }
2457
2458    #[test]
2459    fn execute_build_pipeline_verbose_propagates_compile_errors() -> Result<()>
2460    {
2461        let temp = tempdir()?;
2462        let mut config = SsgConfig::default();
2463        config.content_dir = temp.path().join("missing");
2464        config.output_dir = temp.path().join("public");
2465        config.template_dir = temp.path().join("missing-templates");
2466        config.site_name = "broken-verbose".to_string();
2467
2468        let opts = RunOptions {
2469            quiet: false,
2470            include_drafts: false,
2471            deploy_target: None,
2472            validate_only: false,
2473            jobs: None,
2474            max_memory_mb: None,
2475            ai_fix: false,
2476            ai_fix_dry_run: false,
2477        };
2478
2479        let (plugins, ctx, build_dir, site_dir) =
2480            build_pipeline(&config, &opts);
2481
2482        let _ = execute_build_pipeline(
2483            &plugins,
2484            &ctx,
2485            &build_dir,
2486            &config.content_dir,
2487            &site_dir,
2488            &config.template_dir,
2489            opts.quiet,
2490        );
2491        Ok(())
2492    }
2493
2494    #[test]
2495    fn build_pipeline_with_drafts_flag_registers_draft_plugin() {
2496        let temp = tempdir().unwrap();
2497        let mut config = SsgConfig::default();
2498        config.content_dir = temp.path().join("content");
2499        config.output_dir = temp.path().join("public");
2500
2501        let opts = RunOptions {
2502            quiet: true,
2503            include_drafts: true,
2504            deploy_target: None,
2505            validate_only: false,
2506            jobs: None,
2507            max_memory_mb: None,
2508            ai_fix: false,
2509            ai_fix_dry_run: false,
2510        };
2511        let (plugins, _, _, _) = build_pipeline(&config, &opts);
2512        assert!(plugins.names().iter().any(|n| n == &"drafts"));
2513    }
2514
2515    // -----------------------------------------------------------------
2516    // now_iso / days_to_ymd coverage
2517    // -----------------------------------------------------------------
2518
2519    #[test]
2520    fn now_iso_returns_valid_iso8601_format() {
2521        let ts = now_iso();
2522        assert_eq!(ts.len(), 20, "ISO timestamp should be 20 chars: {ts}");
2523        assert!(ts.ends_with('Z'), "should end with Z: {ts}");
2524        assert_eq!(&ts[4..5], "-");
2525        assert_eq!(&ts[7..8], "-");
2526        assert_eq!(&ts[10..11], "T");
2527        assert_eq!(&ts[13..14], ":");
2528        assert_eq!(&ts[16..17], ":");
2529        let year: u64 = ts[0..4].parse().unwrap();
2530        assert!(year >= 2020, "year should be recent: {year}");
2531    }
2532
2533    #[test]
2534    fn days_to_ymd_epoch() {
2535        let (y, m, d) = days_to_ymd(0);
2536        assert_eq!((y, m, d), (1970, 1, 1));
2537    }
2538
2539    #[test]
2540    fn days_to_ymd_known_date_2026_04_13() {
2541        let (y, m, d) = days_to_ymd(20_556);
2542        assert_eq!((y, m, d), (2026, 4, 13));
2543    }
2544
2545    #[test]
2546    fn days_to_ymd_leap_day() {
2547        let (y, m, d) = days_to_ymd(11_016);
2548        assert_eq!((y, m, d), (2000, 2, 29));
2549    }
2550
2551    #[test]
2552    fn days_to_ymd_y2k() {
2553        let (y, m, d) = days_to_ymd(10_957);
2554        assert_eq!((y, m, d), (2000, 1, 1));
2555    }
2556
2557    // -----------------------------------------------------------------
2558    // SimpleLogger coverage
2559    // -----------------------------------------------------------------
2560
2561    #[test]
2562    fn simple_logger_enabled_respects_max_level() {
2563        let logger = SimpleLogger;
2564        let meta = log::MetadataBuilder::new()
2565            .level(log::Level::Info)
2566            .target("test")
2567            .build();
2568        let _ = logger.enabled(&meta);
2569    }
2570
2571    #[test]
2572    fn simple_logger_flush_is_noop() {
2573        use log::Log;
2574        let logger = SimpleLogger;
2575        logger.flush();
2576    }
2577
2578    // -----------------------------------------------------------------
2579    // build_serve_address additional coverage
2580    // -----------------------------------------------------------------
2581
2582    #[test]
2583    fn build_serve_address_with_absolute_path() {
2584        let (addr, root) = build_serve_address(Path::new("/tmp/site")).unwrap();
2585        assert!(addr.contains(&cmd::DEFAULT_PORT.to_string()));
2586        assert_eq!(root, "/tmp/site");
2587    }
2588
2589    // -----------------------------------------------------------------
2590    // copy_dir_with_progress file count output
2591    // -----------------------------------------------------------------
2592
2593    #[test]
2594    fn copy_dir_with_progress_counts_files_and_dirs() -> Result<()> {
2595        let src_dir = tempdir()?;
2596        let dst_dir = tempdir()?;
2597
2598        fs::write(src_dir.path().join("a.txt"), "a")?;
2599        fs::write(src_dir.path().join("b.txt"), "b")?;
2600        let sub = src_dir.path().join("sub");
2601        fs::create_dir(&sub)?;
2602        fs::write(sub.join("c.txt"), "c")?;
2603
2604        copy_dir_with_progress(src_dir.path(), dst_dir.path())?;
2605
2606        assert!(dst_dir.path().join("a.txt").exists());
2607        assert!(dst_dir.path().join("b.txt").exists());
2608        assert!(dst_dir.path().join("sub/c.txt").exists());
2609        Ok(())
2610    }
2611
2612    // -----------------------------------------------------------------
2613    // days_to_ymd — additional edge cases
2614    // -----------------------------------------------------------------
2615
2616    #[test]
2617    fn days_to_ymd_end_of_year() {
2618        // Dec 31, 1970 = day 364
2619        let (y, m, d) = days_to_ymd(364);
2620        assert_eq!((y, m, d), (1970, 12, 31));
2621    }
2622
2623    #[test]
2624    fn days_to_ymd_non_leap_year_feb28() {
2625        // Feb 28, 1971 = day 58 + 365 = 423
2626        let (y, m, d) = days_to_ymd(423);
2627        assert_eq!((y, m, d), (1971, 2, 28));
2628    }
2629
2630    #[test]
2631    fn days_to_ymd_non_leap_year_mar1() {
2632        // Mar 1, 1971 = day 424
2633        let (y, m, d) = days_to_ymd(424);
2634        assert_eq!((y, m, d), (1971, 3, 1));
2635    }
2636
2637    #[test]
2638    fn days_to_ymd_century_non_leap() {
2639        // 1900 is NOT a leap year (divisible by 100, not by 400).
2640        // Mar 1, 1900 — we use a negative-offset approach:
2641        // 2000-01-01 is day 10957. 1900-01-01 is 10957 - 36524 = ???
2642        // Easier: just test a few far-future dates.
2643        // 2100-01-01 is NOT a leap year.
2644        // 2100-03-01: days = (2100-1970)*365 + leap_days + 31 + 28
2645        // Instead, let's verify round-trip for several known dates.
2646        let (y, m, d) = days_to_ymd(10_956);
2647        assert_eq!((y, m, d), (1999, 12, 31));
2648    }
2649
2650    #[test]
2651    fn days_to_ymd_large_day_count() {
2652        // Far-future date: 2100-01-01
2653        // 2100-01-01 is day 47482
2654        let (y, m, d) = days_to_ymd(47_482);
2655        assert_eq!((y, m, d), (2100, 1, 1));
2656    }
2657
2658    // -----------------------------------------------------------------
2659    // now_iso — additional format checks
2660    // -----------------------------------------------------------------
2661
2662    #[test]
2663    fn now_iso_month_and_day_within_range() {
2664        let ts = now_iso();
2665        let month: u32 = ts[5..7].parse().unwrap();
2666        let day: u32 = ts[8..10].parse().unwrap();
2667        let hour: u32 = ts[11..13].parse().unwrap();
2668        let minute: u32 = ts[14..16].parse().unwrap();
2669        let second: u32 = ts[17..19].parse().unwrap();
2670        assert!((1..=12).contains(&month), "month out of range: {month}");
2671        assert!((1..=31).contains(&day), "day out of range: {day}");
2672        assert!(hour < 24, "hour out of range: {hour}");
2673        assert!(minute < 60, "minute out of range: {minute}");
2674        assert!(second < 60, "second out of range: {second}");
2675    }
2676
2677    // -----------------------------------------------------------------
2678    // Paths — additional validation edge cases
2679    // -----------------------------------------------------------------
2680
2681    #[test]
2682    fn paths_validate_double_slash_in_content() {
2683        let paths = Paths {
2684            site: PathBuf::from("public"),
2685            content: PathBuf::from("content//nested"),
2686            build: PathBuf::from("build"),
2687            template: PathBuf::from("templates"),
2688        };
2689        let err = paths.validate().unwrap_err();
2690        assert!(err.to_string().contains("invalid double slashes"));
2691    }
2692
2693    #[test]
2694    fn paths_validate_traversal_in_build() {
2695        let paths = Paths {
2696            site: PathBuf::from("public"),
2697            content: PathBuf::from("content"),
2698            build: PathBuf::from("../build"),
2699            template: PathBuf::from("templates"),
2700        };
2701        let err = paths.validate().unwrap_err();
2702        assert!(err.to_string().contains("directory traversal"));
2703    }
2704
2705    #[test]
2706    fn paths_validate_traversal_in_template() {
2707        let paths = Paths {
2708            site: PathBuf::from("public"),
2709            content: PathBuf::from("content"),
2710            build: PathBuf::from("build"),
2711            template: PathBuf::from("../templates"),
2712        };
2713        let err = paths.validate().unwrap_err();
2714        assert!(err.to_string().contains("directory traversal"));
2715    }
2716
2717    #[test]
2718    fn paths_validate_double_slash_in_build() {
2719        let paths = Paths {
2720            site: PathBuf::from("public"),
2721            content: PathBuf::from("content"),
2722            build: PathBuf::from("build//sub"),
2723            template: PathBuf::from("templates"),
2724        };
2725        let err = paths.validate().unwrap_err();
2726        assert!(err.to_string().contains("invalid double slashes"));
2727    }
2728
2729    #[test]
2730    fn paths_validate_double_slash_in_template() {
2731        let paths = Paths {
2732            site: PathBuf::from("public"),
2733            content: PathBuf::from("content"),
2734            build: PathBuf::from("build"),
2735            template: PathBuf::from("templates//sub"),
2736        };
2737        let err = paths.validate().unwrap_err();
2738        assert!(err.to_string().contains("invalid double slashes"));
2739    }
2740
2741    // -----------------------------------------------------------------
2742    // PathsBuilder — additional coverage
2743    // -----------------------------------------------------------------
2744
2745    #[test]
2746    fn paths_builder_partial_override() -> Result<()> {
2747        let paths = Paths::builder()
2748            .site("custom_site")
2749            .template("custom_templates")
2750            .build()?;
2751        assert_eq!(paths.site, PathBuf::from("custom_site"));
2752        assert_eq!(paths.content, PathBuf::from("content"));
2753        assert_eq!(paths.build, PathBuf::from("build"));
2754        assert_eq!(paths.template, PathBuf::from("custom_templates"));
2755        Ok(())
2756    }
2757
2758    #[test]
2759    fn paths_debug_format() {
2760        let paths = Paths::default_paths();
2761        let debug = format!("{paths:?}");
2762        assert!(debug.contains("site"));
2763        assert!(debug.contains("content"));
2764    }
2765
2766    // -----------------------------------------------------------------
2767    // RunOptions — additional flag combinations
2768    // -----------------------------------------------------------------
2769
2770    #[test]
2771    fn run_options_from_matches_extracts_validate_flag() {
2772        let cli = Cli::build();
2773        let matches = cli
2774            .try_get_matches_from(vec!["ssg", "--validate"])
2775            .expect("matches");
2776        let opts = RunOptions::from_matches(&matches);
2777        assert!(opts.validate_only);
2778        assert!(!opts.quiet);
2779        assert!(!opts.include_drafts);
2780    }
2781
2782    #[test]
2783    fn run_options_from_matches_extracts_jobs_flag() {
2784        let cli = Cli::build();
2785        let matches = cli
2786            .try_get_matches_from(vec!["ssg", "--jobs", "8"])
2787            .expect("matches");
2788        let opts = RunOptions::from_matches(&matches);
2789        assert_eq!(opts.jobs, Some(8));
2790    }
2791
2792    #[test]
2793    fn run_options_from_matches_extracts_max_memory_flag() {
2794        let cli = Cli::build();
2795        let matches = cli
2796            .try_get_matches_from(vec!["ssg", "--max-memory", "256"])
2797            .expect("matches");
2798        let opts = RunOptions::from_matches(&matches);
2799        assert_eq!(opts.max_memory_mb, Some(256));
2800    }
2801
2802    #[test]
2803    fn run_options_from_matches_extracts_ai_fix_flags() {
2804        let cli = Cli::build();
2805        let matches = cli
2806            .try_get_matches_from(vec!["ssg", "--ai-fix", "--ai-fix-dry-run"])
2807            .expect("matches");
2808        let opts = RunOptions::from_matches(&matches);
2809        assert!(opts.ai_fix);
2810        assert!(opts.ai_fix_dry_run);
2811    }
2812
2813    #[test]
2814    fn run_options_from_matches_all_flags_combined() {
2815        let cli = Cli::build();
2816        let matches = cli
2817            .try_get_matches_from(vec![
2818                "ssg",
2819                "--quiet",
2820                "--drafts",
2821                "--deploy",
2822                "vercel",
2823                "--validate",
2824                "--jobs",
2825                "4",
2826                "--max-memory",
2827                "1024",
2828                "--ai-fix",
2829                "--ai-fix-dry-run",
2830            ])
2831            .expect("matches");
2832        let opts = RunOptions::from_matches(&matches);
2833        assert!(opts.quiet);
2834        assert!(opts.include_drafts);
2835        assert_eq!(opts.deploy_target.as_deref(), Some("vercel"));
2836        assert!(opts.validate_only);
2837        assert_eq!(opts.jobs, Some(4));
2838        assert_eq!(opts.max_memory_mb, Some(1024));
2839        assert!(opts.ai_fix);
2840        assert!(opts.ai_fix_dry_run);
2841    }
2842
2843    // -----------------------------------------------------------------
2844    // build_pipeline — memory budget propagation
2845    // -----------------------------------------------------------------
2846
2847    #[test]
2848    fn build_pipeline_propagates_max_memory_to_context() {
2849        let temp = tempdir().unwrap();
2850        let mut config = SsgConfig::default();
2851        config.content_dir = temp.path().join("content");
2852        config.output_dir = temp.path().join("public");
2853        config.template_dir = temp.path().join("templates");
2854
2855        let opts = RunOptions {
2856            quiet: true,
2857            include_drafts: false,
2858            deploy_target: None,
2859            validate_only: false,
2860            jobs: None,
2861            max_memory_mb: Some(128),
2862            ai_fix: false,
2863            ai_fix_dry_run: false,
2864        };
2865
2866        let (_plugins, ctx, _build_dir, _site_dir) =
2867            build_pipeline(&config, &opts);
2868
2869        assert!(
2870            ctx.memory_budget.is_some(),
2871            "memory_budget should be set when max_memory_mb is provided"
2872        );
2873    }
2874
2875    #[test]
2876    fn build_pipeline_no_memory_budget_when_not_specified() {
2877        let temp = tempdir().unwrap();
2878        let mut config = SsgConfig::default();
2879        config.content_dir = temp.path().join("content");
2880        config.output_dir = temp.path().join("public");
2881        config.template_dir = temp.path().join("templates");
2882
2883        let opts = RunOptions {
2884            quiet: true,
2885            include_drafts: false,
2886            deploy_target: None,
2887            validate_only: false,
2888            jobs: None,
2889            max_memory_mb: None,
2890            ai_fix: false,
2891            ai_fix_dry_run: false,
2892        };
2893
2894        let (_plugins, ctx, _build_dir, _site_dir) =
2895            build_pipeline(&config, &opts);
2896
2897        assert!(
2898            ctx.memory_budget.is_none(),
2899            "memory_budget should be None when max_memory_mb not provided"
2900        );
2901    }
2902
2903    // -----------------------------------------------------------------
2904    // build_pipeline — deploy targets: vercel, cloudflare, github
2905    // -----------------------------------------------------------------
2906
2907    #[test]
2908    fn build_pipeline_with_vercel_deploy_target() {
2909        let temp = tempdir().unwrap();
2910        let mut config = SsgConfig::default();
2911        config.content_dir = temp.path().join("content");
2912        config.output_dir = temp.path().join("public");
2913
2914        let opts = RunOptions {
2915            quiet: true,
2916            include_drafts: false,
2917            deploy_target: Some("vercel".to_string()),
2918            validate_only: false,
2919            jobs: None,
2920            max_memory_mb: None,
2921            ai_fix: false,
2922            ai_fix_dry_run: false,
2923        };
2924        let (plugins, _, _, _) = build_pipeline(&config, &opts);
2925        assert!(plugins.names().iter().any(|n| n == &"deploy"));
2926    }
2927
2928    #[test]
2929    fn build_pipeline_with_cloudflare_deploy_target() {
2930        let temp = tempdir().unwrap();
2931        let mut config = SsgConfig::default();
2932        config.content_dir = temp.path().join("content");
2933        config.output_dir = temp.path().join("public");
2934
2935        let opts = RunOptions {
2936            quiet: true,
2937            include_drafts: false,
2938            deploy_target: Some("cloudflare".to_string()),
2939            validate_only: false,
2940            jobs: None,
2941            max_memory_mb: None,
2942            ai_fix: false,
2943            ai_fix_dry_run: false,
2944        };
2945        let (plugins, _, _, _) = build_pipeline(&config, &opts);
2946        assert!(plugins.names().iter().any(|n| n == &"deploy"));
2947    }
2948
2949    #[test]
2950    fn build_pipeline_with_github_deploy_target() {
2951        let temp = tempdir().unwrap();
2952        let mut config = SsgConfig::default();
2953        config.content_dir = temp.path().join("content");
2954        config.output_dir = temp.path().join("public");
2955
2956        let opts = RunOptions {
2957            quiet: true,
2958            include_drafts: false,
2959            deploy_target: Some("github".to_string()),
2960            validate_only: false,
2961            jobs: None,
2962            max_memory_mb: None,
2963            ai_fix: false,
2964            ai_fix_dry_run: false,
2965        };
2966        let (plugins, _, _, _) = build_pipeline(&config, &opts);
2967        assert!(plugins.names().iter().any(|n| n == &"deploy"));
2968    }
2969
2970    // -----------------------------------------------------------------
2971    // resolve_build_and_site_dirs — additional edge cases
2972    // -----------------------------------------------------------------
2973
2974    #[test]
2975    fn resolve_build_and_site_dirs_serve_dir_none_uses_output_dir_as_site() {
2976        let mut config = SsgConfig::default();
2977        config.output_dir = PathBuf::from("my-output");
2978        config.serve_dir = None;
2979
2980        let (_build_dir, site_dir) = resolve_build_and_site_dirs(&config);
2981        assert_eq!(site_dir, PathBuf::from("my-output"));
2982    }
2983
2984    #[test]
2985    fn resolve_build_and_site_dirs_always_produces_distinct_dirs() {
2986        // Even when serve_dir == output_dir, build != site
2987        let mut config = SsgConfig::default();
2988        config.output_dir = PathBuf::from("same");
2989        config.serve_dir = Some(PathBuf::from("same"));
2990
2991        let (build_dir, site_dir) = resolve_build_and_site_dirs(&config);
2992        assert_ne!(build_dir, site_dir);
2993        assert_eq!(site_dir, PathBuf::from("same"));
2994        assert!(
2995            build_dir.to_string_lossy().contains("build-tmp"),
2996            "expected build-tmp suffix, got: {}",
2997            build_dir.display()
2998        );
2999    }
3000
3001    // -----------------------------------------------------------------
3002    // generate_locale_redirect coverage
3003    // -----------------------------------------------------------------
3004
3005    #[test]
3006    fn generate_locale_redirect_creates_index_html() -> Result<()> {
3007        let temp = tempdir()?;
3008        let locales = vec!["en".to_string(), "fr".to_string()];
3009        generate_locale_redirect(temp.path(), &locales, "en")?;
3010
3011        let index = temp.path().join("index.html");
3012        assert!(index.exists());
3013        let content = fs::read_to_string(&index)?;
3014        assert!(content.contains("ssg-locale-redirect"));
3015        assert!(content.contains("\"en\""));
3016        assert!(content.contains("\"fr\""));
3017        Ok(())
3018    }
3019
3020    #[test]
3021    fn generate_locale_redirect_does_not_overwrite_user_index() -> Result<()> {
3022        let temp = tempdir()?;
3023        let user_html = "<html><body>My site</body></html>";
3024        fs::write(temp.path().join("index.html"), user_html)?;
3025
3026        let locales = vec!["en".to_string()];
3027        generate_locale_redirect(temp.path(), &locales, "en")?;
3028
3029        let content = fs::read_to_string(temp.path().join("index.html"))?;
3030        assert_eq!(content, user_html, "user index.html should be preserved");
3031        Ok(())
3032    }
3033
3034    #[test]
3035    fn generate_locale_redirect_overwrites_own_index() -> Result<()> {
3036        let temp = tempdir()?;
3037        let old_redirect = "<!-- ssg-locale-redirect --><html>old</html>";
3038        fs::write(temp.path().join("index.html"), old_redirect)?;
3039
3040        let locales = vec!["de".to_string(), "en".to_string()];
3041        generate_locale_redirect(temp.path(), &locales, "de")?;
3042
3043        let content = fs::read_to_string(temp.path().join("index.html"))?;
3044        assert!(content.contains("ssg-locale-redirect"));
3045        assert!(content.contains("\"de\""));
3046        Ok(())
3047    }
3048}
3049
3050#[cfg(test)]
3051#[allow(clippy::unwrap_used, clippy::expect_used)]
3052mod proptests {
3053    use proptest::prelude::*;
3054
3055    proptest! {
3056        #![proptest_config(ProptestConfig::with_cases(1000))]
3057
3058        /// `frontmatter_gen::extract` must never panic on arbitrary input.
3059        #[test]
3060        fn parse_frontmatter_never_panics(input in "\\PC*") {
3061            let _ = frontmatter_gen::extract(&input);
3062        }
3063
3064        /// Compiling arbitrary Markdown via pulldown-cmark must never panic
3065        /// and must produce valid UTF-8 (guaranteed by `String`).
3066        #[test]
3067        fn compile_markdown_never_panics(input in "\\PC*") {
3068            use pulldown_cmark::{Parser, html};
3069            let parser = Parser::new(&input);
3070            let mut output = String::new();
3071            html::push_html(&mut output, parser);
3072            // output is a String — valid UTF-8 by construction.
3073            // Reaching this point without a panic is the property.
3074            drop(output);
3075        }
3076
3077        /// Reading time of non-empty text must be at least 1 minute.
3078        #[test]
3079        fn reading_time_at_least_one(input in ".{1,5000}") {
3080            let word_count = input.split_whitespace().count();
3081            let minutes = (word_count / 200).max(1);
3082            prop_assert!(minutes >= 1, "reading time was {}", minutes);
3083        }
3084    }
3085}