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