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