#![allow(clippy::unwrap_used, clippy::expect_used)]
use anyhow::Result;
use ssg::cache::BuildCache;
use ssg::cmd::{
Cli, CliError, LanguageCode, SsgConfig, DEFAULT_HOST, DEFAULT_PORT,
DEFAULT_SITE_NAME, DEFAULT_SITE_TITLE, MAX_CONFIG_SIZE, RESERVED_NAMES,
};
use ssg::plugin::{Plugin, PluginContext, PluginManager};
use ssg::plugins::{DeployPlugin, ImageOptiPlugin, MinifyPlugin};
use ssg::schema::{generate_schema, write_schema};
use ssg::stream::{
process_batch, stream_copy, stream_hash, stream_lines, BatchResult,
MAX_BATCH_SIZE, STREAM_BUFFER_SIZE,
};
use ssg::watch::{FileWatcher, WatchConfig, MAX_WATCH_ITERATIONS};
use ssg::{
collect_files_recursive, copy_dir_all, copy_dir_with_progress,
create_directories, create_log_file, is_safe_path, log_arguments,
log_initialization, verify_and_copy_files, verify_file_safety, Paths,
MAX_DIR_DEPTH,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tempfile::tempdir;
#[test]
fn paths_default_values() {
let p = Paths::default_paths();
assert_eq!(p.site, PathBuf::from("public"));
assert_eq!(p.content, PathBuf::from("content"));
assert_eq!(p.build, PathBuf::from("build"));
assert_eq!(p.template, PathBuf::from("templates"));
}
#[test]
fn paths_builder_defaults() -> Result<()> {
let p = Paths::builder().build()?;
assert_eq!(p.site, PathBuf::from("public"));
Ok(())
}
#[test]
fn paths_builder_custom() -> Result<()> {
let tmp = tempdir()?;
let p = Paths::builder()
.site(tmp.path().join("s"))
.content(tmp.path().join("c"))
.build_dir(tmp.path().join("b"))
.template(tmp.path().join("t"))
.build()?;
assert_eq!(p.site, tmp.path().join("s"));
assert_eq!(p.template, tmp.path().join("t"));
Ok(())
}
#[test]
fn paths_builder_relative_to() -> Result<()> {
let tmp = tempdir()?;
fs::create_dir_all(tmp.path().join("public"))?;
fs::create_dir_all(tmp.path().join("content"))?;
fs::create_dir_all(tmp.path().join("build"))?;
fs::create_dir_all(tmp.path().join("templates"))?;
let p = Paths::builder().relative_to(tmp.path()).build()?;
assert_eq!(p.site, tmp.path().join("public"));
Ok(())
}
#[test]
fn paths_validate_rejects_traversal() {
let result = Paths::builder().site("../escape").build();
assert!(result.is_err());
}
#[test]
fn paths_validate_rejects_double_slash() {
let result = Paths::builder().site("bad//path").build();
assert!(result.is_err());
}
#[test]
fn is_safe_path_accepts_valid() -> Result<()> {
let tmp = tempdir()?;
let dir = tmp.path().join("safe");
fs::create_dir(&dir)?;
assert!(is_safe_path(&dir.canonicalize()?)?);
Ok(())
}
#[test]
fn is_safe_path_rejects_traversal() -> Result<()> {
assert!(!is_safe_path(Path::new("../../etc"))?);
Ok(())
}
#[test]
fn is_safe_path_nonexistent_safe() -> Result<()> {
assert!(is_safe_path(Path::new("nonexistent_dir"))?);
Ok(())
}
#[test]
fn verify_file_safety_accepts_regular() -> Result<()> {
let tmp = tempdir()?;
let f = tmp.path().join("ok.txt");
fs::write(&f, "safe")?;
verify_file_safety(&f)?;
Ok(())
}
#[test]
fn verify_file_safety_rejects_oversized() -> Result<()> {
let tmp = tempdir()?;
let f = tmp.path().join("big.bin");
let file = fs::File::create(&f)?;
file.set_len(11 * 1024 * 1024)?; assert!(verify_file_safety(&f).is_err());
Ok(())
}
#[cfg(unix)]
#[test]
fn verify_file_safety_rejects_symlink() -> Result<()> {
let tmp = tempdir()?;
let target = tmp.path().join("target.txt");
let link = tmp.path().join("link.txt");
fs::write(&target, "data")?;
std::os::unix::fs::symlink(&target, &link)?;
assert!(verify_file_safety(&link).is_err());
Ok(())
}
#[test]
fn verify_and_copy_files_copies_tree() -> Result<()> {
let tmp = tempdir()?;
let src = tmp.path().join("src");
let dst = tmp.path().join("dst");
fs::create_dir_all(src.join("sub"))?;
fs::write(src.join("a.txt"), "a")?;
fs::write(src.join("sub/b.txt"), "b")?;
verify_and_copy_files(&src, &dst)?;
assert_eq!(fs::read_to_string(dst.join("a.txt"))?, "a");
assert_eq!(fs::read_to_string(dst.join("sub/b.txt"))?, "b");
Ok(())
}
#[test]
fn verify_and_copy_files_rejects_nonexistent() {
let result = verify_and_copy_files(
Path::new("/nonexistent_src"),
Path::new("/tmp/dst"),
);
assert!(result.is_err());
}
#[test]
fn verify_and_copy_files_async_copies() -> Result<()> {
let tmp = tempdir()?;
let src = tmp.path().join("src");
let dst = tmp.path().join("dst");
fs::create_dir_all(&src)?;
fs::write(src.join("x.txt"), "x")?;
ssg::verify_and_copy_files_async(&src, &dst)?;
assert_eq!(fs::read_to_string(dst.join("x.txt"))?, "x");
Ok(())
}
#[test]
fn copy_dir_all_copies_nested() -> Result<()> {
let tmp = tempdir()?;
let src = tmp.path().join("s");
let dst = tmp.path().join("d");
fs::create_dir_all(src.join("a/b"))?;
fs::write(src.join("a/b/c.txt"), "deep")?;
copy_dir_all(&src, &dst)?;
assert_eq!(fs::read_to_string(dst.join("a/b/c.txt"))?, "deep");
Ok(())
}
#[test]
fn copy_dir_with_progress_works() -> Result<()> {
let tmp = tempdir()?;
let src = tmp.path().join("s");
let dst = tmp.path().join("d");
fs::create_dir_all(&src)?;
fs::write(src.join("f.txt"), "data")?;
copy_dir_with_progress(&src, &dst)?;
assert!(dst.join("f.txt").exists());
Ok(())
}
#[test]
fn collect_files_recursive_finds_all() -> Result<()> {
let tmp = tempdir()?;
fs::create_dir_all(tmp.path().join("sub"))?;
fs::write(tmp.path().join("a.txt"), "")?;
fs::write(tmp.path().join("sub/b.txt"), "")?;
let mut files = Vec::new();
collect_files_recursive(tmp.path(), &mut files)?;
assert_eq!(files.len(), 2);
Ok(())
}
#[test]
fn create_directories_creates_all() -> Result<()> {
let tmp = tempdir()?;
let paths = Paths {
site: tmp.path().join("site"),
content: tmp.path().join("content"),
build: tmp.path().join("build"),
template: tmp.path().join("template"),
};
create_directories(&paths)?;
assert!(paths.site.exists());
assert!(paths.content.exists());
assert!(paths.build.exists());
assert!(paths.template.exists());
Ok(())
}
#[test]
fn create_log_file_creates() -> Result<()> {
let tmp = tempdir()?;
let path = tmp.path().join("test.log");
let f = create_log_file(path.to_str().unwrap())?;
assert!(f.metadata()?.is_file());
Ok(())
}
#[test]
fn log_initialization_writes() -> Result<()> {
let tmp = tempdir()?;
let path = tmp.path().join("init.log");
let mut f = fs::File::create(&path)?;
let date = ssg::now_iso();
log_initialization(&mut f, &date)?;
let content = fs::read_to_string(&path)?;
assert!(content.contains("INFO"));
Ok(())
}
#[test]
fn log_arguments_writes() -> Result<()> {
let tmp = tempdir()?;
let path = tmp.path().join("args.log");
let mut f = fs::File::create(&path)?;
let date = ssg::now_iso();
log_arguments(&mut f, &date)?;
let content = fs::read_to_string(&path)?;
assert!(content.contains("process"));
Ok(())
}
#[test]
fn max_dir_depth_is_128() {
assert_eq!(MAX_DIR_DEPTH, 128);
}
#[test]
fn ssg_config_default_has_site_name() {
let config = SsgConfig::default();
assert_eq!(config.site_name, DEFAULT_SITE_NAME);
assert_eq!(config.site_title, DEFAULT_SITE_TITLE);
}
#[test]
fn ssg_config_builder_validates() {
let result = SsgConfig::builder().site_name(String::new()).build();
assert!(matches!(result, Err(CliError::ValidationError(_))));
}
#[test]
fn ssg_config_builder_builds_valid() -> Result<(), CliError> {
let config = SsgConfig::builder()
.site_name("test".to_string())
.base_url("http://example.com".to_string())
.build()?;
assert_eq!(config.site_name, "test");
Ok(())
}
#[test]
fn ssg_config_from_toml_string() {
let toml = r#"
site_name = "parsed"
content_dir = "content"
output_dir = "public"
template_dir = "templates"
base_url = "http://example.com"
site_title = "Parsed"
site_description = "From TOML"
language = "en-GB"
"#;
let config: SsgConfig = toml.parse().unwrap();
assert_eq!(config.site_name, "parsed");
}
#[test]
fn ssg_config_from_file() -> Result<(), Box<dyn std::error::Error>> {
let tmp = tempdir()?;
let path = tmp.path().join("config.toml");
fs::write(
&path,
r#"
site_name = "file"
content_dir = "c"
output_dir = "o"
template_dir = "t"
base_url = "http://example.com"
site_title = "File"
site_description = "From file"
language = "en-GB"
"#,
)?;
let config = SsgConfig::from_file(&path)?;
assert_eq!(config.site_name, "file");
Ok(())
}
#[test]
fn ssg_config_rejects_oversized_file() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("big.toml");
fs::write(&path, "x".repeat(MAX_CONFIG_SIZE + 1)).unwrap();
assert!(matches!(
SsgConfig::from_file(&path),
Err(CliError::ValidationError(_))
));
}
#[test]
fn validate_url_accepts_http() {
assert!(ssg::cmd::validate_url("http://example.com").is_ok());
}
#[test]
fn validate_url_accepts_https() {
assert!(ssg::cmd::validate_url("https://example.com").is_ok());
}
#[test]
fn validate_url_rejects_javascript() {
assert!(ssg::cmd::validate_url("javascript:alert(1)").is_err());
}
#[test]
fn validate_url_rejects_ftp() {
assert!(ssg::cmd::validate_url("ftp://example.com").is_err());
}
#[test]
fn validate_url_rejects_angle_brackets() {
assert!(ssg::cmd::validate_url("http://a.com<script>").is_err());
}
#[test]
fn language_code_valid() {
assert!(LanguageCode::new("en-GB").is_ok());
assert!(LanguageCode::new("fr-FR").is_ok());
}
#[test]
fn language_code_invalid_format() {
assert!(LanguageCode::new("english").is_err());
assert!(LanguageCode::new("EN-gb").is_err());
assert!(LanguageCode::new("e-GB").is_err());
}
#[test]
fn language_code_display() {
let code = LanguageCode::new("en-GB").unwrap();
assert_eq!(format!("{code}"), "en-GB");
}
#[test]
fn cli_build_creates_command() {
let cmd = Cli::build();
assert_eq!(cmd.get_name(), "ssg");
}
#[test]
fn cli_constants() {
assert_eq!(DEFAULT_PORT, 8000);
assert_eq!(DEFAULT_HOST, "127.0.0.1");
assert!(RESERVED_NAMES.contains(&"con"));
assert!(RESERVED_NAMES.contains(&"nul"));
}
#[derive(Debug)]
struct TestPlugin {
name: &'static str,
}
impl Plugin for TestPlugin {
fn name(&self) -> &str {
self.name
}
fn after_compile(&self, _ctx: &PluginContext) -> Result<()> {
Ok(())
}
}
#[test]
fn plugin_manager_register_and_run() -> Result<()> {
let mut pm = PluginManager::new();
pm.register(TestPlugin { name: "test" });
assert_eq!(pm.len(), 1);
assert!(!pm.is_empty());
assert_eq!(pm.names(), vec!["test"]);
let ctx = PluginContext::new(
Path::new("c"),
Path::new("b"),
Path::new("s"),
Path::new("t"),
);
pm.run_before_compile(&ctx)?;
pm.run_after_compile(&ctx)?;
pm.run_on_serve(&ctx)?;
Ok(())
}
#[test]
fn plugin_manager_empty_runs_succeed() -> Result<()> {
let pm = PluginManager::new();
let ctx = PluginContext::new(
Path::new("c"),
Path::new("b"),
Path::new("s"),
Path::new("t"),
);
pm.run_before_compile(&ctx)?;
pm.run_after_compile(&ctx)?;
pm.run_on_serve(&ctx)?;
Ok(())
}
#[test]
fn plugin_context_fields() {
let ctx = PluginContext::new(
Path::new("/a"),
Path::new("/b"),
Path::new("/c"),
Path::new("/d"),
);
assert_eq!(ctx.content_dir, PathBuf::from("/a"));
assert_eq!(ctx.build_dir, PathBuf::from("/b"));
assert_eq!(ctx.site_dir, PathBuf::from("/c"));
assert_eq!(ctx.template_dir, PathBuf::from("/d"));
}
#[test]
fn minify_plugin_processes_html() -> Result<()> {
let tmp = tempdir()?;
fs::write(tmp.path().join("page.html"), "<p> spaced </p>")?;
let ctx = PluginContext::new(
Path::new("c"),
Path::new("b"),
tmp.path(),
Path::new("t"),
);
MinifyPlugin.after_compile(&ctx)?;
let content = fs::read_to_string(tmp.path().join("page.html"))?;
assert!(!content.contains(" "));
Ok(())
}
#[test]
fn image_opti_plugin_scans_images() -> Result<()> {
let tmp = tempdir()?;
fs::write(tmp.path().join("photo.png"), "PNG")?;
let ctx = PluginContext::new(
Path::new("c"),
Path::new("b"),
tmp.path(),
Path::new("t"),
);
ImageOptiPlugin.after_compile(&ctx)?;
Ok(())
}
#[test]
fn deploy_plugin_runs() -> Result<()> {
let tmp = tempdir()?;
let ctx = PluginContext::new(
Path::new("c"),
Path::new("b"),
tmp.path(),
Path::new("t"),
);
let p = DeployPlugin::new("staging");
p.after_compile(&ctx)?;
Ok(())
}
#[test]
fn all_builtin_plugins_register() {
let mut pm = PluginManager::new();
pm.register(MinifyPlugin);
pm.register(ImageOptiPlugin);
pm.register(DeployPlugin::new("prod"));
assert_eq!(pm.len(), 3);
assert_eq!(pm.names(), vec!["minify", "image-opti", "deploy"]);
}
#[test]
fn build_cache_load_missing_returns_empty() -> Result<()> {
let tmp = tempdir()?;
let cache = BuildCache::load(&tmp.path().join("missing.json"))?;
assert!(cache.is_empty());
Ok(())
}
#[test]
fn build_cache_save_and_load_roundtrip() -> Result<()> {
let tmp = tempdir()?;
let cache_path = tmp.path().join("cache.json");
let content_dir = tmp.path().join("content");
fs::create_dir_all(&content_dir)?;
fs::write(content_dir.join("a.md"), "# Hello")?;
let mut cache = BuildCache::new(&cache_path);
cache.update(&content_dir)?;
cache.save()?;
let loaded = BuildCache::load(&cache_path)?;
assert_eq!(loaded.len(), 1);
Ok(())
}
#[test]
fn build_cache_detects_changes() -> Result<()> {
let tmp = tempdir()?;
let cache_path = tmp.path().join("cache.json");
let content_dir = tmp.path().join("content");
fs::create_dir_all(&content_dir)?;
fs::write(content_dir.join("a.md"), "original")?;
let mut cache = BuildCache::new(&cache_path);
cache.update(&content_dir)?;
fs::write(content_dir.join("a.md"), "modified")?;
let changed = cache.changed_files(&content_dir)?;
assert_eq!(changed.len(), 1);
Ok(())
}
#[test]
fn build_cache_detects_no_changes() -> Result<()> {
let tmp = tempdir()?;
let cache_path = tmp.path().join("cache.json");
let content_dir = tmp.path().join("content");
fs::create_dir_all(&content_dir)?;
fs::write(content_dir.join("a.md"), "stable")?;
let mut cache = BuildCache::new(&cache_path);
cache.update(&content_dir)?;
let changed = cache.changed_files(&content_dir)?;
assert!(changed.is_empty());
Ok(())
}
#[test]
fn build_cache_detects_new_files() -> Result<()> {
let tmp = tempdir()?;
let cache_path = tmp.path().join("cache.json");
let content_dir = tmp.path().join("content");
fs::create_dir_all(&content_dir)?;
let cache = BuildCache::new(&cache_path);
fs::write(content_dir.join("new.md"), "new")?;
let changed = cache.changed_files(&content_dir)?;
assert_eq!(changed.len(), 1);
Ok(())
}
#[test]
fn stream_copy_preserves_content() -> Result<()> {
let tmp = tempdir()?;
let src = tmp.path().join("src.bin");
let dst = tmp.path().join("dst.bin");
let data = vec![42u8; 100_000]; fs::write(&src, &data)?;
let bytes = stream_copy(&src, &dst)?;
assert_eq!(bytes, 100_000);
assert_eq!(fs::read(&dst)?, data);
Ok(())
}
#[test]
fn stream_hash_deterministic() -> Result<()> {
let tmp = tempdir()?;
let path = tmp.path().join("f.txt");
fs::write(&path, "consistent")?;
let h1 = stream_hash(&path)?;
let h2 = stream_hash(&path)?;
assert_eq!(h1, h2);
assert_eq!(h1.len(), 16);
Ok(())
}
#[test]
fn stream_hash_differs_for_different_content() -> Result<()> {
let tmp = tempdir()?;
let a = tmp.path().join("a.txt");
let b = tmp.path().join("b.txt");
fs::write(&a, "alpha")?;
fs::write(&b, "beta")?;
assert_ne!(stream_hash(&a)?, stream_hash(&b)?);
Ok(())
}
#[test]
fn process_batch_copies_all_files() -> Result<()> {
let tmp = tempdir()?;
let src = tmp.path().join("in");
let dst = tmp.path().join("out");
fs::create_dir_all(&src)?;
for i in 0..20 {
fs::write(src.join(format!("f{i}.txt")), format!("data{i}"))?;
}
let result = process_batch(&src, &dst, stream_copy)?;
assert_eq!(result.files_processed, 20);
assert!(result.throughput > 0.0);
Ok(())
}
#[test]
fn stream_lines_processes_each_line() -> Result<()> {
let tmp = tempdir()?;
let path = tmp.path().join("lines.txt");
fs::write(&path, "one\ntwo\nthree")?;
let mut lines = Vec::new();
let _ = stream_lines(&path, |_i, line| {
lines.push(line.to_string());
Ok(())
})?;
assert_eq!(lines, vec!["one", "two", "three"]);
Ok(())
}
#[test]
fn stream_constants() {
assert_eq!(STREAM_BUFFER_SIZE, 8192);
assert_eq!(MAX_BATCH_SIZE, 100_000);
}
#[test]
fn batch_result_fields() {
let r = BatchResult {
files_processed: 5,
bytes_read: 500,
bytes_written: 450,
duration_ms: 1.0,
throughput: 5000.0,
};
assert_eq!(r.files_processed, 5);
assert_eq!(r.bytes_read, 500);
}
#[test]
fn watch_config_accessors() {
let cfg =
WatchConfig::new(PathBuf::from("content"), Duration::from_secs(2));
assert_eq!(cfg.directory(), Path::new("content"));
assert_eq!(cfg.poll_interval(), Duration::from_secs(2));
}
#[test]
fn file_watcher_initial_snapshot() -> Result<()> {
let tmp = tempdir()?;
fs::write(tmp.path().join("a.txt"), "a")?;
fs::write(tmp.path().join("b.txt"), "b")?;
let cfg = WatchConfig::new(tmp.path().into(), Duration::from_millis(10));
let watcher = FileWatcher::new(cfg)?;
assert_eq!(watcher.tracked_file_count(), 2);
Ok(())
}
#[test]
fn file_watcher_detects_new_file() -> Result<()> {
let tmp = tempdir()?;
let cfg = WatchConfig::new(tmp.path().into(), Duration::from_millis(10));
let mut watcher = FileWatcher::new(cfg)?;
fs::write(tmp.path().join("new.txt"), "new")?;
let changes = watcher.check_for_changes()?;
assert!(!changes.is_empty());
Ok(())
}
#[test]
fn file_watcher_no_changes() -> Result<()> {
let tmp = tempdir()?;
fs::write(tmp.path().join("stable.txt"), "stable")?;
let cfg = WatchConfig::new(tmp.path().into(), Duration::from_millis(10));
let mut watcher = FileWatcher::new(cfg)?;
let changes = watcher.check_for_changes()?;
assert!(changes.is_empty());
Ok(())
}
#[test]
fn watch_constants() {
assert_eq!(MAX_WATCH_ITERATIONS, 1_000_000);
}
#[test]
fn schema_has_all_config_fields() {
let schema = generate_schema();
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("site_name"));
assert!(props.contains_key("content_dir"));
assert!(props.contains_key("output_dir"));
assert!(props.contains_key("template_dir"));
assert!(props.contains_key("serve_dir"));
assert!(props.contains_key("base_url"));
assert!(props.contains_key("site_title"));
assert!(props.contains_key("site_description"));
assert!(props.contains_key("language"));
}
#[test]
fn schema_write_creates_file() -> Result<()> {
let tmp = tempdir()?;
let path = tmp.path().join("schema.json");
write_schema(&path)?;
assert!(path.exists());
let content = fs::read_to_string(&path)?;
assert!(content.contains("site_name"));
Ok(())
}
#[test]
fn schema_language_has_pattern() {
let schema = generate_schema();
let lang = &schema["properties"]["language"];
assert!(lang["pattern"].as_str().is_some());
}
#[test]
fn process_error_display() {
use ssg::process::ProcessError;
let err = ProcessError::MissingArgument("test".into());
assert!(format!("{err}").contains("test"));
}
#[test]
fn get_argument_returns_value() {
use clap::{arg, Command};
use ssg::process::get_argument;
let matches = Command::new("t")
.arg(arg!(--"name" <NAME> "Name"))
.get_matches_from(vec!["t", "--name", "val"]);
assert_eq!(get_argument(&matches, "name").unwrap(), "val");
}
#[test]
fn get_argument_missing_errors() {
use clap::{arg, Command};
use ssg::process::get_argument;
let matches = Command::new("t")
.arg(arg!(--"name" <NAME> "Name").required(false))
.get_matches_from(vec!["t"]);
assert!(get_argument(&matches, "name").is_err());
}
#[test]
fn ensure_directory_creates() -> Result<()> {
use ssg::process::ensure_directory;
let tmp = tempdir()?;
let dir = tmp.path().join("new_dir");
ensure_directory(&dir, "test")?;
assert!(dir.exists());
Ok(())
}
#[test]
fn e2e_cache_then_copy_pipeline() -> Result<()> {
let tmp = tempdir()?;
let content = tmp.path().join("content");
let build = tmp.path().join("build");
let cache_path = tmp.path().join(".ssg-cache.json");
fs::create_dir_all(&content)?;
fs::write(content.join("page.md"), "# Hello")?;
let mut cache = BuildCache::load(&cache_path)?;
let changed = cache.changed_files(&content)?;
assert_eq!(changed.len(), 1);
fs::create_dir_all(&build)?;
for src in &changed {
let name = src.file_name().unwrap();
let _ = stream_copy(src, &build.join(name))?;
}
assert!(build.join("page.md").exists());
cache.update(&content)?;
cache.save()?;
let cache2 = BuildCache::load(&cache_path)?;
let changed2 = cache2.changed_files(&content)?;
assert!(changed2.is_empty());
Ok(())
}
#[test]
fn e2e_plugin_pipeline() -> Result<()> {
let tmp = tempdir()?;
let site = tmp.path().join("site");
fs::create_dir_all(&site)?;
fs::write(site.join("index.html"), "<h1> Hello World </h1>")?;
let mut pm = PluginManager::new();
pm.register(MinifyPlugin);
pm.register(ImageOptiPlugin);
pm.register(DeployPlugin::new("test"));
let ctx = PluginContext::new(
Path::new("content"),
Path::new("build"),
&site,
Path::new("templates"),
);
pm.run_before_compile(&ctx)?;
pm.run_after_compile(&ctx)?;
pm.run_on_serve(&ctx)?;
let html = fs::read_to_string(site.join("index.html"))?;
assert!(!html.contains(" "));
Ok(())
}
#[test]
fn e2e_stream_batch_with_hash_verification() -> Result<()> {
let tmp = tempdir()?;
let src = tmp.path().join("src");
let dst = tmp.path().join("dst");
fs::create_dir_all(&src)?;
for i in 0..50 {
fs::write(src.join(format!("f{i}.txt")), format!("content-{i}"))?;
}
let result = process_batch(&src, &dst, stream_copy)?;
assert_eq!(result.files_processed, 50);
for i in 0..50 {
let src_hash = stream_hash(&src.join(format!("f{i}.txt")))?;
let dst_hash = stream_hash(&dst.join(format!("f{i}.txt")))?;
assert_eq!(src_hash, dst_hash, "hash mismatch for f{i}.txt");
}
Ok(())
}
#[test]
fn e2e_full_directory_lifecycle() -> Result<()> {
let tmp = tempdir()?;
let paths = Paths {
site: tmp.path().join("public"),
content: tmp.path().join("content"),
build: tmp.path().join("build"),
template: tmp.path().join("templates"),
};
create_directories(&paths)?;
fs::write(paths.content.join("index.md"), "# Home")?;
fs::write(paths.content.join("about.md"), "# About")?;
let mut files = Vec::new();
collect_files_recursive(&paths.content, &mut files)?;
assert_eq!(files.len(), 2);
verify_and_copy_files(&paths.content, &paths.build)?;
assert!(paths.build.join("index.md").exists());
assert!(paths.build.join("about.md").exists());
let mut log =
create_log_file(tmp.path().join("build.log").to_str().unwrap())?;
let date = ssg::now_iso();
log_initialization(&mut log, &date)?;
log_arguments(&mut log, &date)?;
Ok(())
}