1#![forbid(unsafe_code)]
2#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
3#![doc = include_str!("../README.md")]
6#![doc(
7 html_favicon_url = "https://cloudcdn.pro/static-site-generator/v1/favicon.ico",
8 html_logo_url = "https://cloudcdn.pro/static-site-generator/v1/logos/static-site-generator.svg",
9 html_root_url = "https://docs.rs/ssg"
10)]
11#![crate_name = "ssg"]
12#![crate_type = "lib"]
13
14#[cfg(feature = "test-fault-injection")]
18macro_rules! fail_point {
19 ($name:expr, $body:expr) => {
20 fail::fail_point!($name, $body);
21 };
22}
23#[cfg(not(feature = "test-fault-injection"))]
24macro_rules! fail_point {
25 ($name:expr, $body:expr) => {};
26}
27
28#[cfg(test)]
30#[allow(unreachable_pub, clippy::unwrap_used, clippy::expect_used)]
31pub(crate) mod test_support {
32 use std::sync::Once;
33
34 static LOGGER: Once = Once::new();
35
36 pub fn init_logger() {
43 LOGGER.call_once(|| {
44 log::set_max_level(log::LevelFilter::Trace);
45 });
46 }
47}
48
49use std::{
51 fs,
52 path::{Path, PathBuf},
53};
54
55use crate::cmd::{Cli, CliInvocation, SsgConfig};
56
57use log::{debug, info};
59
60#[must_use]
74#[allow(clippy::many_single_char_names)]
75pub fn now_iso() -> String {
76 use std::time::{SystemTime, UNIX_EPOCH};
77 let dur = SystemTime::now()
78 .duration_since(UNIX_EPOCH)
79 .unwrap_or_default();
80 let secs = dur.as_secs();
81 let (sec, min, hour) = (secs % 60, (secs / 60) % 60, (secs / 3600) % 24);
82 let days = secs / 86400;
83 let (year, month, day) = days_to_ymd(days);
84 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z")
85}
86
87const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
89 let z = days + 719_468;
90 let era = z / 146_097;
91 let doe = z - era * 146_097;
92 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
93 let y = yoe + era * 400;
94 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
95 let mp = (5 * doy + 2) / 153;
96 let d = doy - (153 * mp + 2) / 5 + 1;
97 let m = if mp < 10 { mp + 3 } else { mp - 9 };
98 let y = if m <= 2 { y + 1 } else { y };
99 (y, m, d)
100}
101
102pub mod audit;
103pub mod cmd;
104#[path = "core/mod.rs"]
105pub(crate) mod core_group;
106pub mod error;
107#[path = "plugins/mod.rs"]
108pub(crate) mod plugins_group;
109#[path = "server/mod.rs"]
110pub(crate) mod server_group;
111pub mod theme;
112#[path = "util/mod.rs"]
113pub mod util;
114pub use error::{PathErrorExt, SsgError};
115
116pub use crate::core_group::bench_corpus;
118pub use crate::core_group::cache;
119pub use crate::core_group::collections;
120pub use crate::core_group::content;
121pub use crate::core_group::content_stager;
122pub use crate::core_group::dates;
123pub use crate::core_group::depgraph;
124pub use crate::core_group::deploy;
125pub use crate::core_group::deploy_adapter;
126pub use crate::core_group::frontmatter;
127pub use crate::core_group::fs_ops;
128pub use crate::core_group::io_pool;
129pub use crate::core_group::logging;
130pub use crate::core_group::otel;
131pub use crate::core_group::pipeline;
132pub use crate::core_group::process;
133pub use crate::core_group::scaffold;
134pub use crate::core_group::schema;
135pub use crate::core_group::stream;
136pub use crate::core_group::streaming;
137#[cfg(feature = "templates")]
138pub use crate::core_group::template_engine;
139pub use crate::core_group::theme_manifest;
140pub use crate::core_group::urls;
141pub use crate::core_group::walk;
142
143pub use crate::plugins_group::accessibility;
145pub use crate::plugins_group::agent_api;
146pub use crate::plugins_group::ai;
147pub use crate::plugins_group::assets;
148pub use crate::plugins_group::audit as audit_plugin;
149pub use crate::plugins_group::csp;
150pub use crate::plugins_group::drafts;
151pub use crate::plugins_group::highlight;
152#[cfg(feature = "i18n")]
153pub use crate::plugins_group::i18n;
154#[cfg(feature = "image-optimization")]
155pub use crate::plugins_group::image_plugin;
156pub use crate::plugins_group::islands;
157pub use crate::plugins_group::isr_manifest;
158pub use crate::plugins_group::listings;
159pub use crate::plugins_group::llm;
160pub use crate::plugins_group::llm_cache;
161pub use crate::plugins_group::markdown_ext;
162pub use crate::plugins_group::oembed;
163pub use crate::plugins_group::og_image;
164pub use crate::plugins_group::pagination;
165pub use crate::plugins_group::plugin;
166pub use crate::plugins_group::plugins;
167pub use crate::plugins_group::postprocess;
168pub use crate::plugins_group::rpc_schema;
169pub use crate::plugins_group::sbom;
170pub use crate::plugins_group::search;
171pub use crate::plugins_group::search_index;
172pub use crate::plugins_group::seo;
173pub use crate::plugins_group::shortcodes;
174pub use crate::plugins_group::taxonomy;
175#[cfg(feature = "templates")]
176pub use crate::plugins_group::template_plugin;
177pub use crate::plugins_group::topic_clusters;
178pub use crate::plugins_group::view_transitions;
179
180pub use crate::server_group::dev_server;
182pub use crate::server_group::event_watch;
183pub use crate::server_group::hmr;
184pub use crate::server_group::livereload;
185pub use crate::server_group::server;
186pub use crate::server_group::watch;
187
188pub use staticdatagen;
190
191pub use crate::core_group::fs_ops::{
193 collect_files_recursive, copy_dir_all, copy_dir_all_async,
194 copy_dir_with_progress, is_path_within_root, is_safe_path,
195 verify_and_copy_files, verify_and_copy_files_async, verify_file_safety,
196};
197pub use crate::core_group::logging::{
198 create_log_file, log_arguments, log_initialization,
199};
200pub use crate::core_group::pipeline::{compile_site, execute_build_pipeline};
201pub use crate::server_group::server::{
202 generate_locale_redirect, handle_server, prepare_serve_dir, serve_site,
203 serve_site_with, HttpTransport, ServeTransport,
204};
205
206pub const MAX_DIR_DEPTH: usize = 128;
210
211#[derive(Debug, Clone)]
213pub struct Paths {
214 pub site: PathBuf,
216 pub content: PathBuf,
218 pub build: PathBuf,
220 pub template: PathBuf,
222}
223
224impl Paths {
225 #[must_use]
242 pub fn builder() -> PathsBuilder {
243 PathsBuilder::default()
244 }
245
246 #[must_use]
260 pub fn default_paths() -> Self {
261 Self {
262 site: PathBuf::from("public"),
263 content: PathBuf::from("content"),
264 build: PathBuf::from("build"),
265 template: PathBuf::from("templates"),
266 }
267 }
268}
269impl Paths {
271 pub fn validate(&self) -> Result<(), SsgError> {
297 for (name, path) in [
299 ("site", &self.site),
300 ("content", &self.content),
301 ("build", &self.build),
302 ("template", &self.template),
303 ] {
304 let path_str = path.to_string_lossy();
306 if path_str.contains("..") {
307 return Err(SsgError::PathTraversal { path: path.clone() });
308 }
309 if path_str.contains("//") {
310 return Err(SsgError::Validation {
311 field: name.to_string(),
312 message: format!(
313 "path contains invalid double slashes: {}",
314 path.display()
315 ),
316 });
317 }
318
319 if path.exists() {
321 let metadata = symlink_metadata_checked(path)?;
322
323 if metadata.file_type().is_symlink() {
324 return Err(SsgError::SymlinkForbidden {
325 path: path.clone(),
326 });
327 }
328 }
329 }
330
331 Ok(())
332 }
333}
334
335fn symlink_metadata_checked(path: &Path) -> Result<fs::Metadata, SsgError> {
342 fail_point!("lib::symlink-metadata", |_| Err(SsgError::Validation {
343 field: "path".to_string(),
344 message: "injected: lib::symlink-metadata".to_string(),
345 }));
346 path.symlink_metadata().with_path(path)
347}
348
349fn is_safe_path_checked(path: &Path) -> Result<bool, SsgError> {
357 fail_point!("lib::is-safe-path", |_| Err(SsgError::Validation {
358 field: "path".to_string(),
359 message: "injected: lib::is-safe-path".to_string(),
360 }));
361 is_safe_path(path)
362}
363
364#[derive(Debug, Default, Clone)]
366pub struct PathsBuilder {
367 pub site: Option<PathBuf>,
369 pub content: Option<PathBuf>,
371 pub build: Option<PathBuf>,
373 pub template: Option<PathBuf>,
375}
376
377impl PathsBuilder {
378 pub fn site<P: Into<PathBuf>>(mut self, path: P) -> Self {
389 self.site = Some(path.into());
390 self
391 }
392
393 pub fn content<P: Into<PathBuf>>(mut self, path: P) -> Self {
404 self.content = Some(path.into());
405 self
406 }
407
408 pub fn build_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
419 self.build = Some(path.into());
420 self
421 }
422
423 pub fn template<P: Into<PathBuf>>(mut self, path: P) -> Self {
434 self.template = Some(path.into());
435 self
436 }
437
438 pub fn relative_to<P: AsRef<Path>>(self, base: P) -> Self {
453 let base = base.as_ref();
454 self.site(base.join("public"))
455 .content(base.join("content"))
456 .build_dir(base.join("build"))
457 .template(base.join("templates"))
458 }
459
460 pub fn build(self) -> Result<Paths, SsgError> {
482 let paths = Paths {
483 site: self.site.unwrap_or_else(|| PathBuf::from("public")),
484 content: self.content.unwrap_or_else(|| PathBuf::from("content")),
485 build: self.build.unwrap_or_else(|| PathBuf::from("build")),
486 template: self
487 .template
488 .unwrap_or_else(|| PathBuf::from("templates")),
489 };
490
491 paths.validate()?;
493
494 Ok(paths)
495 }
496}
497
498pub fn create_directories(paths: &Paths) -> Result<(), SsgError> {
539 if !is_safe_path_checked(&paths.content)? {
546 return Err(SsgError::PathTraversal {
547 path: paths.content.clone(),
548 });
549 }
550 if !is_safe_path_checked(&paths.build)? {
551 return Err(SsgError::PathTraversal {
552 path: paths.build.clone(),
553 });
554 }
555 if !is_safe_path_checked(&paths.site)? {
556 return Err(SsgError::PathTraversal {
557 path: paths.site.clone(),
558 });
559 }
560 if !is_safe_path_checked(&paths.template)? {
561 return Err(SsgError::PathTraversal {
562 path: paths.template.clone(),
563 });
564 }
565
566 for (_name, path) in [
568 ("content", &paths.content),
569 ("build", &paths.build),
570 ("site", &paths.site),
571 ("template", &paths.template),
572 ] {
573 fs::create_dir_all(path).with_path(path)?;
574 }
575
576 Ok(())
577}
578
579pub fn run() -> Result<(), SsgError> {
596 let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
597 run_with_argv(argv)
598}
599
600fn run_with_argv(argv: Vec<std::ffi::OsString>) -> Result<(), SsgError> {
606 let (invocation, matches) = match Cli::parse_and_dispatch(argv) {
610 Ok(pair) => pair,
611 Err(e) => e.exit(),
615 };
616
617 initialize_logging_checked()?;
618
619 let trace_flag = if matches.try_contains_id("trace").unwrap_or(false) {
624 matches.get_flag("trace")
625 } else {
626 false
627 };
628 let _ = otel::init_if_enabled(trace_flag);
629
630 debug!("Starting dispatch");
633
634 dispatch_invocation(invocation, &matches)
635}
636
637fn dispatch_invocation(
639 invocation: CliInvocation,
640 matches: &clap::ArgMatches,
641) -> Result<(), SsgError> {
642 let announces_site = generates_site(&invocation);
643
644 let result = match invocation {
645 CliInvocation::Legacy => run_legacy(matches),
646 CliInvocation::Build => run_subcommand(matches, "build", false),
647 CliInvocation::Dev => run_subcommand(matches, "dev", true),
648 CliInvocation::Check => run_check(matches),
649 CliInvocation::Audit => run_audit(matches),
650 CliInvocation::Deploy { target } => run_deploy(matches, &target),
651 CliInvocation::Plugins { json, target } => {
652 run_plugins(json, target.as_deref())
653 }
654 };
655
656 if result.is_ok() && announces_site {
659 eprintln!("Site generated successfully.");
660 }
661 result
662}
663
664const fn generates_site(invocation: &CliInvocation) -> bool {
672 match invocation {
673 CliInvocation::Legacy
674 | CliInvocation::Build
675 | CliInvocation::Dev
676 | CliInvocation::Deploy { .. } => true,
677 CliInvocation::Check
681 | CliInvocation::Audit
682 | CliInvocation::Plugins { .. } => false,
683 }
684}
685
686fn run_plugins(json: bool, target: Option<&str>) -> Result<(), SsgError> {
693 let config = SsgConfig::discover_config_file()
698 .and_then(|path| SsgConfig::from_file(&path).ok())
699 .unwrap_or_default();
700
701 let mut plugins = plugin::PluginManager::new();
702 pipeline::register_default_plugins(&mut plugins, &config, false, target);
703 let inventory = plugins.inventory();
704
705 if json {
706 let rows: Vec<_> = inventory
707 .iter()
708 .map(|p| {
709 serde_json::json!({
710 "order": p.order,
711 "name": p.name,
712 "has_transform": p.has_transform,
713 "needs_all_files": p.needs_all_files,
714 })
715 })
716 .collect();
717 let doc = serde_json::json!({
718 "count": inventory.len(),
719 "plugins": rows,
720 });
721 println!(
722 "{}",
723 serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".into())
724 );
725 return Ok(());
726 }
727
728 println!("{} plugin(s), in execution order:\n", inventory.len());
729 println!(
730 " {:>3} {:<28} {:>9} {:>9}",
731 "#", "NAME", "TRANSFORM", "ALL-FILES"
732 );
733 for p in &inventory {
734 println!(
735 " {:>3} {:<28} {:>9} {:>9}",
736 p.order,
737 p.name,
738 if p.has_transform { "yes" } else { "-" },
739 if p.needs_all_files { "yes" } else { "-" },
740 );
741 }
742 Ok(())
743}
744
745fn initialize_logging_checked() -> Result<(), SsgError> {
753 fail_point!("lib::initialize-logging", |_| Err(SsgError::Validation {
754 field: "logging".to_string(),
755 message: "injected: lib::initialize-logging".to_string(),
756 }));
757 logging::initialize_logging()
758}
759
760fn run_on_serve_checked(
768 plugins: &plugin::PluginManager,
769 ctx: &plugin::PluginContext,
770) -> Result<(), SsgError> {
771 fail_point!("lib::run-on-serve", |_| Err(SsgError::Validation {
772 field: "serve".to_string(),
773 message: "injected: lib::run-on-serve".to_string(),
774 }));
775 plugins.run_on_serve(ctx)
776}
777
778fn run_audit(matches: &clap::ArgMatches) -> Result<(), SsgError> {
784 let sub_m = matches.subcommand_matches("audit").ok_or_else(|| {
785 SsgError::Validation {
786 field: "subcommand".to_string(),
787 message: "missing matches for `audit`".to_string(),
788 }
789 })?;
790 cmd::audit::run_and_dispatch(sub_m, false)
791}
792
793fn run_legacy(matches: &clap::ArgMatches) -> Result<(), SsgError> {
795 if let Some(name) = matches.get_one::<String>("new") {
802 return scaffold::scaffold_project_at(name, Path::new(".")).map_err(
803 |e| SsgError::Validation {
804 field: "new".to_string(),
805 message: e.to_string(),
806 },
807 );
808 }
809
810 let config =
811 SsgConfig::from_matches(matches).map_err(|e| SsgError::Validation {
812 field: "config".to_string(),
813 message: e.to_string(),
814 })?;
815 let opts = pipeline::RunOptions::from_matches(matches);
816
817 apply_rayon_thread_pool(opts.jobs)?;
818
819 if opts.validate_only {
820 return content::validate_only(&config.content_dir).map_err(|e| {
821 SsgError::Validation {
822 field: "content".to_string(),
823 message: e.to_string(),
824 }
825 });
826 }
827
828 if !opts.quiet {
829 Cli::print_banner();
830 }
831
832 let (plugins, ctx, build_dir, site_dir) =
833 pipeline::build_pipeline(&config, &opts);
834
835 pipeline::execute_build_pipeline_with(
836 &plugins,
837 &ctx,
838 &build_dir,
839 &config.content_dir,
840 &site_dir,
841 &config.template_dir,
842 opts.quiet,
843 opts.incremental,
844 )?;
845
846 if config.serve_dir.is_some() {
848 run_on_serve_checked(&plugins, &ctx)?;
849 serve_site(&site_dir)
850 } else {
851 Ok(())
852 }
853}
854
855fn run_subcommand(
860 matches: &clap::ArgMatches,
861 name: &str,
862 start_server: bool,
863) -> Result<(), SsgError> {
864 let sub_m = matches.subcommand_matches(name).ok_or_else(|| {
865 SsgError::Validation {
866 field: "subcommand".to_string(),
867 message: format!("missing matches for `{name}`"),
868 }
869 })?;
870
871 let config = build_config_from_subcommand_matches(sub_m)?;
872 let opts = pipeline::RunOptions::from_subcommand_matches(sub_m);
873
874 apply_rayon_thread_pool(opts.jobs)?;
875
876 if !opts.quiet {
877 Cli::print_banner();
878 }
879
880 let (plugins, ctx, build_dir, site_dir) =
881 pipeline::build_pipeline(&config, &opts);
882
883 pipeline::execute_build_pipeline_with(
884 &plugins,
885 &ctx,
886 &build_dir,
887 &config.content_dir,
888 &site_dir,
889 &config.template_dir,
890 opts.quiet,
891 opts.incremental,
892 )?;
893
894 if start_server {
895 run_on_serve_checked(&plugins, &ctx)?;
896 serve_site(&site_dir)
897 } else {
898 Ok(())
899 }
900}
901
902fn run_check(matches: &clap::ArgMatches) -> Result<(), SsgError> {
908 let sub_m = matches.subcommand_matches("check").ok_or_else(|| {
909 SsgError::Validation {
910 field: "subcommand".to_string(),
911 message: "missing matches for `check`".to_string(),
912 }
913 })?;
914
915 let config = build_config_from_subcommand_matches(sub_m)?;
916 let opts = pipeline::RunOptions::from_subcommand_matches(sub_m);
917
918 apply_rayon_thread_pool(opts.jobs)?;
919
920 content::validate_only(&config.content_dir).map_err(|e| {
924 SsgError::Validation {
925 field: "content".to_string(),
926 message: e.to_string(),
927 }
928 })?;
929
930 let (plugins, ctx, _build_dir, _site_dir) =
936 pipeline::build_pipeline(&config, &opts);
937 let ctx = ctx.with_dry_run(true);
938 plugins.run_before_compile(&ctx)?;
939
940 if !opts.quiet {
941 println!("check: all validators passed");
942 }
943 Ok(())
944}
945
946fn run_deploy(
952 matches: &clap::ArgMatches,
953 target: &str,
954) -> Result<(), SsgError> {
955 let sub_m = matches.subcommand_matches("deploy").ok_or_else(|| {
956 SsgError::Validation {
957 field: "subcommand".to_string(),
958 message: "missing matches for `deploy`".to_string(),
959 }
960 })?;
961
962 let config = build_config_from_subcommand_matches(sub_m)?;
963 let opts = pipeline::RunOptions::from_subcommand_matches(sub_m);
964
965 apply_rayon_thread_pool(opts.jobs)?;
966
967 if !opts.quiet {
968 Cli::print_banner();
969 }
970
971 let (plugins, ctx, build_dir, site_dir) =
972 pipeline::build_pipeline(&config, &opts);
973
974 execute_build_pipeline(
975 &plugins,
976 &ctx,
977 &build_dir,
978 &config.content_dir,
979 &site_dir,
980 &config.template_dir,
981 opts.quiet,
982 )?;
983
984 let target_enum = deploy_adapter::Target::from_cli(target)?;
985 let adapter = deploy_adapter::adapter_for(target_enum);
986 if !opts.quiet {
987 println!("deploy: invoking adapter `{}`", adapter.name());
988 }
989 adapter.deploy(&site_dir)
990}
991
992fn build_config_from_subcommand_matches(
997 sub_m: &clap::ArgMatches,
998) -> Result<SsgConfig, SsgError> {
999 SsgConfig::from_subcommand_matches(sub_m).map_err(|e| {
1000 SsgError::Validation {
1001 field: "config".to_string(),
1002 message: e.to_string(),
1003 }
1004 })
1005}
1006
1007fn apply_rayon_thread_pool(jobs: Option<usize>) -> Result<(), SsgError> {
1009 if let Some(n) = jobs {
1010 rayon::ThreadPoolBuilder::new()
1011 .num_threads(n)
1012 .build_global()
1013 .map_err(|e| SsgError::Validation {
1014 field: "jobs".to_string(),
1015 message: format!("failed to configure Rayon thread pool: {e}"),
1016 })?;
1017 info!("Rayon thread pool configured with {n} threads");
1018 }
1019 Ok(())
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024 use super::*;
1025 use crate::cmd::Cli;
1026 use crate::logging::{SimpleLogger, DEFAULT_LOG_LEVEL, ENV_LOG_LEVEL};
1027 use crate::pipeline::{
1028 build_pipeline, execute_build_pipeline, resolve_build_and_site_dirs,
1029 RunOptions,
1030 };
1031 use crate::server::build_serve_address;
1032 use log::Log;
1033 use serial_test::serial;
1034 use std::env;
1035 use std::{
1036 fs::{self, File},
1037 path::PathBuf,
1038 };
1039 use tempfile::{tempdir, TempDir};
1040
1041 fn assert_same_variant<T>(actual: &T, expected: &T) {
1046 assert_eq!(
1047 std::mem::discriminant(actual),
1048 std::mem::discriminant(expected)
1049 );
1050 }
1051
1052 #[test]
1053 fn test_create_log_file_success() {
1054 let temp_dir = tempdir().unwrap();
1055 let log_file_path = temp_dir.path().join("test.log");
1056
1057 let log_file =
1058 create_log_file(log_file_path.to_str().unwrap()).unwrap();
1059 assert!(log_file.metadata().unwrap().is_file());
1060 }
1061
1062 #[test]
1063 fn test_log_arguments() {
1064 let temp_dir = tempdir().unwrap();
1065 let log_file_path = temp_dir.path().join("args_log.log");
1066 let mut log_file = File::create(&log_file_path).unwrap();
1067
1068 let date = now_iso();
1069 log_arguments(&mut log_file, &date).unwrap();
1070
1071 let log_content = fs::read_to_string(log_file_path).unwrap();
1072 assert!(log_content.contains("process"));
1073 }
1074
1075 #[test]
1076 fn test_create_directories_success() {
1077 let temp_dir = tempdir().unwrap();
1078 let base_path = temp_dir.path().to_path_buf();
1079
1080 let paths = Paths {
1081 site: base_path.join("public"),
1082 content: base_path.join("content"),
1083 build: base_path.join("build"),
1084 template: base_path.join("templates"),
1085 };
1086
1087 create_directories(&paths).unwrap();
1088
1089 assert!(paths.site.exists());
1091 assert!(paths.content.exists());
1092 assert!(paths.build.exists());
1093 assert!(paths.template.exists());
1094 }
1095
1096 #[cfg(not(target_os = "windows"))] #[test]
1098 fn test_create_directories_failure() {
1099 let invalid_paths = Paths {
1100 site: PathBuf::from("/invalid/site"),
1101 content: PathBuf::from("/invalid/content"),
1102 build: PathBuf::from("/invalid/build"),
1103 template: PathBuf::from("/invalid/template"),
1104 };
1105
1106 let result = create_directories(&invalid_paths);
1107 assert!(result.is_err());
1108 }
1109
1110 #[test]
1111 fn test_copy_dir_all() {
1112 let src_dir = tempdir().unwrap();
1113 let dst_dir = tempdir().unwrap();
1114
1115 let src_file = src_dir.path().join("test_file.txt");
1116 _ = File::create(&src_file).unwrap();
1117
1118 let result = copy_dir_all(src_dir.path(), dst_dir.path());
1119 assert!(result.is_ok());
1120 assert!(dst_dir.path().join("test_file.txt").exists());
1121 }
1122
1123 #[test]
1124 fn test_verify_and_copy_files_success() {
1125 let temp_dir = tempdir().unwrap();
1126 let base_path = temp_dir.path().to_path_buf();
1127
1128 let src_dir = base_path.join("src");
1130 fs::create_dir_all(&src_dir).unwrap();
1131 let test_file = src_dir.join("test_file.txt");
1132 fs::write(&test_file, "test content").unwrap();
1133
1134 let dst_dir = base_path.join("dst");
1136
1137 verify_and_copy_files(&src_dir, &dst_dir).unwrap();
1139
1140 assert!(dst_dir.join("test_file.txt").exists());
1142 }
1143
1144 #[test]
1145 fn test_verify_and_copy_files_failure() {
1146 let src_dir = PathBuf::from("/invalid/src");
1147 let dst_dir = PathBuf::from("/invalid/dst");
1148
1149 let result = verify_and_copy_files(&src_dir, &dst_dir);
1150 assert!(result.is_err());
1151 }
1152
1153 #[cfg(not(target_os = "windows"))] #[test]
1155 fn test_handle_server_failure() {
1156 let temp_dir = tempdir().unwrap();
1157 let log_file_path = temp_dir.path().join("server_log.log");
1158 let mut log_file = File::create(&log_file_path).unwrap();
1159
1160 let paths = Paths {
1161 site: PathBuf::from("/invalid/site"),
1162 content: PathBuf::from("/invalid/content"),
1163 build: PathBuf::from("/invalid/build"),
1164 template: PathBuf::from("/invalid/template"),
1165 };
1166
1167 let serve_dir = temp_dir.path().join("serve");
1168 let date = now_iso();
1169 let result = handle_server(&mut log_file, &date, &paths, &serve_dir);
1170 assert!(result.is_err());
1171 }
1172
1173 #[test]
1174 fn test_is_safe_path_safe() {
1175 let temp_dir = tempdir().unwrap();
1176 let safe_path = temp_dir.path().to_path_buf().join("safe_path");
1177
1178 fs::create_dir_all(&safe_path).unwrap();
1180
1181 let absolute_safe_path = safe_path.canonicalize().unwrap();
1183 assert!(is_safe_path(&absolute_safe_path).unwrap());
1184 }
1185
1186 #[cfg(not(target_os = "windows"))] #[test]
1188 fn test_create_directories_partial_failure() {
1189 let temp_dir = tempdir().unwrap();
1190 let valid_path = temp_dir.path().join("valid_dir");
1191 let invalid_path = PathBuf::from("/invalid/path");
1192
1193 let paths = Paths {
1194 site: valid_path,
1195 content: invalid_path,
1196 build: temp_dir.path().join("build"),
1197 template: temp_dir.path().join("template"),
1198 };
1199
1200 let result = create_directories(&paths);
1201 assert!(result.is_err());
1202 }
1203
1204 #[test]
1205 fn test_create_directories_rejects_traversal_in_build() {
1206 let tmp = tempdir().unwrap();
1209 let bad = tmp.path().join("..").join("escape-build");
1210 let paths = Paths {
1211 content: tmp.path().join("content"),
1212 build: bad,
1213 site: tmp.path().join("site"),
1214 template: tmp.path().join("template"),
1215 };
1216 let err = create_directories(&paths).unwrap_err();
1217 assert_same_variant(
1218 &err,
1219 &SsgError::PathTraversal {
1220 path: PathBuf::new(),
1221 },
1222 );
1223 }
1224
1225 #[test]
1226 fn test_create_directories_rejects_traversal_in_site() {
1227 let tmp = tempdir().unwrap();
1229 let bad = tmp.path().join("..").join("escape-site");
1230 let paths = Paths {
1231 content: tmp.path().join("content"),
1232 build: tmp.path().join("build"),
1233 site: bad,
1234 template: tmp.path().join("template"),
1235 };
1236 let err = create_directories(&paths).unwrap_err();
1237 assert_same_variant(
1238 &err,
1239 &SsgError::PathTraversal {
1240 path: PathBuf::new(),
1241 },
1242 );
1243 }
1244
1245 #[test]
1246 fn test_create_directories_rejects_traversal_in_template() {
1247 let tmp = tempdir().unwrap();
1249 let bad = tmp.path().join("..").join("escape-template");
1250 let paths = Paths {
1251 content: tmp.path().join("content"),
1252 build: tmp.path().join("build"),
1253 site: tmp.path().join("site"),
1254 template: bad,
1255 };
1256 let err = create_directories(&paths).unwrap_err();
1257 assert_same_variant(
1258 &err,
1259 &SsgError::PathTraversal {
1260 path: PathBuf::new(),
1261 },
1262 );
1263 }
1264
1265 #[test]
1266 fn test_copy_dir_all_nested() {
1267 let src_dir = tempdir().unwrap();
1268 let dst_dir = tempdir().unwrap();
1269
1270 let nested_dir = src_dir.path().join("nested_dir");
1271 fs::create_dir(&nested_dir).unwrap();
1272
1273 let nested_file = nested_dir.join("nested_file.txt");
1274 _ = File::create(&nested_file).unwrap();
1275
1276 copy_dir_all(src_dir.path(), dst_dir.path()).unwrap();
1277 assert!(dst_dir.path().join("nested_dir/nested_file.txt").exists());
1278 }
1279
1280 #[test]
1281 fn test_verify_and_copy_files_missing_source() {
1282 let src_path = PathBuf::from("/non_existent_dir");
1283 let dst_dir = tempdir().unwrap();
1284
1285 let result = verify_and_copy_files(&src_path, dst_dir.path());
1286 assert!(result.is_err());
1287 }
1288
1289 #[test]
1290 fn test_handle_server_missing_serve_dir() {
1291 let temp_dir = tempdir().unwrap();
1292 let log_file_path = temp_dir.path().join("server_log.log");
1293 let mut log_file = File::create(&log_file_path).unwrap();
1294
1295 let paths = Paths {
1296 site: temp_dir.path().join("site"),
1297 content: temp_dir.path().join("content"),
1298 build: temp_dir.path().join("build"),
1299 template: temp_dir.path().join("template"),
1300 };
1301
1302 let non_existent_serve_dir = PathBuf::from("/non_existent_serve_dir");
1303 let binding = now_iso();
1304 let result = handle_server(
1305 &mut log_file,
1306 &binding,
1307 &paths,
1308 &non_existent_serve_dir,
1309 );
1310 assert!(result.is_err());
1311 }
1312
1313 #[test]
1314 fn test_collect_files_recursive_empty() {
1315 let temp_dir = tempdir().unwrap();
1316 let mut files = Vec::new();
1317
1318 collect_files_recursive(temp_dir.path(), &mut files).unwrap();
1319 assert!(files.is_empty());
1320 }
1321
1322 #[test]
1323 fn test_print_banner() {
1324 Cli::print_banner();
1326 }
1327
1328 #[test]
1329 fn test_collect_files_recursive_with_nested_directories() {
1330 let temp_dir = tempdir().unwrap();
1331 let nested_dir = temp_dir.path().join("nested_dir");
1332 fs::create_dir(&nested_dir).unwrap();
1333
1334 let nested_file = nested_dir.join("nested_file.txt");
1335 _ = File::create(&nested_file).unwrap();
1336
1337 let mut files = Vec::new();
1338 collect_files_recursive(temp_dir.path(), &mut files).unwrap();
1339
1340 assert!(files.contains(&nested_file));
1341 assert_eq!(files.len(), 1);
1342 }
1343
1344 #[test]
1345 fn test_handle_server_start_message() {
1346 let temp_dir = tempdir().unwrap();
1347 let log_file_path = temp_dir.path().join("server_log.log");
1348 let mut log_file = File::create(&log_file_path).unwrap();
1349
1350 let paths = Paths {
1351 site: temp_dir.path().join("site"),
1352 content: temp_dir.path().join("content"),
1353 build: temp_dir.path().join("build"),
1354 template: temp_dir.path().join("template"),
1355 };
1356
1357 let serve_dir = temp_dir.path().join("serve");
1358
1359 fs::create_dir_all(&serve_dir).unwrap();
1361 assert!(serve_dir.exists(), "Expected serve directory to be created");
1362
1363 let date = now_iso();
1365 let result = handle_server(&mut log_file, &date, &paths, &serve_dir);
1366 assert!(
1367 result.is_err(),
1368 "Expected handle_server to fail without valid setup"
1369 );
1370 }
1371
1372 #[cfg(any(unix, windows))]
1373 #[test]
1374 fn test_verify_file_safety_symlink() {
1375 let temp_dir = tempdir().unwrap();
1376 let file_path = temp_dir.path().join("test.txt");
1377 let symlink_path = temp_dir.path().join("test_link.txt");
1378
1379 fs::write(&file_path, "test content").unwrap();
1381
1382 #[cfg(unix)]
1384 std::os::unix::fs::symlink(&file_path, &symlink_path).unwrap();
1385 #[cfg(windows)]
1386 std::os::windows::fs::symlink_file(&file_path, &symlink_path).unwrap();
1387
1388 println!("File exists: {}", file_path.exists());
1390 println!("Symlink exists: {}", symlink_path.exists());
1391 println!(
1392 "Is symlink: {}",
1393 symlink_path
1394 .symlink_metadata()
1395 .unwrap()
1396 .file_type()
1397 .is_symlink()
1398 );
1399
1400 let result = verify_file_safety(&symlink_path);
1402
1403 println!("Result: {result:?}");
1405
1406 assert!(result.is_err(), "Expected error for symlink, got success");
1408
1409 let err = result.unwrap_err();
1411 println!("Error message: {err}");
1412 assert!(
1413 matches!(err, SsgError::SymlinkForbidden { ref path } if path == &symlink_path),
1414 "expected SsgError::SymlinkForbidden, got: {err:?}"
1415 );
1416 }
1417
1418 #[test]
1419 fn test_verify_file_safety_size() {
1420 let temp_dir = tempdir().unwrap();
1421 let large_file_path = temp_dir.path().join("large.txt");
1422
1423 let file = File::create(&large_file_path).unwrap();
1425 file.set_len(11 * 1024 * 1024).unwrap(); let result = verify_file_safety(&large_file_path);
1428 assert!(result.is_err(), "Expected error, got: {result:?}");
1429 }
1430
1431 #[test]
1432 fn test_verify_file_safety_regular() {
1433 let temp_dir = tempdir().unwrap();
1434 let file_path = temp_dir.path().join("regular.txt");
1435
1436 fs::write(&file_path, "test content").unwrap();
1438
1439 assert!(verify_file_safety(&file_path).is_ok());
1440 }
1441
1442 #[test]
1444 fn test_copy_empty_directory_async() {
1445 let src_dir = tempdir().unwrap();
1446 let dst_dir = tempdir().unwrap();
1447
1448 let result = copy_dir_all_async(src_dir.path(), dst_dir.path());
1449 assert!(result.is_ok());
1450
1451 assert!(dst_dir.path().exists());
1453 }
1454
1455 #[test]
1457 fn test_copy_single_file_async() {
1458 let src_dir = tempdir().unwrap();
1459 let dst_dir = tempdir().unwrap();
1460
1461 let test_file = src_dir.path().join("test.txt");
1463 fs::write(&test_file, "test content").unwrap();
1464
1465 copy_dir_all_async(src_dir.path(), dst_dir.path()).unwrap();
1466
1467 let copied_file = dst_dir.path().join("test.txt");
1469 assert!(copied_file.exists());
1470 assert_eq!(fs::read_to_string(copied_file).unwrap(), "test content");
1471 }
1472
1473 #[test]
1475 fn test_copy_nested_directories_async() {
1476 let src_dir = tempdir().unwrap();
1477 let dst_dir = tempdir().unwrap();
1478
1479 let nested_dir = src_dir.path().join("nested");
1481 fs::create_dir(&nested_dir).unwrap();
1482
1483 fs::write(src_dir.path().join("root.txt"), "root content").unwrap();
1485 fs::write(nested_dir.join("nested.txt"), "nested content").unwrap();
1486
1487 copy_dir_all_async(src_dir.path(), dst_dir.path()).unwrap();
1488
1489 assert!(dst_dir.path().join("nested").exists());
1491 assert!(dst_dir.path().join("root.txt").exists());
1492 assert!(dst_dir.path().join("nested/nested.txt").exists());
1493
1494 assert_eq!(
1495 fs::read_to_string(dst_dir.path().join("root.txt")).unwrap(),
1496 "root content"
1497 );
1498 assert_eq!(
1499 fs::read_to_string(dst_dir.path().join("nested/nested.txt"))
1500 .unwrap(),
1501 "nested content"
1502 );
1503 }
1504
1505 #[test]
1507 fn test_copy_with_symlink_async() {
1508 let src_dir = tempdir().unwrap();
1509 let dst_dir = tempdir().unwrap();
1510
1511 let file_path = src_dir.path().join("original.txt");
1513 fs::write(&file_path, "original content").unwrap();
1514
1515 #[cfg(unix)]
1517 {
1518 use std::os::unix::fs::symlink;
1519 let symlink_path = src_dir.path().join("link.txt");
1520 symlink(&file_path, &symlink_path).unwrap();
1521 }
1522 #[cfg(windows)]
1523 {
1524 use std::os::windows::fs::symlink_file;
1525 let symlink_path = src_dir.path().join("link.txt");
1526 symlink_file(&file_path, &symlink_path).unwrap();
1527 }
1528
1529 let result = copy_dir_all_async(src_dir.path(), dst_dir.path());
1531 assert!(result.is_err());
1532 }
1533
1534 #[test]
1536 fn test_copy_large_file_async() {
1537 let src_dir = tempdir().unwrap();
1538 let dst_dir = tempdir().unwrap();
1539
1540 let large_file = src_dir.path().join("large.txt");
1542 let file = File::create(&large_file).unwrap();
1543 file.set_len(11 * 1024 * 1024).unwrap();
1544
1545 let result = copy_dir_all_async(src_dir.path(), dst_dir.path());
1547 assert!(result.is_err());
1548 }
1549
1550 #[cfg(not(target_os = "windows"))] #[test]
1553 fn test_copy_invalid_destination_async() {
1554 let src_dir = tempdir().unwrap();
1555 let invalid_dst = PathBuf::from("/nonexistent/path");
1556
1557 let result = copy_dir_all_async(src_dir.path(), &invalid_dst);
1558 assert!(result.is_err());
1559 }
1560
1561 #[test]
1563 fn test_concurrent_copy_async() {
1564 let src_dir = tempdir().unwrap();
1565 let dst_dir = tempdir().unwrap();
1566
1567 for i in 0..5 {
1569 fs::write(
1570 src_dir.path().join(format!("file{i}.txt")),
1571 format!("content {i}"),
1572 )
1573 .unwrap();
1574 }
1575
1576 copy_dir_all_async(src_dir.path(), dst_dir.path()).unwrap();
1577
1578 for i in 0..5 {
1580 let copied_file = dst_dir.path().join(format!("file{i}.txt"));
1581 assert!(copied_file.exists());
1582 assert_eq!(
1583 fs::read_to_string(copied_file).unwrap(),
1584 format!("content {i}")
1585 );
1586 }
1587 }
1588
1589 #[test]
1591 fn test_max_directory_depth_async() {
1592 let src_dir = tempdir().unwrap();
1593 let dst_dir = tempdir().unwrap();
1594 let max_depth = 5;
1595
1596 let mut current_dir = src_dir.path().to_path_buf();
1598 for i in 0..max_depth {
1599 current_dir = current_dir.join(format!("level{i}"));
1600 fs::create_dir(¤t_dir).unwrap();
1601 fs::write(
1602 current_dir.join("file.txt"),
1603 format!("content level {i}"),
1604 )
1605 .unwrap();
1606 }
1607
1608 copy_dir_all_async(src_dir.path(), dst_dir.path()).unwrap();
1609
1610 current_dir = dst_dir.path().to_path_buf();
1612 for i in 0..max_depth {
1613 current_dir = current_dir.join(format!("level{i}"));
1614 assert!(current_dir.exists());
1615 assert!(current_dir.join("file.txt").exists());
1616 assert_eq!(
1617 fs::read_to_string(current_dir.join("file.txt")).unwrap(),
1618 format!("content level {i}")
1619 );
1620 }
1621 }
1622
1623 #[test]
1624 fn test_verify_and_copy_files_async_missing_source() {
1625 let temp_dir = tempdir().unwrap();
1626 let src_dir = temp_dir.path().join("nonexistent");
1627 let dst_dir = temp_dir.path().join("dst");
1628
1629 let error = verify_and_copy_files_async(&src_dir, &dst_dir)
1630 .unwrap_err()
1631 .to_string();
1632
1633 assert!(
1634 error.contains("does not exist"),
1635 "Expected error message about non-existent source, got: {error}"
1636 );
1637 }
1638
1639 #[test]
1640 fn test_paths_builder_default() {
1641 let paths = Paths::builder().build().unwrap();
1642 assert_eq!(paths.site, PathBuf::from("public"));
1643 assert_eq!(paths.content, PathBuf::from("content"));
1644 assert_eq!(paths.build, PathBuf::from("build"));
1645 assert_eq!(paths.template, PathBuf::from("templates"));
1646 }
1647
1648 #[test]
1649 fn test_resolve_build_and_site_dirs_without_serve_dir() {
1650 let mut config = SsgConfig::default();
1651 config.output_dir = PathBuf::from("docs");
1652 config.serve_dir = None;
1653
1654 let (build_dir, site_dir) = resolve_build_and_site_dirs(&config);
1655
1656 assert_eq!(site_dir, PathBuf::from("docs"));
1657 assert_eq!(build_dir, PathBuf::from("docs.build-tmp"));
1658 assert_ne!(build_dir, site_dir);
1659 }
1660
1661 #[test]
1662 fn test_resolve_build_and_site_dirs_with_distinct_serve_dir() {
1663 let mut config = SsgConfig::default();
1664 config.output_dir = PathBuf::from("docs");
1665 config.serve_dir = Some(PathBuf::from("public"));
1666
1667 let (build_dir, site_dir) = resolve_build_and_site_dirs(&config);
1668
1669 assert_eq!(build_dir, PathBuf::from("docs"));
1670 assert_eq!(site_dir, PathBuf::from("public"));
1671 assert_ne!(build_dir, site_dir);
1672 }
1673
1674 #[test]
1675 fn test_resolve_build_and_site_dirs_with_same_serve_and_output_dir() {
1676 let mut config = SsgConfig::default();
1677 config.output_dir = PathBuf::from("docs");
1678 config.serve_dir = Some(PathBuf::from("docs"));
1679
1680 let (build_dir, site_dir) = resolve_build_and_site_dirs(&config);
1681
1682 assert_eq!(site_dir, PathBuf::from("docs"));
1683 assert_eq!(build_dir, PathBuf::from("docs.build-tmp"));
1684 assert_ne!(build_dir, site_dir);
1685 }
1686
1687 #[test]
1688 fn test_paths_builder_custom() {
1689 let temp_dir = tempdir().unwrap();
1690 let paths = Paths::builder()
1691 .site(temp_dir.path().join("custom_public"))
1692 .content(temp_dir.path().join("custom_content"))
1693 .build_dir(temp_dir.path().join("custom_build"))
1694 .template(temp_dir.path().join("custom_templates"))
1695 .build()
1696 .unwrap();
1697
1698 assert_eq!(paths.site, temp_dir.path().join("custom_public"));
1699 assert_eq!(paths.content, temp_dir.path().join("custom_content"));
1700 assert_eq!(paths.build, temp_dir.path().join("custom_build"));
1701 assert_eq!(paths.template, temp_dir.path().join("custom_templates"));
1702 }
1703
1704 #[test]
1705 fn test_paths_builder_relative() {
1706 let temp_dir = tempdir().unwrap();
1707
1708 fs::create_dir_all(temp_dir.path().join("public")).unwrap();
1710 fs::create_dir_all(temp_dir.path().join("content")).unwrap();
1711 fs::create_dir_all(temp_dir.path().join("build")).unwrap();
1712 fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
1713
1714 let paths = Paths::builder()
1715 .relative_to(temp_dir.path())
1716 .build()
1717 .unwrap();
1718
1719 assert_eq!(paths.site, temp_dir.path().join("public"));
1720 assert_eq!(paths.content, temp_dir.path().join("content"));
1721 assert_eq!(paths.build, temp_dir.path().join("build"));
1722 assert_eq!(paths.template, temp_dir.path().join("templates"));
1723 }
1724
1725 #[test]
1726 fn test_paths_validation() {
1727 let err = Paths::builder().site("../invalid").build().unwrap_err();
1729 assert_same_variant(
1730 &err,
1731 &SsgError::PathTraversal {
1732 path: PathBuf::new(),
1733 },
1734 );
1735
1736 let err = Paths::builder().site("invalid//path").build().unwrap_err();
1738 assert_same_variant(
1739 &err,
1740 &SsgError::Validation {
1741 field: String::new(),
1742 message: String::new(),
1743 },
1744 );
1745
1746 #[cfg(unix)]
1748 {
1749 use std::os::unix::fs::symlink;
1750 let temp_dir = tempdir().unwrap();
1751 let real_path = temp_dir.path().join("real");
1752 let symlink_path = temp_dir.path().join("symlink");
1753
1754 fs::create_dir(&real_path).unwrap();
1755 symlink(&real_path, &symlink_path).unwrap();
1756
1757 let err = Paths::builder().site(symlink_path).build().unwrap_err();
1758 assert_same_variant(
1759 &err,
1760 &SsgError::SymlinkForbidden {
1761 path: PathBuf::new(),
1762 },
1763 );
1764 }
1765 }
1766
1767 #[test]
1768 fn test_paths_default_paths() {
1769 let paths = Paths::default_paths();
1770 assert_eq!(paths.site, PathBuf::from("public"));
1771 assert_eq!(paths.content, PathBuf::from("content"));
1772 assert_eq!(paths.build, PathBuf::from("build"));
1773 assert_eq!(paths.template, PathBuf::from("templates"));
1774 }
1775
1776 #[test]
1778 fn test_paths_nonexistent_valid() {
1779 let temp_dir = tempdir().unwrap();
1780 let valid_path = temp_dir.path().join("new_directory");
1781
1782 let paths = Paths::builder().site(valid_path.clone()).build().unwrap();
1783
1784 assert_eq!(paths.site, valid_path);
1785 }
1786
1787 #[test]
1788 #[serial(env_log)]
1789 fn test_initialize_logging_with_custom_level() {
1790 env::set_var(ENV_LOG_LEVEL, "debug");
1791 assert!(logging::initialize_logging().is_ok());
1792 env::remove_var(ENV_LOG_LEVEL);
1793 }
1794
1795 #[test]
1796 fn test_paths_builder_with_all_invalid_paths() {
1797 let result = Paths::builder()
1798 .site("../invalid")
1799 .content("content//invalid")
1800 .build_dir("build/../invalid")
1801 .template("template//invalid")
1802 .build();
1803
1804 assert!(result.is_err());
1805 }
1806
1807 #[test]
1808 fn test_paths_builder_clone() {
1809 let builder = PathsBuilder::default();
1810 let cloned = builder;
1811 assert!(cloned.site.is_none());
1812 assert!(cloned.content.is_none());
1813 assert!(cloned.build.is_none());
1814 assert!(cloned.template.is_none());
1815 }
1816
1817 #[test]
1818 fn test_paths_clone() {
1819 let paths = Paths::default_paths();
1820 let cloned = paths.clone();
1821
1822 assert_eq!(paths.site, cloned.site);
1823 assert_eq!(paths.content, cloned.content);
1824 assert_eq!(paths.build, cloned.build);
1825 assert_eq!(paths.template, cloned.template);
1826 }
1827
1828 #[test]
1829 fn test_async_copy_with_empty_source() {
1830 let temp_dir = tempdir().unwrap();
1831 let src_dir = temp_dir.path().join("empty_src");
1832 let dst_dir = temp_dir.path().join("empty_dst");
1833
1834 fs::create_dir(&src_dir).unwrap();
1835
1836 let result = verify_and_copy_files_async(&src_dir, &dst_dir);
1837 assert!(result.is_ok());
1838 assert!(dst_dir.exists());
1839 }
1840
1841 #[test]
1842 fn test_paths_validation_all_aspects() {
1843 let temp_dir = tempdir().unwrap();
1844
1845 let result = Paths::builder()
1847 .site(temp_dir.path().join("site"))
1848 .content(temp_dir.path().join("content"))
1849 .build_dir(temp_dir.path().join("build"))
1850 .template(temp_dir.path().join("template"))
1851 .build();
1852
1853 assert!(result.is_ok());
1854
1855 let result = Paths::builder()
1857 .site("../site")
1858 .content("content//test")
1859 .build_dir("build/../../test")
1860 .template("template//test")
1861 .build();
1862
1863 assert!(result.is_err());
1864 }
1865
1866 #[test]
1867 fn test_log_initialization_with_empty_log_file() {
1868 let temp_dir = tempdir().unwrap();
1869 let log_path = temp_dir.path().join("empty.log");
1870 let mut log_file = File::create(&log_path).unwrap();
1871
1872 let date = now_iso();
1873 log_initialization(&mut log_file, &date).unwrap();
1874
1875 let content = fs::read_to_string(&log_path).unwrap();
1876 assert!(!content.is_empty());
1877 assert!(content.contains("process"));
1878 }
1879
1880 #[test]
1881 fn test_verify_and_copy_files_async_with_nested_empty_dirs() {
1882 let temp_dir = tempdir().unwrap();
1883 let src_dir = temp_dir.path().join("src");
1884 let dst_dir = temp_dir.path().join("dst");
1885
1886 fs::create_dir_all(src_dir.join("a/b/c")).unwrap();
1888 fs::create_dir_all(src_dir.join("d/e/f")).unwrap();
1889
1890 verify_and_copy_files_async(&src_dir, &dst_dir).unwrap();
1891
1892 assert!(dst_dir.join("a/b/c").exists());
1893 assert!(dst_dir.join("d/e/f").exists());
1894 }
1895
1896 #[test]
1897 fn test_validate_nonexistent_paths() {
1898 let paths = Paths {
1899 site: PathBuf::from("nonexistent/site"),
1900 content: PathBuf::from("nonexistent/content"),
1901 build: PathBuf::from("nonexistent/build"),
1902 template: PathBuf::from("nonexistent/template"),
1903 };
1904
1905 assert!(paths.validate().is_ok());
1907 }
1908
1909 #[test]
1910 fn test_copy_dir_all_async_with_empty_dirs() {
1911 let temp_dir = tempdir().unwrap();
1912 let src_dir = temp_dir.path().join("src");
1913 let dst_dir = temp_dir.path().join("dst");
1914
1915 fs::create_dir_all(src_dir.join("empty1")).unwrap();
1916 fs::create_dir_all(src_dir.join("empty2/empty3")).unwrap();
1917
1918 copy_dir_all_async(&src_dir, &dst_dir).unwrap();
1919
1920 assert!(dst_dir.join("empty1").exists());
1921 assert!(dst_dir.join("empty2/empty3").exists());
1922 }
1923
1924 #[test]
1925 #[serial(env_log)]
1926 fn test_log_level_from_env() {
1927 env::set_var(ENV_LOG_LEVEL, "info");
1930 let original_value = env::var(ENV_LOG_LEVEL).ok();
1931
1932 fn get_processed_log_level() -> String {
1934 let log_level = env::var(ENV_LOG_LEVEL)
1935 .unwrap_or_else(|_| DEFAULT_LOG_LEVEL.to_string());
1936
1937 match log_level.to_lowercase().as_str() {
1938 "error" => "error",
1939 "warn" => "warn",
1940 "info" => "info",
1941 "debug" => "debug",
1942 "trace" => "trace",
1943 _ => "info", }
1945 .to_string()
1946 }
1947
1948 let test_levels = vec![
1950 ("DEBUG", "debug"),
1951 ("ERROR", "error"),
1952 ("WARN", "warn"),
1953 ("INFO", "info"),
1954 ("TRACE", "trace"),
1955 ("INVALID", "info"), ];
1957
1958 for (input, expected) in test_levels {
1959 env::set_var(ENV_LOG_LEVEL, input);
1960 let processed_level = get_processed_log_level();
1961 assert_eq!(
1962 processed_level, expected,
1963 "Expected log level '{expected}' for input '{input}', but got '{processed_level}'"
1964 );
1965 }
1966
1967 env::remove_var(ENV_LOG_LEVEL);
1970 assert_eq!(get_processed_log_level(), DEFAULT_LOG_LEVEL);
1971
1972 env::remove_var(ENV_LOG_LEVEL);
1974 if let Some(value) = original_value {
1975 env::set_var(ENV_LOG_LEVEL, value);
1976 }
1977 }
1978
1979 #[test]
1981 #[serial(env_log)]
1982 fn test_default_log_level() {
1983 env::set_var(ENV_LOG_LEVEL, "info");
1986 let original_value = env::var(ENV_LOG_LEVEL).ok();
1987 env::remove_var(ENV_LOG_LEVEL);
1988
1989 let log_level = env::var(ENV_LOG_LEVEL)
1990 .unwrap_or_else(|_| DEFAULT_LOG_LEVEL.to_string())
1991 .to_lowercase();
1992 assert_eq!(log_level, DEFAULT_LOG_LEVEL.to_lowercase());
1993
1994 env::remove_var(ENV_LOG_LEVEL);
1995 if let Some(value) = original_value {
1996 env::set_var(ENV_LOG_LEVEL, value);
1997 }
1998 }
1999
2000 #[test]
2002 fn test_log_level_translation() {
2003 use log::LevelFilter;
2004 let test_cases = vec![
2005 ("error", LevelFilter::Error),
2006 ("warn", LevelFilter::Warn),
2007 ("info", LevelFilter::Info),
2008 ("debug", LevelFilter::Debug),
2009 ("trace", LevelFilter::Trace),
2010 ("invalid", LevelFilter::Info),
2011 ("", LevelFilter::Info),
2012 ];
2013
2014 for (input, expected) in test_cases {
2015 let level = match input.to_lowercase().as_str() {
2016 "error" => LevelFilter::Error,
2017 "warn" => LevelFilter::Warn,
2018 "info" => LevelFilter::Info,
2019 "debug" => LevelFilter::Debug,
2020 "trace" => LevelFilter::Trace,
2021 _ => LevelFilter::Info,
2022 };
2023
2024 assert_eq!(
2025 level, expected,
2026 "Log level mismatch for input: '{input}' - expected {expected:?}, got {level:?}"
2027 );
2028 }
2029 }
2030
2031 #[test]
2033 #[serial(env_log)]
2034 fn test_env_log_level_handling() {
2035 env::set_var(ENV_LOG_LEVEL, "info");
2038 let original_value = env::var(ENV_LOG_LEVEL).ok();
2039
2040 let test_cases = vec![
2041 (Some("DEBUG"), "debug"),
2042 (Some("ERROR"), "error"),
2043 (Some("WARN"), "warn"),
2044 (Some("INFO"), "info"),
2045 (Some("TRACE"), "trace"),
2046 (Some("INVALID"), "info"),
2047 (None, "info"),
2048 ];
2049
2050 for (env_value, expected) in test_cases {
2051 env::remove_var(ENV_LOG_LEVEL);
2053
2054 if let Some(value) = env_value {
2056 env::set_var(ENV_LOG_LEVEL, value);
2057 }
2058
2059 let log_level = env::var(ENV_LOG_LEVEL)
2060 .unwrap_or_else(|_| DEFAULT_LOG_LEVEL.to_string())
2061 .to_lowercase();
2062
2063 let actual = match log_level.as_str() {
2064 "error" => "error",
2065 "warn" => "warn",
2066 "info" => "info",
2067 "debug" => "debug",
2068 "trace" => "trace",
2069 _ => "info",
2070 };
2071
2072 assert_eq!(
2073 actual, expected,
2074 "Log level mismatch for env value: {env_value:?}"
2075 );
2076 }
2077
2078 env::remove_var(ENV_LOG_LEVEL);
2080 if let Some(value) = original_value {
2081 env::set_var(ENV_LOG_LEVEL, value);
2082 }
2083 }
2084
2085 #[test]
2086 fn test_initialize_logging_custom_levels() {
2087 let valid_levels = ["debug", "warn", "error", "trace", "info"];
2089 for level in &valid_levels {
2090 assert!(
2091 ["trace", "debug", "info", "warn", "error"].contains(level),
2092 "unexpected log level: {level}"
2093 );
2094 }
2095 assert!(["trace", "debug", "info", "warn", "error"]
2097 .contains(&DEFAULT_LOG_LEVEL),);
2098 }
2099
2100 #[test]
2101 fn parse_log_level_recognises_all_supported_levels() {
2102 use log::LevelFilter;
2103 assert_eq!(logging::parse_log_level("error"), LevelFilter::Error);
2104 assert_eq!(logging::parse_log_level("warn"), LevelFilter::Warn);
2105 assert_eq!(logging::parse_log_level("info"), LevelFilter::Info);
2106 assert_eq!(logging::parse_log_level("debug"), LevelFilter::Debug);
2107 assert_eq!(logging::parse_log_level("trace"), LevelFilter::Trace);
2108 }
2109
2110 #[test]
2111 fn parse_log_level_is_case_insensitive() {
2112 use log::LevelFilter;
2113 assert_eq!(logging::parse_log_level("ERROR"), LevelFilter::Error);
2114 assert_eq!(logging::parse_log_level("Warn"), LevelFilter::Warn);
2115 assert_eq!(logging::parse_log_level("TraCe"), LevelFilter::Trace);
2116 }
2117
2118 #[test]
2119 fn parse_log_level_unknown_value_falls_back_to_info() {
2120 use log::LevelFilter;
2121 assert_eq!(logging::parse_log_level("nonsense"), LevelFilter::Info);
2122 assert_eq!(logging::parse_log_level(""), LevelFilter::Info);
2123 assert_eq!(logging::parse_log_level("verbose"), LevelFilter::Info);
2124 }
2125
2126 #[test]
2127 fn test_concurrent_operations() {
2128 let temp_dir = TempDir::new().unwrap();
2129 let src_dir = temp_dir.path().join("src");
2130 let dst_dir = temp_dir.path().join("dst");
2131
2132 fs::create_dir_all(&src_dir).unwrap();
2134
2135 for i in 0..100 {
2137 fs::write(
2138 src_dir.join(format!("file_{i}.txt")),
2139 format!("content {i}"),
2140 )
2141 .unwrap();
2142 }
2143
2144 let mut src_files = Vec::new();
2146 collect_files_recursive(&src_dir, &mut src_files).unwrap();
2147 assert_eq!(src_files.len(), 100);
2148
2149 fs::create_dir_all(&dst_dir).unwrap();
2151
2152 verify_and_copy_files(&src_dir, &dst_dir).unwrap();
2154
2155 let mut dst_files = Vec::new();
2157 collect_files_recursive(&dst_dir, &mut dst_files).unwrap();
2158
2159 assert_eq!(dst_files.len(), 100);
2160
2161 for i in 0..100 {
2163 let dst_path = dst_dir.join(format!("file_{i}.txt"));
2164 assert!(dst_path.exists());
2165
2166 let content = fs::read_to_string(&dst_path).unwrap();
2167 assert_eq!(
2168 content,
2169 format!("content {i}"),
2170 "Content mismatch for file {}",
2171 i
2172 );
2173 }
2174 }
2175
2176 #[test]
2177 fn test_verify_and_copy_files_basic() {
2178 let temp_dir = TempDir::new().unwrap();
2179 let src_dir = temp_dir.path().join("src");
2180 let dst_dir = temp_dir.path().join("dst");
2181
2182 fs::create_dir_all(&src_dir).unwrap();
2183
2184 fs::write(src_dir.join("test.txt"), "test content").unwrap();
2186
2187 verify_and_copy_files(&src_dir, &dst_dir).unwrap();
2189
2190 assert!(dst_dir.join("test.txt").exists());
2192 assert_eq!(
2193 fs::read_to_string(dst_dir.join("test.txt")).unwrap(),
2194 "test content"
2195 );
2196 }
2197
2198 #[test]
2199 fn test_copy_dir_with_progress_empty_source() {
2200 let src_dir = tempdir().unwrap();
2201 let dst_dir = tempdir().unwrap();
2202
2203 copy_dir_with_progress(src_dir.path(), dst_dir.path()).unwrap();
2205
2206 assert!(dst_dir.path().exists());
2208 assert!(fs::read_dir(dst_dir.path()).unwrap().next().is_none());
2209 }
2210
2211 #[test]
2212 fn test_copy_dir_with_progress_source_does_not_exist() {
2213 let src_dir = Path::new("/nonexistent");
2214 let dst_dir = tempdir().unwrap();
2215
2216 let result = copy_dir_with_progress(src_dir, dst_dir.path());
2217 assert!(result.is_err());
2218 }
2219
2220 #[test]
2221 fn test_copy_dir_with_progress_single_file() {
2222 let src_dir = tempdir().unwrap();
2223 let dst_dir = tempdir().unwrap();
2224
2225 fs::write(src_dir.path().join("file1.txt"), "content").unwrap();
2226
2227 copy_dir_with_progress(src_dir.path(), dst_dir.path()).unwrap();
2228
2229 let copied_file = dst_dir.path().join("file1.txt");
2230 assert!(copied_file.exists());
2231 assert_eq!(fs::read_to_string(copied_file).unwrap(), "content");
2232 }
2233
2234 #[test]
2235 fn test_copy_dir_with_progress_nested_directories() {
2236 let src_dir = tempdir().unwrap();
2237 let dst_dir = tempdir().unwrap();
2238
2239 let nested_dir = src_dir.path().join("nested");
2240 fs::create_dir(&nested_dir).unwrap();
2241 fs::write(nested_dir.join("file.txt"), "nested content").unwrap();
2242
2243 copy_dir_with_progress(src_dir.path(), dst_dir.path()).unwrap();
2244
2245 let copied_nested_file = dst_dir.path().join("nested/file.txt");
2246 assert!(copied_nested_file.exists());
2247 assert_eq!(
2248 fs::read_to_string(copied_nested_file).unwrap(),
2249 "nested content"
2250 );
2251 }
2252
2253 #[cfg(not(target_os = "windows"))] #[test]
2255 fn test_copy_dir_with_progress_destination_creation_failure() {
2256 let src_dir = tempdir().unwrap();
2257 let dst_dir = Path::new("/invalid_path");
2258
2259 let result = copy_dir_with_progress(src_dir.path(), dst_dir);
2260 assert!(result.is_err());
2261 }
2262
2263 #[test]
2264 fn test_verify_and_copy_files_single_file() {
2265 let temp_dir = tempdir().unwrap();
2266 let src_file = temp_dir.path().join("single.txt");
2267 fs::write(&src_file, "content").unwrap();
2268 let dst_dir = temp_dir.path().join("dst");
2269 let result = verify_and_copy_files(&src_file, &dst_dir);
2272 assert!(result.is_err());
2273 }
2274
2275 #[test]
2276 fn test_is_safe_path_traversal_nonexistent() {
2277 assert!(!is_safe_path(Path::new("../../etc/passwd")).unwrap());
2278 }
2279
2280 #[test]
2281 fn test_copy_dir_with_progress_nested() {
2282 let src_dir = tempdir().unwrap();
2283 let dst_dir = tempdir().unwrap();
2284 let sub = src_dir.path().join("sub");
2286 fs::create_dir(&sub).unwrap();
2287 fs::write(src_dir.path().join("root.txt"), "root").unwrap();
2288 fs::write(sub.join("nested.txt"), "nested").unwrap();
2289 copy_dir_with_progress(src_dir.path(), dst_dir.path()).unwrap();
2290 assert!(dst_dir.path().join("root.txt").exists());
2291 assert!(dst_dir.path().join("sub/nested.txt").exists());
2292 }
2293
2294 #[test]
2295 fn test_copy_dir_all_parallel_threshold() {
2296 let src_dir = tempdir().unwrap();
2297 let dst_dir = tempdir().unwrap();
2298 for i in 0..20 {
2300 fs::write(
2301 src_dir.path().join(format!("file{i}.txt")),
2302 format!("content {i}"),
2303 )
2304 .unwrap();
2305 }
2306 copy_dir_all(src_dir.path(), dst_dir.path()).unwrap();
2307 for i in 0..20 {
2308 assert!(dst_dir.path().join(format!("file{i}.txt")).exists());
2309 }
2310 }
2311
2312 #[test]
2313 fn test_collect_files_recursive_depth_exceeded() {
2314 let temp_dir = tempdir().unwrap();
2315 let mut path = temp_dir.path().to_path_buf();
2317 for i in 0..=MAX_DIR_DEPTH {
2318 path = path.join(format!("d{i}"));
2319 fs::create_dir(&path).unwrap();
2320 }
2321 let mut files = Vec::new();
2322 let result = collect_files_recursive(temp_dir.path(), &mut files);
2323 assert!(result.is_err());
2324 assert!(result.unwrap_err().to_string().contains("maximum depth"));
2325 }
2326
2327 #[test]
2328 fn test_copy_dir_all_depth_exceeded() {
2329 let src_dir = tempdir().unwrap();
2330 let dst_dir = tempdir().unwrap();
2331 let mut path = src_dir.path().to_path_buf();
2332 for i in 0..=MAX_DIR_DEPTH {
2333 path = path.join(format!("d{i}"));
2334 fs::create_dir(&path).unwrap();
2335 }
2336 let result = copy_dir_all(src_dir.path(), dst_dir.path());
2337 assert!(result.is_err());
2338 assert!(result.unwrap_err().to_string().contains("maximum depth"));
2339 }
2340
2341 #[test]
2342 fn test_verify_and_copy_files_async_depth_exceeded() {
2343 let temp_dir = tempdir().unwrap();
2344 let src = temp_dir.path().join("src");
2345 let dst = temp_dir.path().join("dst");
2346 let mut path = src.clone();
2347 for i in 0..=MAX_DIR_DEPTH {
2348 path = path.join(format!("d{i}"));
2349 fs::create_dir_all(&path).unwrap();
2350 }
2351 let result = verify_and_copy_files_async(&src, &dst);
2352 assert!(result.is_err());
2353 assert!(result.unwrap_err().to_string().contains("maximum depth"));
2354 }
2355
2356 #[test]
2357 fn test_copy_dir_all_async_depth_exceeded() {
2358 let temp_dir = tempdir().unwrap();
2359 let src = temp_dir.path().join("src");
2360 let dst = temp_dir.path().join("dst");
2361 let mut path = src.clone();
2362 for i in 0..=MAX_DIR_DEPTH {
2363 path = path.join(format!("d{i}"));
2364 fs::create_dir_all(&path).unwrap();
2365 }
2366 let result = copy_dir_all_async(&src, &dst);
2367 assert!(result.is_err());
2368 assert!(result.unwrap_err().to_string().contains("maximum depth"));
2369 }
2370
2371 #[test]
2372 fn test_verify_file_safety_nonexistent() {
2373 let result = verify_file_safety(Path::new("/nonexistent/file.txt"));
2374 assert!(result.is_err());
2375 }
2376
2377 #[test]
2378 fn test_copy_dir_with_progress_nonexistent_source() {
2379 let dst = env::temp_dir().join("ssg_copy_dir_dst");
2380 let result =
2381 copy_dir_with_progress(Path::new("/nonexistent/source"), &dst);
2382 assert!(result.is_err());
2383 }
2384
2385 #[test]
2386 fn test_verify_and_copy_files_async_with_files() {
2387 let temp_dir = tempdir().unwrap();
2388 let src = temp_dir.path().join("src");
2389 let dst = temp_dir.path().join("dst");
2390
2391 fs::create_dir_all(src.join("sub1/sub2")).unwrap();
2393 fs::write(src.join("root.txt"), "root").unwrap();
2394 fs::write(src.join("sub1/a.txt"), "a").unwrap();
2395 fs::write(src.join("sub1/sub2/b.txt"), "b").unwrap();
2396
2397 verify_and_copy_files_async(&src, &dst).unwrap();
2398
2399 assert_eq!(fs::read_to_string(dst.join("root.txt")).unwrap(), "root");
2400 assert_eq!(fs::read_to_string(dst.join("sub1/a.txt")).unwrap(), "a");
2401 assert_eq!(
2402 fs::read_to_string(dst.join("sub1/sub2/b.txt")).unwrap(),
2403 "b"
2404 );
2405 }
2406
2407 #[test]
2408 fn test_copy_dir_with_progress_with_files() {
2409 let src_dir = tempdir().unwrap();
2410 let dst_dir = tempdir().unwrap();
2411
2412 let sub1 = src_dir.path().join("a");
2414 let sub2 = sub1.join("b");
2415 fs::create_dir_all(&sub2).unwrap();
2416 fs::write(src_dir.path().join("file1.txt"), "f1").unwrap();
2417 fs::write(sub1.join("file2.txt"), "f2").unwrap();
2418 fs::write(sub2.join("file3.txt"), "f3").unwrap();
2419
2420 copy_dir_with_progress(src_dir.path(), dst_dir.path()).unwrap();
2421
2422 assert_eq!(
2423 fs::read_to_string(dst_dir.path().join("file1.txt")).unwrap(),
2424 "f1"
2425 );
2426 assert_eq!(
2427 fs::read_to_string(dst_dir.path().join("a/file2.txt")).unwrap(),
2428 "f2"
2429 );
2430 assert_eq!(
2431 fs::read_to_string(dst_dir.path().join("a/b/file3.txt")).unwrap(),
2432 "f3"
2433 );
2434 }
2435
2436 #[cfg(unix)]
2437 #[test]
2438 fn test_is_safe_path_broken_symlink() {
2439 let temp_dir = tempdir().unwrap();
2440 let target = temp_dir.path().join("nonexistent_target");
2441 let link = temp_dir.path().join("broken_link");
2442
2443 std::os::unix::fs::symlink(&target, &link).unwrap();
2444 let result = is_safe_path(&link).unwrap();
2445 assert!(result);
2446 }
2447
2448 #[cfg(unix)]
2449 #[test]
2450 fn test_paths_validate_symlink() {
2451 let temp_dir = tempdir().unwrap();
2452 let real = temp_dir.path().join("real");
2453 let link = temp_dir.path().join("link");
2454
2455 fs::create_dir(&real).unwrap();
2456 std::os::unix::fs::symlink(&real, &link).unwrap();
2457
2458 let paths = Paths {
2459 site: link,
2460 content: PathBuf::from("content"),
2461 build: PathBuf::from("build"),
2462 template: PathBuf::from("templates"),
2463 };
2464 let err = paths.validate().unwrap_err();
2465 assert_same_variant(
2466 &err,
2467 &SsgError::SymlinkForbidden {
2468 path: PathBuf::new(),
2469 },
2470 );
2471 }
2472
2473 #[test]
2474 fn test_copy_dir_with_progress_depth_exceeded() {
2475 let src_dir = tempdir().unwrap();
2476 let dst_dir = tempdir().unwrap();
2477 let mut path = src_dir.path().to_path_buf();
2478 for i in 0..=MAX_DIR_DEPTH {
2479 path = path.join(format!("d{i}"));
2480 fs::create_dir(&path).unwrap();
2481 }
2482 let result = copy_dir_with_progress(src_dir.path(), dst_dir.path());
2483 assert!(result.is_err());
2484 assert!(result.unwrap_err().to_string().contains("maximum depth"));
2485 }
2486
2487 #[test]
2488 fn test_verify_and_copy_files_source_is_file() {
2489 let temp_dir = tempdir().unwrap();
2490 let src_file = temp_dir.path().join("source.txt");
2491 let dst_dir = temp_dir.path().join("dst");
2492 fs::write(&src_file, "hello").unwrap();
2493
2494 let result = verify_and_copy_files(&src_file, &dst_dir);
2495 assert!(result.is_err());
2496 }
2497
2498 #[test]
2499 fn test_compile_site_error() {
2500 let temp_dir = tempdir().unwrap();
2507 let build = temp_dir.path().join("build");
2508 let content_file = temp_dir.path().join("content_file");
2509 let site = temp_dir.path().join("site");
2510 let template = temp_dir.path().join("template");
2511 fs::create_dir_all(&build).unwrap();
2512 fs::write(&content_file, "not a directory").unwrap();
2513 fs::create_dir_all(&site).unwrap();
2514 fs::create_dir_all(&template).unwrap();
2515
2516 let result = compile_site(&build, &content_file, &site, &template);
2517 assert!(
2518 result.is_err(),
2519 "compile_site should propagate the io error when \
2520 content_dir is a file, got: {result:?}"
2521 );
2522 }
2523
2524 #[test]
2525 fn test_compile_site_propagates_compile_error() {
2526 let temp_dir = tempdir().unwrap();
2535 let build_file = temp_dir.path().join("build_file");
2536 let content = temp_dir.path().join("content");
2537 let site = temp_dir.path().join("site");
2538 let template = temp_dir.path().join("template");
2539 fs::write(&build_file, "not a directory").unwrap();
2540 fs::create_dir_all(&content).unwrap();
2541 fs::create_dir_all(&site).unwrap();
2542 fs::create_dir_all(&template).unwrap();
2543
2544 let result = compile_site(&build_file, &content, &site, &template);
2545 assert!(
2546 result.is_err(),
2547 "compile_site should propagate compile()'s error when \
2548 build_dir is a file, got: {result:?}"
2549 );
2550 }
2551
2552 #[test]
2553 fn test_prepare_serve_dir_same_as_site() {
2554 let temp_dir = tempdir().unwrap();
2555 let site_dir = temp_dir.path().join("site");
2556 fs::create_dir_all(&site_dir).unwrap();
2557 fs::write(site_dir.join("index.html"), "<html/>").unwrap();
2558
2559 let paths = Paths {
2560 site: site_dir.clone(),
2561 content: PathBuf::from("content"),
2562 build: PathBuf::from("build"),
2563 template: PathBuf::from("templates"),
2564 };
2565
2566 prepare_serve_dir(&paths, &site_dir).unwrap();
2568 assert!(site_dir.join("index.html").exists());
2569 }
2570
2571 #[test]
2572 fn test_prepare_serve_dir_different() {
2573 let temp_dir = tempdir().unwrap();
2574 let site_dir = temp_dir.path().join("site");
2575 let serve_dir = temp_dir.path().join("serve");
2576 fs::create_dir_all(&site_dir).unwrap();
2577 fs::write(site_dir.join("index.html"), "<html/>").unwrap();
2578
2579 let paths = Paths {
2580 site: site_dir,
2581 content: PathBuf::from("content"),
2582 build: PathBuf::from("build"),
2583 template: PathBuf::from("templates"),
2584 };
2585
2586 prepare_serve_dir(&paths, &serve_dir).unwrap();
2587 assert!(serve_dir.join("index.html").exists());
2588 }
2589
2590 #[test]
2591 fn test_create_directories_all_valid() {
2592 let temp_dir = tempdir().unwrap();
2593 let paths = Paths {
2594 site: temp_dir.path().join("s"),
2595 content: temp_dir.path().join("c"),
2596 build: temp_dir.path().join("b"),
2597 template: temp_dir.path().join("t"),
2598 };
2599 create_directories(&paths).unwrap();
2600 assert!(paths.site.exists());
2601 assert!(paths.build.exists());
2602 }
2603
2604 #[test]
2605 fn test_is_safe_path_existing_valid() {
2606 let temp_dir = tempdir().unwrap();
2607 let dir = temp_dir.path().join("valid");
2608 fs::create_dir(&dir).unwrap();
2609 let canonical = dir.canonicalize().unwrap();
2610 assert!(is_safe_path(&canonical).unwrap());
2611 }
2612
2613 #[test]
2618 fn run_options_from_matches_extracts_quiet_drafts_and_deploy() {
2619 let cli = Cli::build();
2620 let matches = cli
2621 .try_get_matches_from(vec![
2622 "ssg", "--quiet", "--drafts", "--deploy", "netlify",
2623 ])
2624 .expect("matches");
2625 let opts = RunOptions::from_matches(&matches);
2626 assert!(opts.quiet);
2627 assert!(opts.include_drafts);
2628 assert_eq!(opts.deploy_target.as_deref(), Some("netlify"));
2629 }
2630
2631 #[test]
2632 fn run_options_from_matches_defaults_when_flags_absent() {
2633 let cli = Cli::build();
2634 let matches = cli.try_get_matches_from(vec!["ssg"]).expect("matches");
2635 let opts = RunOptions::from_matches(&matches);
2636 assert!(!opts.quiet);
2637 assert!(!opts.include_drafts);
2638 assert!(opts.deploy_target.is_none());
2639 }
2640
2641 #[test]
2642 fn build_pipeline_assembles_manager_context_and_dirs() {
2643 let temp = tempdir().unwrap();
2644 let mut config = SsgConfig::default();
2645 config.content_dir = temp.path().join("content");
2646 config.output_dir = temp.path().join("public");
2647 config.template_dir = temp.path().join("templates");
2648 let opts = RunOptions {
2649 quiet: true,
2650 include_drafts: false,
2651 deploy_target: None,
2652 validate_only: false,
2653 jobs: None,
2654 max_memory_mb: None,
2655 ai_fix: false,
2656 ai_fix_dry_run: false,
2657 incremental: false,
2658 no_llm_cache: false,
2659
2660 isr: false,
2661 };
2662
2663 let (plugins, ctx, build_dir, site_dir) =
2664 build_pipeline(&config, &opts);
2665
2666 assert!(plugins.len() >= 10);
2667 assert_ne!(build_dir, site_dir);
2668 assert_eq!(site_dir, temp.path().join("public"));
2669 assert_eq!(ctx.content_dir, temp.path().join("content"));
2670 }
2671
2672 #[test]
2673 fn build_pipeline_with_deploy_target_registers_deploy_plugin() {
2674 let temp = tempdir().unwrap();
2675 let mut config = SsgConfig::default();
2676 config.content_dir = temp.path().join("content");
2677 config.output_dir = temp.path().join("public");
2678
2679 let opts_no_deploy = RunOptions {
2680 quiet: true,
2681 include_drafts: false,
2682 deploy_target: None,
2683 validate_only: false,
2684 jobs: None,
2685 max_memory_mb: None,
2686 ai_fix: false,
2687 ai_fix_dry_run: false,
2688 incremental: false,
2689 no_llm_cache: false,
2690
2691 isr: false,
2692 };
2693 let (no_deploy, _, _, _) = build_pipeline(&config, &opts_no_deploy);
2694
2695 let opts_deploy = RunOptions {
2696 quiet: true,
2697 include_drafts: false,
2698 deploy_target: Some("netlify".to_string()),
2699 validate_only: false,
2700 jobs: None,
2701 max_memory_mb: None,
2702 ai_fix: false,
2703 ai_fix_dry_run: false,
2704 incremental: false,
2705 no_llm_cache: false,
2706
2707 isr: false,
2708 };
2709 let (with_deploy, _, _, _) = build_pipeline(&config, &opts_deploy);
2710
2711 assert_eq!(with_deploy.len(), no_deploy.len() + 1);
2712 }
2713
2714 #[test]
2715 fn build_pipeline_with_unknown_deploy_target_logs_and_skips() {
2716 let temp = tempdir().unwrap();
2717 let mut config = SsgConfig::default();
2718 config.content_dir = temp.path().join("content");
2719 config.output_dir = temp.path().join("public");
2720
2721 let opts = RunOptions {
2722 quiet: true,
2723 include_drafts: false,
2724 deploy_target: Some("nonsense-platform".to_string()),
2725 validate_only: false,
2726 jobs: None,
2727 max_memory_mb: None,
2728 ai_fix: false,
2729 ai_fix_dry_run: false,
2730 incremental: false,
2731 no_llm_cache: false,
2732
2733 isr: false,
2734 };
2735 let (plugins, _, _, _) = build_pipeline(&config, &opts);
2736 let names = plugins.names();
2737 assert!(!names.iter().any(|n| n == &"deploy"));
2738 }
2739
2740 #[test]
2741 fn build_pipeline_with_each_known_deploy_target_registers_one_plugin() {
2742 for target in ["netlify", "vercel", "cloudflare", "github"] {
2743 let temp = tempdir().unwrap();
2744 let mut config = SsgConfig::default();
2745 config.content_dir = temp.path().join("content");
2746 config.output_dir = temp.path().join("public");
2747
2748 let opts = RunOptions {
2749 quiet: true,
2750 include_drafts: false,
2751 deploy_target: Some(target.to_string()),
2752 validate_only: false,
2753 jobs: None,
2754 max_memory_mb: None,
2755 ai_fix: false,
2756 ai_fix_dry_run: false,
2757 incremental: false,
2758 no_llm_cache: false,
2759
2760 isr: false,
2761 };
2762 let (plugins, _, _, _) = build_pipeline(&config, &opts);
2763 assert!(
2764 plugins.names().iter().any(|n| n == &"deploy"),
2765 "deploy plugin should be registered for target `{target}`"
2766 );
2767 }
2768 }
2769
2770 #[derive(Debug, Default)]
2777 struct RecordingTransport {
2778 calls: std::sync::Mutex<Vec<(String, String)>>,
2779 }
2780
2781 impl ServeTransport for RecordingTransport {
2782 fn start(&self, addr: &str, root: &str) -> Result<(), SsgError> {
2783 self.calls
2784 .lock()
2785 .unwrap()
2786 .push((addr.to_string(), root.to_string()));
2787 Ok(())
2788 }
2789 }
2790
2791 #[derive(Debug, Default)]
2794 struct FailingTransport;
2795
2796 impl ServeTransport for FailingTransport {
2797 fn start(&self, _addr: &str, _root: &str) -> Result<(), SsgError> {
2798 Err(SsgError::Validation {
2799 field: "transport".to_string(),
2800 message: "transport failed".to_string(),
2801 })
2802 }
2803 }
2804
2805 #[test]
2806 fn build_serve_address_resolves_path_to_addr_root_pair() {
2807 let (addr, root) = build_serve_address(Path::new("./public")).unwrap();
2808 assert_eq!(
2809 addr,
2810 format!("{}:{}", cmd::DEFAULT_HOST, cmd::DEFAULT_PORT)
2811 );
2812 assert_eq!(root, "./public");
2813 }
2814
2815 #[test]
2816 fn verify_and_copy_files_destination_create_dir_failure_propagates() {
2817 let temp = tempdir().unwrap();
2818 let blocker = temp.path().join("blocker.txt");
2819 fs::write(&blocker, "i am a file, not a directory").unwrap();
2820
2821 let bad_dst = blocker.join("sub");
2822 let result = verify_and_copy_files(temp.path(), &bad_dst);
2823 assert!(result.is_err());
2824 let err = result.unwrap_err();
2825 assert!(
2826 matches!(err, SsgError::Io { ref path, .. } if path == &bad_dst),
2827 "expected SsgError::Io for bad_dst, got: {err:?}"
2828 );
2829 }
2830
2831 #[cfg(not(target_os = "windows"))] #[test]
2833 fn create_directories_unsafe_path_bails() {
2834 let temp = tempdir().unwrap();
2835 let blocker = temp.path().join("blocker.txt");
2836 fs::write(&blocker, "x").unwrap();
2837
2838 let unsafe_path = blocker.join("..").join("subdir");
2839
2840 let paths = Paths {
2841 site: temp.path().join("s"),
2842 content: unsafe_path,
2843 build: temp.path().join("b"),
2844 template: temp.path().join("t"),
2845 };
2846 let result = create_directories(&paths);
2847 assert!(result.is_err());
2848 }
2849
2850 #[test]
2851 fn copy_dir_with_progress_read_dir_failure_propagates() {
2852 let temp = tempdir().unwrap();
2853 let src_file = temp.path().join("not-a-dir.txt");
2854 fs::write(&src_file, "content").unwrap();
2855 let dst = temp.path().join("dst");
2856
2857 let result = copy_dir_with_progress(&src_file, &dst);
2858 assert!(result.is_err());
2859 let err = result.unwrap_err();
2860 assert!(
2861 matches!(err, SsgError::Io { ref path, .. } if path == &src_file),
2862 "expected SsgError::Io for src_file, got: {err:?}"
2863 );
2864 }
2865
2866 #[test]
2867 fn verify_and_copy_files_async_destination_create_dir_failure_propagates() {
2868 let temp = tempdir().unwrap();
2869 let blocker = temp.path().join("async-blocker.txt");
2870 fs::write(&blocker, "blocker").unwrap();
2871
2872 let bad_dst = blocker.join("sub");
2873 let result = verify_and_copy_files_async(temp.path(), &bad_dst);
2874 assert!(result.is_err());
2875 let err = result.unwrap_err();
2876 assert!(
2877 matches!(err, SsgError::Io { ref path, .. } if path == &bad_dst),
2878 "expected SsgError::Io for bad_dst, got: {err:?}"
2879 );
2880 }
2881
2882 #[test]
2883 #[cfg(unix)]
2884 fn build_serve_address_rejects_invalid_utf8_path() {
2885 use std::ffi::OsStr;
2886 use std::os::unix::ffi::OsStrExt;
2887
2888 let invalid_bytes = b"site_\xff_invalid";
2889 let path = Path::new(OsStr::from_bytes(invalid_bytes));
2890 let err = build_serve_address(path).unwrap_err();
2891 assert!(format!("{err:?}").contains("invalid UTF-8"));
2892 }
2893
2894 #[test]
2895 #[cfg(unix)]
2896 fn serve_site_shim_propagates_invalid_utf8_path_error() {
2897 use std::ffi::OsStr;
2898 use std::os::unix::ffi::OsStrExt;
2899 let invalid = b"\xfe\xfe_bad";
2900 let path = Path::new(OsStr::from_bytes(invalid));
2901 let err = serve_site(path).unwrap_err();
2902 assert!(format!("{err:?}").contains("invalid UTF-8"));
2903 }
2904
2905 #[test]
2906 fn serve_site_with_recording_transport_records_addr_and_root() {
2907 let transport = RecordingTransport::default();
2908 serve_site_with(Path::new("./public"), &transport).unwrap();
2909 let calls = transport.calls.lock().unwrap();
2910 assert_eq!(calls.len(), 1);
2911 assert_eq!(calls[0].1, "./public");
2912 }
2913
2914 #[test]
2915 fn serve_site_with_propagates_transport_errors() {
2916 let transport = FailingTransport;
2917 let result = serve_site_with(Path::new("./public"), &transport);
2918 assert!(result.is_err());
2919 assert!(
2920 format!("{:?}", result.unwrap_err()).contains("transport failed")
2921 );
2922 }
2923
2924 #[test]
2925 fn http_transport_implements_serve_transport_trait() {
2926 fn assert_impl<T: ServeTransport>() {}
2927 assert_impl::<HttpTransport>();
2928 }
2929
2930 #[test]
2935 fn execute_build_pipeline_propagates_compile_errors() {
2936 let temp = tempdir().unwrap();
2937 let mut config = SsgConfig::default();
2938 config.content_dir = temp.path().join("missing-content");
2939 config.output_dir = temp.path().join("public");
2940 config.template_dir = temp.path().join("missing-templates");
2941 config.site_name = "broken".to_string();
2942
2943 let opts = RunOptions {
2944 quiet: true,
2945 include_drafts: false,
2946 deploy_target: None,
2947 validate_only: false,
2948 jobs: None,
2949 max_memory_mb: None,
2950 ai_fix: false,
2951 ai_fix_dry_run: false,
2952 incremental: false,
2953 no_llm_cache: false,
2954
2955 isr: false,
2956 };
2957
2958 let (plugins, ctx, build_dir, site_dir) =
2959 build_pipeline(&config, &opts);
2960
2961 let result = execute_build_pipeline(
2962 &plugins,
2963 &ctx,
2964 &build_dir,
2965 &config.content_dir,
2966 &site_dir,
2967 &config.template_dir,
2968 opts.quiet,
2969 );
2970 assert!(result.is_err(), "broken layout should propagate Err");
2971 }
2972
2973 fn run_example_fixture_pipeline(base: &Path, quiet: bool) -> bool {
2977 let content = base.join("examples/content/en");
2978 let template = base.join("examples/templates/en");
2979 if !content.exists() || !template.exists() {
2980 eprintln!(
2981 "skipping: examples/content/en not present in {}",
2982 base.display()
2983 );
2984 return false;
2985 }
2986
2987 let temp = tempdir().unwrap();
2988 let mut config = SsgConfig::default();
2989 config.content_dir = content;
2990 config.template_dir = template;
2991 config.output_dir = temp.path().join("public");
2992 config.site_name = "pipeline-success-test".to_string();
2993 config.base_url = "http://localhost".to_string();
2994
2995 let opts = RunOptions {
2996 quiet,
2997 include_drafts: false,
2998 deploy_target: None,
2999 validate_only: false,
3000 jobs: None,
3001 max_memory_mb: None,
3002 ai_fix: false,
3003 ai_fix_dry_run: false,
3004 incremental: false,
3005 no_llm_cache: false,
3006
3007 isr: false,
3008 };
3009
3010 let (plugins, ctx, build_dir, site_dir) =
3011 build_pipeline(&config, &opts);
3012
3013 execute_build_pipeline(
3014 &plugins,
3015 &ctx,
3016 &build_dir,
3017 &config.content_dir,
3018 &site_dir,
3019 &config.template_dir,
3020 opts.quiet,
3021 )
3022 .unwrap();
3023
3024 let output_present = site_dir.exists() | build_dir.exists();
3026 assert!(output_present);
3027 true
3028 }
3029
3030 #[test]
3031 fn execute_build_pipeline_succeeds_against_real_example_fixtures() {
3032 let cwd = env::current_dir().unwrap();
3033 let _ = run_example_fixture_pipeline(&cwd, true);
3034 }
3035
3036 #[test]
3037 fn execute_build_pipeline_verbose_success_hits_println_arm() {
3038 let cwd = env::current_dir().unwrap();
3039 let _ = run_example_fixture_pipeline(&cwd, false);
3040 }
3041
3042 #[test]
3043 fn example_fixture_pipeline_skips_when_fixtures_missing() {
3044 let temp = tempdir().unwrap();
3045 assert!(!run_example_fixture_pipeline(temp.path(), true));
3046 }
3047
3048 #[test]
3049 fn execute_build_pipeline_verbose_propagates_compile_errors() {
3050 let temp = tempdir().unwrap();
3051 let mut config = SsgConfig::default();
3052 config.content_dir = temp.path().join("missing");
3053 config.output_dir = temp.path().join("public");
3054 config.template_dir = temp.path().join("missing-templates");
3055 config.site_name = "broken-verbose".to_string();
3056
3057 let opts = RunOptions {
3058 quiet: false,
3059 include_drafts: false,
3060 deploy_target: None,
3061 validate_only: false,
3062 jobs: None,
3063 max_memory_mb: None,
3064 ai_fix: false,
3065 ai_fix_dry_run: false,
3066 incremental: false,
3067 no_llm_cache: false,
3068
3069 isr: false,
3070 };
3071
3072 let (plugins, ctx, build_dir, site_dir) =
3073 build_pipeline(&config, &opts);
3074
3075 let _ = execute_build_pipeline(
3076 &plugins,
3077 &ctx,
3078 &build_dir,
3079 &config.content_dir,
3080 &site_dir,
3081 &config.template_dir,
3082 opts.quiet,
3083 );
3084 }
3085
3086 #[test]
3087 fn build_pipeline_with_drafts_flag_registers_draft_plugin() {
3088 let temp = tempdir().unwrap();
3089 let mut config = SsgConfig::default();
3090 config.content_dir = temp.path().join("content");
3091 config.output_dir = temp.path().join("public");
3092
3093 let opts = RunOptions {
3094 quiet: true,
3095 include_drafts: true,
3096 deploy_target: None,
3097 validate_only: false,
3098 jobs: None,
3099 max_memory_mb: None,
3100 ai_fix: false,
3101 ai_fix_dry_run: false,
3102 incremental: false,
3103 no_llm_cache: false,
3104
3105 isr: false,
3106 };
3107 let (plugins, _, _, _) = build_pipeline(&config, &opts);
3108 assert!(plugins.names().iter().any(|n| n == &"drafts"));
3109 }
3110
3111 #[test]
3116 fn now_iso_returns_valid_iso8601_format() {
3117 let ts = now_iso();
3118 assert_eq!(ts.len(), 20, "ISO timestamp should be 20 chars: {ts}");
3119 assert!(ts.ends_with('Z'), "should end with Z: {ts}");
3120 assert_eq!(&ts[4..5], "-");
3121 assert_eq!(&ts[7..8], "-");
3122 assert_eq!(&ts[10..11], "T");
3123 assert_eq!(&ts[13..14], ":");
3124 assert_eq!(&ts[16..17], ":");
3125 let year: u64 = ts[0..4].parse().unwrap();
3126 assert!(year >= 2020, "year should be recent: {year}");
3127 }
3128
3129 #[test]
3130 fn days_to_ymd_epoch() {
3131 let (y, m, d) = days_to_ymd(0);
3132 assert_eq!((y, m, d), (1970, 1, 1));
3133 }
3134
3135 #[test]
3136 fn days_to_ymd_known_date_2026_04_13() {
3137 let (y, m, d) = days_to_ymd(20_556);
3138 assert_eq!((y, m, d), (2026, 4, 13));
3139 }
3140
3141 #[test]
3142 fn days_to_ymd_leap_day() {
3143 let (y, m, d) = days_to_ymd(11_016);
3144 assert_eq!((y, m, d), (2000, 2, 29));
3145 }
3146
3147 #[test]
3148 fn days_to_ymd_y2k() {
3149 let (y, m, d) = days_to_ymd(10_957);
3150 assert_eq!((y, m, d), (2000, 1, 1));
3151 }
3152
3153 #[test]
3158 fn simple_logger_enabled_respects_max_level() {
3159 let logger = SimpleLogger;
3160 let meta = log::MetadataBuilder::new()
3161 .level(log::Level::Info)
3162 .target("test")
3163 .build();
3164 let _ = logger.enabled(&meta);
3165 }
3166
3167 #[test]
3168 fn simple_logger_flush_is_noop() {
3169 use log::Log;
3170 let logger = SimpleLogger;
3171 logger.flush();
3172 }
3173
3174 #[test]
3179 fn build_serve_address_with_absolute_path() {
3180 let (addr, root) = build_serve_address(Path::new("/tmp/site")).unwrap();
3181 assert!(addr.contains(&cmd::DEFAULT_PORT.to_string()));
3182 assert_eq!(root, "/tmp/site");
3183 }
3184
3185 #[test]
3190 fn copy_dir_with_progress_counts_files_and_dirs() {
3191 let src_dir = tempdir().unwrap();
3192 let dst_dir = tempdir().unwrap();
3193
3194 fs::write(src_dir.path().join("a.txt"), "a").unwrap();
3195 fs::write(src_dir.path().join("b.txt"), "b").unwrap();
3196 let sub = src_dir.path().join("sub");
3197 fs::create_dir(&sub).unwrap();
3198 fs::write(sub.join("c.txt"), "c").unwrap();
3199
3200 copy_dir_with_progress(src_dir.path(), dst_dir.path()).unwrap();
3201
3202 assert!(dst_dir.path().join("a.txt").exists());
3203 assert!(dst_dir.path().join("b.txt").exists());
3204 assert!(dst_dir.path().join("sub/c.txt").exists());
3205 }
3206
3207 #[test]
3212 fn days_to_ymd_end_of_year() {
3213 let (y, m, d) = days_to_ymd(364);
3215 assert_eq!((y, m, d), (1970, 12, 31));
3216 }
3217
3218 #[test]
3219 fn days_to_ymd_non_leap_year_feb28() {
3220 let (y, m, d) = days_to_ymd(423);
3222 assert_eq!((y, m, d), (1971, 2, 28));
3223 }
3224
3225 #[test]
3226 fn days_to_ymd_non_leap_year_mar1() {
3227 let (y, m, d) = days_to_ymd(424);
3229 assert_eq!((y, m, d), (1971, 3, 1));
3230 }
3231
3232 #[test]
3233 fn days_to_ymd_century_non_leap() {
3234 let (y, m, d) = days_to_ymd(10_956);
3242 assert_eq!((y, m, d), (1999, 12, 31));
3243 }
3244
3245 #[test]
3246 fn days_to_ymd_large_day_count() {
3247 let (y, m, d) = days_to_ymd(47_482);
3250 assert_eq!((y, m, d), (2100, 1, 1));
3251 }
3252
3253 #[test]
3258 fn now_iso_month_and_day_within_range() {
3259 let ts = now_iso();
3260 let month: u32 = ts[5..7].parse().unwrap();
3261 let day: u32 = ts[8..10].parse().unwrap();
3262 let hour: u32 = ts[11..13].parse().unwrap();
3263 let minute: u32 = ts[14..16].parse().unwrap();
3264 let second: u32 = ts[17..19].parse().unwrap();
3265 assert!((1..=12).contains(&month), "month out of range: {month}");
3266 assert!((1..=31).contains(&day), "day out of range: {day}");
3267 assert!(hour < 24, "hour out of range: {hour}");
3268 assert!(minute < 60, "minute out of range: {minute}");
3269 assert!(second < 60, "second out of range: {second}");
3270 }
3271
3272 #[test]
3277 fn paths_validate_double_slash_in_content() {
3278 let paths = Paths {
3279 site: PathBuf::from("public"),
3280 content: PathBuf::from("content//nested"),
3281 build: PathBuf::from("build"),
3282 template: PathBuf::from("templates"),
3283 };
3284 let err = paths.validate().unwrap_err();
3285 assert_same_variant(
3286 &err,
3287 &SsgError::Validation {
3288 field: String::new(),
3289 message: String::new(),
3290 },
3291 );
3292 }
3293
3294 #[test]
3295 fn paths_validate_traversal_in_build() {
3296 let paths = Paths {
3297 site: PathBuf::from("public"),
3298 content: PathBuf::from("content"),
3299 build: PathBuf::from("../build"),
3300 template: PathBuf::from("templates"),
3301 };
3302 let err = paths.validate().unwrap_err();
3303 assert_same_variant(
3304 &err,
3305 &SsgError::PathTraversal {
3306 path: PathBuf::new(),
3307 },
3308 );
3309 }
3310
3311 #[test]
3312 fn paths_validate_traversal_in_template() {
3313 let paths = Paths {
3314 site: PathBuf::from("public"),
3315 content: PathBuf::from("content"),
3316 build: PathBuf::from("build"),
3317 template: PathBuf::from("../templates"),
3318 };
3319 let err = paths.validate().unwrap_err();
3320 assert_same_variant(
3321 &err,
3322 &SsgError::PathTraversal {
3323 path: PathBuf::new(),
3324 },
3325 );
3326 }
3327
3328 #[test]
3329 fn paths_validate_double_slash_in_build() {
3330 let paths = Paths {
3331 site: PathBuf::from("public"),
3332 content: PathBuf::from("content"),
3333 build: PathBuf::from("build//sub"),
3334 template: PathBuf::from("templates"),
3335 };
3336 let err = paths.validate().unwrap_err();
3337 assert_same_variant(
3338 &err,
3339 &SsgError::Validation {
3340 field: String::new(),
3341 message: String::new(),
3342 },
3343 );
3344 }
3345
3346 #[test]
3347 fn paths_validate_double_slash_in_template() {
3348 let paths = Paths {
3349 site: PathBuf::from("public"),
3350 content: PathBuf::from("content"),
3351 build: PathBuf::from("build"),
3352 template: PathBuf::from("templates//sub"),
3353 };
3354 let err = paths.validate().unwrap_err();
3355 assert_same_variant(
3356 &err,
3357 &SsgError::Validation {
3358 field: String::new(),
3359 message: String::new(),
3360 },
3361 );
3362 }
3363
3364 #[test]
3369 fn paths_builder_partial_override() {
3370 let paths = Paths::builder()
3371 .site("custom_site")
3372 .template("custom_templates")
3373 .build()
3374 .unwrap();
3375 assert_eq!(paths.site, PathBuf::from("custom_site"));
3376 assert_eq!(paths.content, PathBuf::from("content"));
3377 assert_eq!(paths.build, PathBuf::from("build"));
3378 assert_eq!(paths.template, PathBuf::from("custom_templates"));
3379 }
3380
3381 #[test]
3382 fn paths_debug_format() {
3383 let paths = Paths::default_paths();
3384 let debug = format!("{paths:?}");
3385 assert!(debug.contains("site"));
3386 assert!(debug.contains("content"));
3387 }
3388
3389 #[test]
3394 fn run_options_from_matches_extracts_validate_flag() {
3395 let cli = Cli::build();
3396 let matches = cli
3397 .try_get_matches_from(vec!["ssg", "--validate"])
3398 .expect("matches");
3399 let opts = RunOptions::from_matches(&matches);
3400 assert!(opts.validate_only);
3401 assert!(!opts.quiet);
3402 assert!(!opts.include_drafts);
3403 }
3404
3405 #[test]
3406 fn run_options_from_matches_extracts_jobs_flag() {
3407 let cli = Cli::build();
3408 let matches = cli
3409 .try_get_matches_from(vec!["ssg", "--jobs", "8"])
3410 .expect("matches");
3411 let opts = RunOptions::from_matches(&matches);
3412 assert_eq!(opts.jobs, Some(8));
3413 }
3414
3415 #[test]
3416 fn run_options_from_matches_extracts_max_memory_flag() {
3417 let cli = Cli::build();
3418 let matches = cli
3419 .try_get_matches_from(vec!["ssg", "--max-memory", "256"])
3420 .expect("matches");
3421 let opts = RunOptions::from_matches(&matches);
3422 assert_eq!(opts.max_memory_mb, Some(256));
3423 }
3424
3425 #[test]
3426 fn run_options_from_matches_extracts_ai_fix_flags() {
3427 let cli = Cli::build();
3428 let matches = cli
3429 .try_get_matches_from(vec!["ssg", "--ai-fix", "--ai-fix-dry-run"])
3430 .expect("matches");
3431 let opts = RunOptions::from_matches(&matches);
3432 assert!(opts.ai_fix);
3433 assert!(opts.ai_fix_dry_run);
3434 }
3435
3436 #[test]
3437 fn run_options_from_matches_all_flags_combined() {
3438 let cli = Cli::build();
3439 let matches = cli
3440 .try_get_matches_from(vec![
3441 "ssg",
3442 "--quiet",
3443 "--drafts",
3444 "--deploy",
3445 "vercel",
3446 "--validate",
3447 "--jobs",
3448 "4",
3449 "--max-memory",
3450 "1024",
3451 "--ai-fix",
3452 "--ai-fix-dry-run",
3453 ])
3454 .expect("matches");
3455 let opts = RunOptions::from_matches(&matches);
3456 assert!(opts.quiet);
3457 assert!(opts.include_drafts);
3458 assert_eq!(opts.deploy_target.as_deref(), Some("vercel"));
3459 assert!(opts.validate_only);
3460 assert_eq!(opts.jobs, Some(4));
3461 assert_eq!(opts.max_memory_mb, Some(1024));
3462 assert!(opts.ai_fix);
3463 assert!(opts.ai_fix_dry_run);
3464 }
3465
3466 #[test]
3471 fn build_pipeline_propagates_max_memory_to_context() {
3472 let temp = tempdir().unwrap();
3473 let mut config = SsgConfig::default();
3474 config.content_dir = temp.path().join("content");
3475 config.output_dir = temp.path().join("public");
3476 config.template_dir = temp.path().join("templates");
3477
3478 let opts = RunOptions {
3479 quiet: true,
3480 include_drafts: false,
3481 deploy_target: None,
3482 validate_only: false,
3483 jobs: None,
3484 max_memory_mb: Some(128),
3485 ai_fix: false,
3486 ai_fix_dry_run: false,
3487 incremental: false,
3488 no_llm_cache: false,
3489
3490 isr: false,
3491 };
3492
3493 let (_plugins, ctx, _build_dir, _site_dir) =
3494 build_pipeline(&config, &opts);
3495
3496 assert!(
3497 ctx.memory_budget.is_some(),
3498 "memory_budget should be set when max_memory_mb is provided"
3499 );
3500 }
3501
3502 #[test]
3503 fn build_pipeline_no_memory_budget_when_not_specified() {
3504 let temp = tempdir().unwrap();
3505 let mut config = SsgConfig::default();
3506 config.content_dir = temp.path().join("content");
3507 config.output_dir = temp.path().join("public");
3508 config.template_dir = temp.path().join("templates");
3509
3510 let opts = RunOptions {
3511 quiet: true,
3512 include_drafts: false,
3513 deploy_target: None,
3514 validate_only: false,
3515 jobs: None,
3516 max_memory_mb: None,
3517 ai_fix: false,
3518 ai_fix_dry_run: false,
3519 incremental: false,
3520 no_llm_cache: false,
3521
3522 isr: false,
3523 };
3524
3525 let (_plugins, ctx, _build_dir, _site_dir) =
3526 build_pipeline(&config, &opts);
3527
3528 assert!(
3529 ctx.memory_budget.is_none(),
3530 "memory_budget should be None when max_memory_mb not provided"
3531 );
3532 }
3533
3534 #[test]
3539 fn build_pipeline_with_vercel_deploy_target() {
3540 let temp = tempdir().unwrap();
3541 let mut config = SsgConfig::default();
3542 config.content_dir = temp.path().join("content");
3543 config.output_dir = temp.path().join("public");
3544
3545 let opts = RunOptions {
3546 quiet: true,
3547 include_drafts: false,
3548 deploy_target: Some("vercel".to_string()),
3549 validate_only: false,
3550 jobs: None,
3551 max_memory_mb: None,
3552 ai_fix: false,
3553 ai_fix_dry_run: false,
3554 incremental: false,
3555 no_llm_cache: false,
3556
3557 isr: false,
3558 };
3559 let (plugins, _, _, _) = build_pipeline(&config, &opts);
3560 assert!(plugins.names().iter().any(|n| n == &"deploy"));
3561 }
3562
3563 #[test]
3564 fn build_pipeline_with_cloudflare_deploy_target() {
3565 let temp = tempdir().unwrap();
3566 let mut config = SsgConfig::default();
3567 config.content_dir = temp.path().join("content");
3568 config.output_dir = temp.path().join("public");
3569
3570 let opts = RunOptions {
3571 quiet: true,
3572 include_drafts: false,
3573 deploy_target: Some("cloudflare".to_string()),
3574 validate_only: false,
3575 jobs: None,
3576 max_memory_mb: None,
3577 ai_fix: false,
3578 ai_fix_dry_run: false,
3579 incremental: false,
3580 no_llm_cache: false,
3581
3582 isr: false,
3583 };
3584 let (plugins, _, _, _) = build_pipeline(&config, &opts);
3585 assert!(plugins.names().iter().any(|n| n == &"deploy"));
3586 }
3587
3588 #[test]
3589 fn build_pipeline_with_github_deploy_target() {
3590 let temp = tempdir().unwrap();
3591 let mut config = SsgConfig::default();
3592 config.content_dir = temp.path().join("content");
3593 config.output_dir = temp.path().join("public");
3594
3595 let opts = RunOptions {
3596 quiet: true,
3597 include_drafts: false,
3598 deploy_target: Some("github".to_string()),
3599 validate_only: false,
3600 jobs: None,
3601 max_memory_mb: None,
3602 ai_fix: false,
3603 ai_fix_dry_run: false,
3604 incremental: false,
3605 no_llm_cache: false,
3606
3607 isr: false,
3608 };
3609 let (plugins, _, _, _) = build_pipeline(&config, &opts);
3610 assert!(plugins.names().iter().any(|n| n == &"deploy"));
3611 }
3612
3613 #[test]
3618 fn resolve_build_and_site_dirs_serve_dir_none_uses_output_dir_as_site() {
3619 let mut config = SsgConfig::default();
3620 config.output_dir = PathBuf::from("my-output");
3621 config.serve_dir = None;
3622
3623 let (_build_dir, site_dir) = resolve_build_and_site_dirs(&config);
3624 assert_eq!(site_dir, PathBuf::from("my-output"));
3625 }
3626
3627 #[test]
3628 fn resolve_build_and_site_dirs_always_produces_distinct_dirs() {
3629 let mut config = SsgConfig::default();
3631 config.output_dir = PathBuf::from("same");
3632 config.serve_dir = Some(PathBuf::from("same"));
3633
3634 let (build_dir, site_dir) = resolve_build_and_site_dirs(&config);
3635 assert_ne!(build_dir, site_dir);
3636 assert_eq!(site_dir, PathBuf::from("same"));
3637 assert!(build_dir.to_string_lossy().contains("build-tmp"));
3638 }
3639
3640 #[test]
3645 fn generate_locale_redirect_creates_index_html() {
3646 let temp = tempdir().unwrap();
3647 let locales = vec!["en".to_string(), "fr".to_string()];
3648 generate_locale_redirect(temp.path(), &locales, "en").unwrap();
3649
3650 let index = temp.path().join("index.html");
3651 assert!(index.exists());
3652 let content = fs::read_to_string(&index).unwrap();
3653 assert!(content.contains("ssg-locale-redirect"));
3654 assert!(content.contains("\"en\""));
3655 assert!(content.contains("\"fr\""));
3656 }
3657
3658 #[test]
3659 fn generate_locale_redirect_does_not_overwrite_user_index() {
3660 let temp = tempdir().unwrap();
3661 let user_html = "<html><body>My site</body></html>";
3662 fs::write(temp.path().join("index.html"), user_html).unwrap();
3663
3664 let locales = vec!["en".to_string()];
3665 generate_locale_redirect(temp.path(), &locales, "en").unwrap();
3666
3667 let content =
3668 fs::read_to_string(temp.path().join("index.html")).unwrap();
3669 assert_eq!(content, user_html, "user index.html should be preserved");
3670 }
3671
3672 #[test]
3673 fn generate_locale_redirect_overwrites_own_index() {
3674 let temp = tempdir().unwrap();
3675 let old_redirect = "<!-- ssg-locale-redirect --><html>old</html>";
3676 fs::write(temp.path().join("index.html"), old_redirect).unwrap();
3677
3678 let locales = vec!["de".to_string(), "en".to_string()];
3679 generate_locale_redirect(temp.path(), &locales, "de").unwrap();
3680
3681 let content =
3682 fs::read_to_string(temp.path().join("index.html")).unwrap();
3683 assert!(content.contains("ssg-locale-redirect"));
3684 assert!(content.contains("\"de\""));
3685 }
3686
3687 #[test]
3690 fn apply_rayon_thread_pool_none_is_no_op() {
3691 assert!(apply_rayon_thread_pool(None).is_ok());
3693 }
3694
3695 #[test]
3696 fn apply_rayon_thread_pool_some_either_succeeds_or_signals_already_set() {
3697 test_support::init_logger();
3707 let result = apply_rayon_thread_pool(Some(1));
3708 match result {
3709 Ok(()) => {}
3710 Err(e) => assert_same_variant(
3711 &e,
3712 &SsgError::Validation {
3713 field: String::new(),
3714 message: String::new(),
3715 },
3716 ),
3717 }
3718 }
3719
3720 #[test]
3721 fn apply_rayon_thread_pool_succeeds_in_fresh_process() {
3722 let exe = env::current_exe().unwrap();
3726 let status = std::process::Command::new(exe)
3727 .args([
3728 "--exact",
3729 "tests::apply_rayon_thread_pool_some_either_succeeds_or_signals_already_set",
3730 "--test-threads=1",
3731 ])
3732 .status()
3733 .unwrap();
3734 assert!(status.success());
3735 }
3736
3737 #[test]
3738 fn build_config_from_subcommand_matches_routes_through_to_config() {
3739 let (_inv, matches) =
3740 Cli::parse_and_dispatch(["ssg", "build"]).unwrap();
3741 let sub = matches.subcommand_matches("build").unwrap();
3742 let cfg = build_config_from_subcommand_matches(sub).unwrap();
3743 assert_eq!(cfg.content_dir, PathBuf::from("content"));
3745 }
3746
3747 #[test]
3748 fn build_config_from_subcommand_matches_propagates_validation_errors() {
3749 let (_inv, matches) = Cli::parse_and_dispatch([
3753 "ssg",
3754 "build",
3755 "--config",
3756 "/definitely/does/not/exist.toml",
3757 ])
3758 .unwrap();
3759 let sub = matches.subcommand_matches("build").unwrap();
3760 let err = build_config_from_subcommand_matches(sub).unwrap_err();
3761 assert_same_variant(
3762 &err,
3763 &SsgError::Validation {
3764 field: String::new(),
3765 message: String::new(),
3766 },
3767 );
3768 }
3769
3770 #[test]
3771 fn dispatch_invocation_check_with_missing_subcommand_returns_validation_error(
3772 ) {
3773 let matches =
3776 Cli::subcommand_app().try_get_matches_from(["ssg"]).unwrap();
3777 let err =
3778 dispatch_invocation(CliInvocation::Check, &matches).unwrap_err();
3779 assert!(
3780 matches!(err, SsgError::Validation { field, .. } if field == "subcommand")
3781 );
3782 }
3783
3784 #[test]
3785 fn dispatch_invocation_build_with_missing_subcommand_returns_validation_error(
3786 ) {
3787 let matches =
3788 Cli::subcommand_app().try_get_matches_from(["ssg"]).unwrap();
3789 let err =
3790 dispatch_invocation(CliInvocation::Build, &matches).unwrap_err();
3791 assert!(
3792 matches!(err, SsgError::Validation { field, .. } if field == "subcommand")
3793 );
3794 }
3795
3796 #[test]
3797 fn dispatch_invocation_dev_with_missing_subcommand_returns_validation_error(
3798 ) {
3799 let matches =
3800 Cli::subcommand_app().try_get_matches_from(["ssg"]).unwrap();
3801 let err =
3802 dispatch_invocation(CliInvocation::Dev, &matches).unwrap_err();
3803 assert!(
3804 matches!(err, SsgError::Validation { field, .. } if field == "subcommand")
3805 );
3806 }
3807
3808 #[test]
3809 fn dispatch_invocation_deploy_with_missing_subcommand_returns_validation_error(
3810 ) {
3811 let matches =
3812 Cli::subcommand_app().try_get_matches_from(["ssg"]).unwrap();
3813 let err = dispatch_invocation(
3814 CliInvocation::Deploy {
3815 target: "none".to_string(),
3816 },
3817 &matches,
3818 )
3819 .unwrap_err();
3820 assert!(
3821 matches!(err, SsgError::Validation { field, .. } if field == "subcommand")
3822 );
3823 }
3824
3825 fn ssg_check_lock() -> &'static std::sync::Mutex<()> {
3829 use std::sync::Mutex;
3830 use std::sync::OnceLock;
3831 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
3832 LOCK.get_or_init(|| Mutex::new(()))
3833 }
3834
3835 fn ssg_check_guard() -> std::sync::MutexGuard<'static, ()> {
3838 ssg_check_lock().lock().unwrap_or_else(|p| p.into_inner())
3839 }
3840
3841 #[test]
3842 fn ssg_check_guard_recovers_from_poisoned_lock() {
3843 let poisoner = std::thread::spawn(|| {
3846 let _g = ssg_check_lock().lock().unwrap();
3847 panic!("deliberate poison for ssg_check_guard test");
3848 });
3849 assert!(poisoner.join().is_err());
3850 let _g = ssg_check_guard();
3851 }
3852
3853 #[test]
3854 fn run_check_with_empty_content_dir_passes() {
3855 let _g = ssg_check_guard();
3856
3857 let content = tempdir().unwrap();
3858 let templates = tempdir().unwrap();
3859 let output = tempdir().unwrap();
3860 let argv = [
3861 "ssg",
3862 "check",
3863 "--content",
3864 content.path().to_str().unwrap(),
3865 "--template",
3866 templates.path().to_str().unwrap(),
3867 "--output",
3868 output.path().to_str().unwrap(),
3869 "--quiet",
3870 ];
3871 let (inv, matches) = Cli::parse_and_dispatch(argv).unwrap();
3872 assert_same_variant(&inv, &CliInvocation::Check);
3873 let result = dispatch_invocation(inv, &matches);
3876 assert!(result.is_ok(), "run_check failed: {result:?}");
3877 }
3878
3879 #[test]
3880 fn only_site_producing_invocations_claim_a_generated_site() {
3881 for inv in [
3885 CliInvocation::Legacy,
3886 CliInvocation::Build,
3887 CliInvocation::Dev,
3888 CliInvocation::Deploy {
3889 target: "none".to_string(),
3890 },
3891 ] {
3892 assert!(
3893 generates_site(&inv),
3894 "{inv:?} writes a site and must report it"
3895 );
3896 }
3897
3898 for inv in [
3899 CliInvocation::Check,
3900 CliInvocation::Audit,
3901 CliInvocation::Plugins {
3902 json: false,
3903 target: None,
3904 },
3905 ] {
3906 assert!(
3907 !generates_site(&inv),
3908 "{inv:?} writes no site and must not claim one"
3909 );
3910 }
3911 }
3912
3913 #[test]
3914 fn deploy_claims_a_site_for_every_supported_target() {
3915 for target in cmd::DEPLOY_TARGETS {
3918 let inv = CliInvocation::Deploy {
3919 target: (*target).to_string(),
3920 };
3921 assert!(generates_site(&inv), "deploy --target {target}");
3922 }
3923 }
3924
3925 #[test]
3926 fn run_legacy_with_validate_flag_short_circuits_to_validate_only() {
3927 let _g = ssg_check_guard();
3928
3929 let content = tempdir().unwrap();
3930 let templates = tempdir().unwrap();
3931 let output = tempdir().unwrap();
3932 let argv = [
3933 "ssg",
3934 "--content",
3935 content.path().to_str().unwrap(),
3936 "--template",
3937 templates.path().to_str().unwrap(),
3938 "--output",
3939 output.path().to_str().unwrap(),
3940 "--quiet",
3941 "--validate",
3942 ];
3943 let (inv, matches) = Cli::parse_and_dispatch(argv).unwrap();
3944 assert_same_variant(&inv, &CliInvocation::Legacy);
3945 let result = dispatch_invocation(inv, &matches);
3948 assert!(result.is_ok(), "run_legacy --validate failed: {result:?}");
3949 }
3950
3951 #[test]
3952 fn run_subcommand_build_with_empty_dirs_completes() {
3953 let _g = ssg_check_guard();
3958
3959 let content = tempdir().unwrap();
3960 let templates = tempdir().unwrap();
3961 let output = tempdir().unwrap();
3962 let argv = [
3963 "ssg",
3964 "build",
3965 "--content",
3966 content.path().to_str().unwrap(),
3967 "--template",
3968 templates.path().to_str().unwrap(),
3969 "--output",
3970 output.path().to_str().unwrap(),
3971 "--quiet",
3972 ];
3973 let (inv, matches) = Cli::parse_and_dispatch(argv).unwrap();
3974 assert_same_variant(&inv, &CliInvocation::Build);
3975 let result = dispatch_invocation(inv, &matches);
3976 let _ = result;
3979 }
3980
3981 #[test]
3982 fn run_deploy_with_none_target_invokes_noop_adapter() {
3983 let _g = ssg_check_guard();
3988
3989 let content = tempdir().unwrap();
3990 let templates = tempdir().unwrap();
3991 let output = tempdir().unwrap();
3992 let argv = [
3993 "ssg",
3994 "deploy",
3995 "--target",
3996 "none",
3997 "--content",
3998 content.path().to_str().unwrap(),
3999 "--template",
4000 templates.path().to_str().unwrap(),
4001 "--output",
4002 output.path().to_str().unwrap(),
4003 "--quiet",
4004 ];
4005 let (inv, matches) = Cli::parse_and_dispatch(argv).unwrap();
4006 assert_same_variant(
4007 &inv,
4008 &CliInvocation::Deploy {
4009 target: String::new(),
4010 },
4011 );
4012 let result = dispatch_invocation(inv, &matches);
4013 let _ = result;
4014 }
4015
4016 #[test]
4017 fn run_legacy_happy_path_with_empty_content_dir() {
4018 let _g = ssg_check_guard();
4023
4024 let content = tempdir().unwrap();
4025 let templates = tempdir().unwrap();
4026 let output = tempdir().unwrap();
4027 let argv = [
4028 "ssg",
4029 "--content",
4030 content.path().to_str().unwrap(),
4031 "--template",
4032 templates.path().to_str().unwrap(),
4033 "--output",
4034 output.path().to_str().unwrap(),
4035 "--quiet",
4036 ];
4037 let (inv, matches) = Cli::parse_and_dispatch(argv).unwrap();
4038 assert_same_variant(&inv, &CliInvocation::Legacy);
4039 let result = dispatch_invocation(inv, &matches);
4040 let _ = result;
4041 }
4042 fn os_argv(args: &[&str]) -> Vec<std::ffi::OsString> {
4046 args.iter().map(std::ffi::OsString::from).collect()
4047 }
4048
4049 #[test]
4050 fn run_with_argv_legacy_validate_short_circuits_cleanly() {
4051 let _g = ssg_check_guard();
4052
4053 let content = tempdir().unwrap();
4054 let templates = tempdir().unwrap();
4055 let output = tempdir().unwrap();
4056 let argv = os_argv(&[
4057 "ssg",
4058 "--content",
4059 content.path().to_str().unwrap(),
4060 "--template",
4061 templates.path().to_str().unwrap(),
4062 "--output",
4063 output.path().to_str().unwrap(),
4064 "--quiet",
4065 "--validate",
4066 ]);
4067 let result = run_with_argv(argv);
4070 assert!(result.is_ok());
4071 }
4072
4073 #[test]
4074 fn run_with_argv_subcommand_check_lacks_trace_id() {
4075 let _g = ssg_check_guard();
4076
4077 let content = tempdir().unwrap();
4078 let templates = tempdir().unwrap();
4079 let output = tempdir().unwrap();
4080 let argv = os_argv(&[
4081 "ssg",
4082 "check",
4083 "--content",
4084 content.path().to_str().unwrap(),
4085 "--template",
4086 templates.path().to_str().unwrap(),
4087 "--output",
4088 output.path().to_str().unwrap(),
4089 "--quiet",
4090 ]);
4091 let result = run_with_argv(argv);
4094 assert!(result.is_ok());
4095 }
4096
4097 const RUN_CHILD_ENV: &str = "SSG_TEST_RUN_ENTRYPOINT_CHILD";
4099
4100 #[test]
4101 fn run_entrypoint_child() {
4102 if env::var(RUN_CHILD_ENV).is_err() {
4109 return;
4110 }
4111 let _ = run();
4112 }
4113
4114 #[test]
4115 fn run_exits_with_clap_error_code_in_child_process() {
4116 let exe = env::current_exe().unwrap();
4120 let output = std::process::Command::new(exe)
4121 .args([
4122 "--exact",
4123 "tests::run_entrypoint_child",
4124 "--test-threads=1",
4125 ])
4126 .env(RUN_CHILD_ENV, "1")
4127 .output()
4128 .unwrap();
4129 assert_eq!(output.status.code(), Some(2));
4130 }
4131
4132 #[test]
4135 fn dispatch_invocation_audit_with_missing_subcommand_returns_validation_error(
4136 ) {
4137 let matches =
4138 Cli::subcommand_app().try_get_matches_from(["ssg"]).unwrap();
4139 let err =
4140 dispatch_invocation(CliInvocation::Audit, &matches).unwrap_err();
4141 assert!(
4142 matches!(err, SsgError::Validation { field, .. } if field == "subcommand")
4143 );
4144 }
4145
4146 #[test]
4147 fn run_audit_explain_lists_gates_without_running_them() {
4148 let (inv, matches) =
4152 Cli::parse_and_dispatch(["ssg", "audit", "--explain"]).unwrap();
4153 let result = dispatch_invocation(inv, &matches);
4154 assert!(result.is_ok());
4155 }
4156
4157 #[test]
4160 fn run_legacy_with_bad_config_file_maps_to_validation_error() {
4161 let _g = ssg_check_guard();
4162
4163 let (inv, matches) = Cli::parse_and_dispatch([
4164 "ssg",
4165 "--config",
4166 "/definitely/does/not/exist.toml",
4167 ])
4168 .unwrap();
4169 let err = dispatch_invocation(inv, &matches).unwrap_err();
4170 assert!(
4171 matches!(err, SsgError::Validation { field, .. } if field == "config")
4172 );
4173 }
4174
4175 #[test]
4176 fn run_legacy_validate_with_invalid_schema_propagates_error() {
4177 let _g = ssg_check_guard();
4178
4179 let content = tempdir().unwrap();
4180 fs::write(
4181 content.path().join("content.schema.toml"),
4182 "not [valid toml",
4183 )
4184 .unwrap();
4185 let (inv, matches) = Cli::parse_and_dispatch([
4186 "ssg",
4187 "--content",
4188 content.path().to_str().unwrap(),
4189 "--quiet",
4190 "--validate",
4191 ])
4192 .unwrap();
4193 let err = dispatch_invocation(inv, &matches).unwrap_err();
4194 assert!(
4195 matches!(err, SsgError::Validation { field, .. } if field == "content")
4196 );
4197 }
4198
4199 #[test]
4200 fn run_legacy_with_jobs_after_pool_init_errors() {
4201 let _g = ssg_check_guard();
4202 force_rayon_global_pool_init();
4203
4204 let content = tempdir().unwrap();
4205 let (inv, matches) = Cli::parse_and_dispatch([
4206 "ssg",
4207 "--content",
4208 content.path().to_str().unwrap(),
4209 "--jobs",
4210 "2",
4211 "--quiet",
4212 ])
4213 .unwrap();
4214 let err = dispatch_invocation(inv, &matches).unwrap_err();
4215 assert!(
4216 matches!(err, SsgError::Validation { field, .. } if field == "jobs")
4217 );
4218 }
4219
4220 #[test]
4221 fn run_legacy_nonquiet_prints_banner_and_builds() {
4222 let _g = ssg_check_guard();
4223
4224 let content = tempdir().unwrap();
4225 let templates = tempdir().unwrap();
4226 let output = tempdir().unwrap();
4227 let (inv, matches) = Cli::parse_and_dispatch([
4228 "ssg",
4229 "--content",
4230 content.path().to_str().unwrap(),
4231 "--template",
4232 templates.path().to_str().unwrap(),
4233 "--output",
4234 output.path().to_str().unwrap(),
4235 ])
4236 .unwrap();
4237 let result = dispatch_invocation(inv, &matches);
4238 assert!(result.is_ok());
4239 }
4240
4241 #[test]
4242 fn run_legacy_with_missing_content_dir_propagates_build_error() {
4243 let _g = ssg_check_guard();
4244
4245 let temp = tempdir().unwrap();
4246 let missing_content = temp.path().join("missing-content");
4247 let missing_templates = temp.path().join("missing-templates");
4248 let output = temp.path().join("public");
4249 let (inv, matches) = Cli::parse_and_dispatch([
4250 "ssg",
4251 "--content",
4252 missing_content.to_str().unwrap(),
4253 "--template",
4254 missing_templates.to_str().unwrap(),
4255 "--output",
4256 output.to_str().unwrap(),
4257 "--quiet",
4258 ])
4259 .unwrap();
4260 let result = dispatch_invocation(inv, &matches);
4261 assert!(result.is_err());
4262 }
4263
4264 #[test]
4265 fn run_legacy_serve_flag_boots_dev_server_and_returns() {
4266 let _g = ssg_check_guard();
4267
4268 let _port_guard =
4272 std::net::TcpListener::bind((cmd::DEFAULT_HOST, cmd::DEFAULT_PORT))
4273 .ok();
4274
4275 let content = tempdir().unwrap();
4276 let templates = tempdir().unwrap();
4277 let output = tempdir().unwrap();
4278 let (inv, matches) = Cli::parse_and_dispatch([
4279 "ssg",
4280 "--content",
4281 content.path().to_str().unwrap(),
4282 "--template",
4283 templates.path().to_str().unwrap(),
4284 "--output",
4285 output.path().to_str().unwrap(),
4286 "--serve",
4287 output.path().to_str().unwrap(),
4288 "--quiet",
4289 ])
4290 .unwrap();
4291 let result = dispatch_invocation(inv, &matches);
4292 assert!(result.is_ok());
4293 }
4294
4295 #[test]
4298 fn run_subcommand_build_with_bad_config_returns_validation_error() {
4299 let _g = ssg_check_guard();
4300
4301 let (inv, matches) = Cli::parse_and_dispatch([
4302 "ssg",
4303 "build",
4304 "--config",
4305 "/definitely/does/not/exist.toml",
4306 ])
4307 .unwrap();
4308 let err = dispatch_invocation(inv, &matches).unwrap_err();
4309 assert!(
4310 matches!(err, SsgError::Validation { field, .. } if field == "config")
4311 );
4312 }
4313
4314 fn force_rayon_global_pool_init() {
4317 let _ = rayon::ThreadPoolBuilder::new().build_global();
4318 }
4319
4320 #[test]
4321 fn run_subcommand_build_with_jobs_after_pool_init_errors() {
4322 let _g = ssg_check_guard();
4323 force_rayon_global_pool_init();
4324
4325 let content = tempdir().unwrap();
4326 let (inv, matches) = Cli::parse_and_dispatch([
4327 "ssg",
4328 "build",
4329 "--content",
4330 content.path().to_str().unwrap(),
4331 "--jobs",
4332 "2",
4333 "--quiet",
4334 ])
4335 .unwrap();
4336 let err = dispatch_invocation(inv, &matches).unwrap_err();
4337 assert!(
4338 matches!(err, SsgError::Validation { field, .. } if field == "jobs")
4339 );
4340 }
4341
4342 #[test]
4343 fn run_subcommand_build_nonquiet_prints_banner() {
4344 let _g = ssg_check_guard();
4345
4346 let content = tempdir().unwrap();
4347 let templates = tempdir().unwrap();
4348 let output = tempdir().unwrap();
4349 let (inv, matches) = Cli::parse_and_dispatch([
4350 "ssg",
4351 "build",
4352 "--content",
4353 content.path().to_str().unwrap(),
4354 "--template",
4355 templates.path().to_str().unwrap(),
4356 "--output",
4357 output.path().to_str().unwrap(),
4358 ])
4359 .unwrap();
4360 let result = dispatch_invocation(inv, &matches);
4361 assert!(result.is_ok());
4362 }
4363
4364 #[test]
4365 fn run_subcommand_build_with_missing_content_dir_propagates_error() {
4366 let _g = ssg_check_guard();
4367
4368 let temp = tempdir().unwrap();
4369 let (inv, matches) = Cli::parse_and_dispatch([
4370 "ssg",
4371 "build",
4372 "--content",
4373 temp.path().join("missing-content").to_str().unwrap(),
4374 "--template",
4375 temp.path().join("missing-templates").to_str().unwrap(),
4376 "--output",
4377 temp.path().join("public").to_str().unwrap(),
4378 "--quiet",
4379 ])
4380 .unwrap();
4381 let result = dispatch_invocation(inv, &matches);
4382 assert!(result.is_err());
4383 }
4384
4385 #[test]
4386 fn run_subcommand_dev_serves_and_returns_when_port_is_held() {
4387 let _g = ssg_check_guard();
4388
4389 let _port_guard =
4390 std::net::TcpListener::bind((cmd::DEFAULT_HOST, cmd::DEFAULT_PORT))
4391 .ok();
4392
4393 let content = tempdir().unwrap();
4394 let templates = tempdir().unwrap();
4395 let output = tempdir().unwrap();
4396 let (inv, matches) = Cli::parse_and_dispatch([
4397 "ssg",
4398 "dev",
4399 "--content",
4400 content.path().to_str().unwrap(),
4401 "--template",
4402 templates.path().to_str().unwrap(),
4403 "--output",
4404 output.path().to_str().unwrap(),
4405 "--quiet",
4406 ])
4407 .unwrap();
4408 assert_same_variant(&inv, &CliInvocation::Dev);
4409 let result = dispatch_invocation(inv, &matches);
4410 assert!(result.is_ok());
4411 }
4412
4413 #[test]
4416 fn run_check_with_bad_config_returns_validation_error() {
4417 let _g = ssg_check_guard();
4418
4419 let (inv, matches) = Cli::parse_and_dispatch([
4420 "ssg",
4421 "check",
4422 "--config",
4423 "/definitely/does/not/exist.toml",
4424 ])
4425 .unwrap();
4426 let err = dispatch_invocation(inv, &matches).unwrap_err();
4427 assert!(
4428 matches!(err, SsgError::Validation { field, .. } if field == "config")
4429 );
4430 }
4431
4432 #[test]
4433 fn run_check_with_jobs_after_pool_init_errors() {
4434 let _g = ssg_check_guard();
4435 force_rayon_global_pool_init();
4436
4437 let content = tempdir().unwrap();
4438 let (inv, matches) = Cli::parse_and_dispatch([
4439 "ssg",
4440 "check",
4441 "--content",
4442 content.path().to_str().unwrap(),
4443 "--jobs",
4444 "2",
4445 "--quiet",
4446 ])
4447 .unwrap();
4448 let err = dispatch_invocation(inv, &matches).unwrap_err();
4449 assert!(
4450 matches!(err, SsgError::Validation { field, .. } if field == "jobs")
4451 );
4452 }
4453
4454 #[test]
4455 fn run_check_with_invalid_schema_fails_validation() {
4456 let _g = ssg_check_guard();
4457
4458 let content = tempdir().unwrap();
4459 fs::write(
4460 content.path().join("content.schema.toml"),
4461 "not [valid toml",
4462 )
4463 .unwrap();
4464 let (inv, matches) = Cli::parse_and_dispatch([
4465 "ssg",
4466 "check",
4467 "--content",
4468 content.path().to_str().unwrap(),
4469 "--quiet",
4470 ])
4471 .unwrap();
4472 let err = dispatch_invocation(inv, &matches).unwrap_err();
4473 assert!(
4474 matches!(err, SsgError::Validation { field, .. } if field == "content")
4475 );
4476 }
4477
4478 #[test]
4479 fn run_check_with_invalid_utf8_markdown_fails_before_compile() {
4480 let _g = ssg_check_guard();
4481
4482 let content = tempdir().unwrap();
4485 fs::write(content.path().join("fail.md"), [0xFF, 0xFE, 0xFD]).unwrap();
4486 let templates = tempdir().unwrap();
4487 let output = tempdir().unwrap();
4488 let (inv, matches) = Cli::parse_and_dispatch([
4489 "ssg",
4490 "check",
4491 "--content",
4492 content.path().to_str().unwrap(),
4493 "--template",
4494 templates.path().to_str().unwrap(),
4495 "--output",
4496 output.path().to_str().unwrap(),
4497 "--quiet",
4498 ])
4499 .unwrap();
4500 let result = dispatch_invocation(inv, &matches);
4501 assert!(result.is_err());
4502 }
4503
4504 #[test]
4505 fn run_check_nonquiet_prints_success_line() {
4506 let _g = ssg_check_guard();
4507
4508 let content = tempdir().unwrap();
4509 let templates = tempdir().unwrap();
4510 let output = tempdir().unwrap();
4511 let (inv, matches) = Cli::parse_and_dispatch([
4512 "ssg",
4513 "check",
4514 "--content",
4515 content.path().to_str().unwrap(),
4516 "--template",
4517 templates.path().to_str().unwrap(),
4518 "--output",
4519 output.path().to_str().unwrap(),
4520 ])
4521 .unwrap();
4522 let result = dispatch_invocation(inv, &matches);
4523 assert!(result.is_ok());
4524 }
4525
4526 #[test]
4529 fn run_deploy_with_bad_config_returns_validation_error() {
4530 let _g = ssg_check_guard();
4531
4532 let (inv, matches) = Cli::parse_and_dispatch([
4533 "ssg",
4534 "deploy",
4535 "--target",
4536 "none",
4537 "--config",
4538 "/definitely/does/not/exist.toml",
4539 ])
4540 .unwrap();
4541 let err = dispatch_invocation(inv, &matches).unwrap_err();
4542 assert!(
4543 matches!(err, SsgError::Validation { field, .. } if field == "config")
4544 );
4545 }
4546
4547 #[test]
4548 fn run_deploy_with_jobs_after_pool_init_errors() {
4549 let _g = ssg_check_guard();
4550 force_rayon_global_pool_init();
4551
4552 let content = tempdir().unwrap();
4553 let (inv, matches) = Cli::parse_and_dispatch([
4554 "ssg",
4555 "deploy",
4556 "--target",
4557 "none",
4558 "--content",
4559 content.path().to_str().unwrap(),
4560 "--jobs",
4561 "2",
4562 "--quiet",
4563 ])
4564 .unwrap();
4565 let err = dispatch_invocation(inv, &matches).unwrap_err();
4566 assert!(
4567 matches!(err, SsgError::Validation { field, .. } if field == "jobs")
4568 );
4569 }
4570
4571 #[test]
4572 fn run_deploy_nonquiet_prints_banner_and_adapter_name() {
4573 let _g = ssg_check_guard();
4574
4575 let content = tempdir().unwrap();
4576 let templates = tempdir().unwrap();
4577 let output = tempdir().unwrap();
4578 let (inv, matches) = Cli::parse_and_dispatch([
4579 "ssg",
4580 "deploy",
4581 "--target",
4582 "none",
4583 "--content",
4584 content.path().to_str().unwrap(),
4585 "--template",
4586 templates.path().to_str().unwrap(),
4587 "--output",
4588 output.path().to_str().unwrap(),
4589 ])
4590 .unwrap();
4591 let result = dispatch_invocation(inv, &matches);
4592 assert!(result.is_ok());
4593 }
4594
4595 #[test]
4596 fn run_deploy_with_missing_content_dir_propagates_build_error() {
4597 let _g = ssg_check_guard();
4598
4599 let temp = tempdir().unwrap();
4600 let (inv, matches) = Cli::parse_and_dispatch([
4601 "ssg",
4602 "deploy",
4603 "--target",
4604 "none",
4605 "--content",
4606 temp.path().join("missing-content").to_str().unwrap(),
4607 "--template",
4608 temp.path().join("missing-templates").to_str().unwrap(),
4609 "--output",
4610 temp.path().join("public").to_str().unwrap(),
4611 "--quiet",
4612 ])
4613 .unwrap();
4614 let result = dispatch_invocation(inv, &matches);
4615 assert!(result.is_err());
4616 }
4617
4618 #[test]
4619 fn run_deploy_with_unknown_target_errors_after_build() {
4620 let _g = ssg_check_guard();
4621
4622 let content = tempdir().unwrap();
4623 let templates = tempdir().unwrap();
4624 let output = tempdir().unwrap();
4625 let (_inv, matches) = Cli::parse_and_dispatch([
4629 "ssg",
4630 "deploy",
4631 "--target",
4632 "none",
4633 "--content",
4634 content.path().to_str().unwrap(),
4635 "--template",
4636 templates.path().to_str().unwrap(),
4637 "--output",
4638 output.path().to_str().unwrap(),
4639 "--quiet",
4640 ])
4641 .unwrap();
4642 let err = dispatch_invocation(
4643 CliInvocation::Deploy {
4644 target: "bogus".to_string(),
4645 },
4646 &matches,
4647 )
4648 .unwrap_err();
4649 assert!(
4650 matches!(err, SsgError::Validation { field, .. } if field == "deploy.target")
4651 );
4652 }
4653
4654 #[cfg(feature = "test-fault-injection")]
4662 mod failpoints {
4663 use super::*;
4664
4665 struct FailGuard<'a>(&'a str);
4667 impl Drop for FailGuard<'_> {
4668 fn drop(&mut self) {
4669 let _ = fail::cfg(self.0, "off");
4670 }
4671 }
4672
4673 #[test]
4674 fn run_with_argv_propagates_injected_logging_init_failure() {
4675 let _g = ssg_check_guard();
4676 let _fp = FailGuard("lib::initialize-logging");
4677 fail::cfg("lib::initialize-logging", "return").unwrap();
4678
4679 let argv = os_argv(&["ssg", "--validate", "--quiet"]);
4680 let err = run_with_argv(argv).unwrap_err();
4681 assert!(format!("{err:?}")
4682 .contains("injected: lib::initialize-logging"));
4683 }
4684
4685 #[test]
4686 fn run_legacy_serve_propagates_injected_on_serve_failure() {
4687 let _g = ssg_check_guard();
4688 let _fp = FailGuard("lib::run-on-serve");
4689 fail::cfg("lib::run-on-serve", "return").unwrap();
4690
4691 let content = tempdir().unwrap();
4692 let templates = tempdir().unwrap();
4693 let output = tempdir().unwrap();
4694 let (inv, matches) = Cli::parse_and_dispatch([
4695 "ssg",
4696 "--content",
4697 content.path().to_str().unwrap(),
4698 "--template",
4699 templates.path().to_str().unwrap(),
4700 "--output",
4701 output.path().to_str().unwrap(),
4702 "--serve",
4703 output.path().to_str().unwrap(),
4704 "--quiet",
4705 ])
4706 .unwrap();
4707 let err = dispatch_invocation(inv, &matches).unwrap_err();
4708 assert!(format!("{err:?}").contains("injected: lib::run-on-serve"));
4709 }
4710
4711 #[test]
4712 fn run_subcommand_dev_propagates_injected_on_serve_failure() {
4713 let _g = ssg_check_guard();
4714 let _fp = FailGuard("lib::run-on-serve");
4715 fail::cfg("lib::run-on-serve", "return").unwrap();
4716
4717 let content = tempdir().unwrap();
4718 let templates = tempdir().unwrap();
4719 let output = tempdir().unwrap();
4720 let (inv, matches) = Cli::parse_and_dispatch([
4721 "ssg",
4722 "dev",
4723 "--content",
4724 content.path().to_str().unwrap(),
4725 "--template",
4726 templates.path().to_str().unwrap(),
4727 "--output",
4728 output.path().to_str().unwrap(),
4729 "--quiet",
4730 ])
4731 .unwrap();
4732 let err = dispatch_invocation(inv, &matches).unwrap_err();
4733 assert!(format!("{err:?}").contains("injected: lib::run-on-serve"));
4734 }
4735
4736 #[test]
4741 fn paths_validate_propagates_injected_symlink_metadata_failure() {
4742 let _g = ssg_check_guard();
4743 let _fp = FailGuard("lib::symlink-metadata");
4744 fail::cfg("lib::symlink-metadata", "return").unwrap();
4745
4746 let tmp = tempdir().unwrap();
4747 let paths = Paths {
4748 site: tmp.path().to_path_buf(),
4749 content: tmp.path().to_path_buf(),
4750 build: tmp.path().to_path_buf(),
4751 template: tmp.path().to_path_buf(),
4752 };
4753 let err = paths.validate().unwrap_err();
4754 assert!(
4755 format!("{err:?}").contains("injected: lib::symlink-metadata")
4756 );
4757 }
4758
4759 #[test]
4764 fn create_directories_propagates_injected_is_safe_path_failure() {
4765 let _g = ssg_check_guard();
4766 let _fp = FailGuard("lib::is-safe-path");
4767 fail::cfg("lib::is-safe-path", "return").unwrap();
4768
4769 let tmp = tempdir().unwrap();
4770 let paths = Paths {
4771 site: tmp.path().join("site"),
4772 content: tmp.path().join("content"),
4773 build: tmp.path().join("build"),
4774 template: tmp.path().join("template"),
4775 };
4776 let err = create_directories(&paths).unwrap_err();
4777 assert!(format!("{err:?}").contains("injected: lib::is-safe-path"));
4778 }
4779 }
4780}
4781
4782#[cfg(test)]
4783mod proptests {
4784 use proptest::prelude::*;
4785
4786 proptest! {
4787 #![proptest_config(ProptestConfig::with_cases(1000))]
4788
4789 #[test]
4791 fn parse_frontmatter_never_panics(input in "\\PC*") {
4792 let _ = frontmatter_gen::extract(&input);
4793 }
4794
4795 #[test]
4798 fn compile_markdown_never_panics(input in "\\PC*") {
4799 use pulldown_cmark::{Parser, html};
4800 let parser = Parser::new(&input);
4801 let mut output = String::new();
4802 html::push_html(&mut output, parser);
4803 drop(output);
4806 }
4807
4808 #[test]
4810 fn reading_time_at_least_one(input in ".{1,5000}") {
4811 let word_count = input.split_whitespace().count();
4812 let minutes = (word_count / 200).max(1);
4813 prop_assert!(minutes >= 1, "reading time was {}", minutes);
4814 }
4815 }
4816}