Skip to main content

ssg/core/
streaming.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Streaming compilation for large sites.
5//!
6//! Processes content files in batches to cap peak memory usage, enabling
7//! compilation of 100K+ page sites within a configurable memory budget.
8//!
9//! The streaming compiler divides content files into chunks based on the
10//! memory budget, compiles each chunk, then releases it before processing
11//! the next. After all chunks, a merge pass unifies cross-page artefacts
12//! (sitemap, search index, feeds).
13
14use crate::error::{PathErrorExt, SsgError};
15use crate::walk;
16use std::{
17    fs,
18    path::{Path, PathBuf},
19};
20
21/// Default peak memory budget: 512 MB.
22pub const DEFAULT_MEMORY_BUDGET_MB: usize = 512;
23
24/// Estimated memory per page in bytes (HTML + metadata + buffers).
25/// Conservative estimate for batch sizing.
26const ESTIMATED_BYTES_PER_PAGE: usize = 64 * 1024; // 64 KB
27
28/// Memory budget configuration for streaming compilation.
29#[derive(Debug, Clone, Copy)]
30pub struct MemoryBudget {
31    /// Maximum memory in bytes.
32    pub max_bytes: usize,
33    /// Pages per batch, derived from `max_bytes`.
34    pub batch_size: usize,
35}
36
37impl MemoryBudget {
38    /// Creates a memory budget from a megabyte limit.
39    ///
40    /// # Examples
41    ///
42    /// ```rust
43    /// use ssg::streaming::MemoryBudget;
44    ///
45    /// let b = MemoryBudget::from_mb(64);
46    /// assert_eq!(b.max_bytes, 64 * 1024 * 1024);
47    /// assert!(b.batch_size >= 10);
48    /// ```
49    #[must_use]
50    pub fn from_mb(mb: usize) -> Self {
51        let max_bytes = mb * 1024 * 1024;
52        let batch_size = (max_bytes / ESTIMATED_BYTES_PER_PAGE).max(10);
53        Self {
54            max_bytes,
55            batch_size,
56        }
57    }
58
59    /// Creates the default 512 MB budget.
60    ///
61    /// # Examples
62    ///
63    /// ```rust
64    /// use ssg::streaming::MemoryBudget;
65    ///
66    /// let b = MemoryBudget::default_budget();
67    /// assert_eq!(b.max_bytes, 512 * 1024 * 1024);
68    /// ```
69    #[must_use]
70    pub fn default_budget() -> Self {
71        Self::from_mb(DEFAULT_MEMORY_BUDGET_MB)
72    }
73}
74
75/// Collects content files and returns them as batches.
76///
77/// Each batch contains at most `budget.batch_size` files.
78///
79/// # Examples
80///
81/// ```rust
82/// use ssg::streaming::{batched_content_files, MemoryBudget};
83/// use tempfile::tempdir;
84/// use std::fs;
85///
86/// let dir = tempdir().unwrap();
87/// fs::write(dir.path().join("a.md"), "x").unwrap();
88/// let budget = MemoryBudget { max_bytes: 0, batch_size: 10 };
89/// let batches = batched_content_files(dir.path(), &budget).unwrap();
90/// assert_eq!(batches.len(), 1);
91/// ```
92pub fn batched_content_files(
93    content_dir: &Path,
94    budget: &MemoryBudget,
95) -> Result<Vec<Vec<PathBuf>>, SsgError> {
96    let all_files = walk::walk_files(content_dir, "md")?;
97
98    if all_files.is_empty() {
99        return Ok(vec![]);
100    }
101
102    let batches: Vec<Vec<PathBuf>> = all_files
103        .chunks(budget.batch_size)
104        .map(|chunk| chunk.to_vec())
105        .collect();
106
107    log::info!(
108        "[streaming] {} file(s) in {} batch(es) (budget: {} MB, {} pages/batch)",
109        all_files.len(),
110        batches.len(),
111        budget.max_bytes / (1024 * 1024),
112        budget.batch_size,
113    );
114
115    Ok(batches)
116}
117
118/// Compiles a single batch of content files into the build directory.
119///
120/// Creates a temporary content directory containing only the batch files,
121/// runs `staticdatagen::compile` on it, then merges the output into the
122/// final site directory.
123///
124/// # Examples
125///
126/// ```rust
127/// use ssg::streaming::compile_batch;
128/// use tempfile::tempdir;
129///
130/// let dir = tempdir().unwrap();
131/// // Empty batch is a no-op: returns Ok immediately.
132/// assert!(compile_batch(&[], dir.path(), dir.path(), dir.path(), dir.path(), 0).is_ok());
133/// ```
134pub fn compile_batch(
135    batch: &[PathBuf],
136    content_dir: &Path,
137    build_dir: &Path,
138    site_dir: &Path,
139    template_dir: &Path,
140    batch_idx: usize,
141) -> Result<(), SsgError> {
142    if batch.is_empty() {
143        return Ok(());
144    }
145
146    // Create a temporary batch content directory
147    let batch_content = build_dir.join(format!(".batch-{batch_idx}"));
148    fs::create_dir_all(&batch_content).with_path(&batch_content)?;
149
150    // Copy batch files preserving directory structure
151    for file in batch {
152        let rel = file.strip_prefix(content_dir).unwrap_or(file);
153        let dest = batch_content.join(rel);
154        if let Some(parent) = dest.parent() {
155            fs::create_dir_all(parent).with_path(parent)?;
156        }
157        let _ = fs::copy(file, &dest).with_path(&dest)?;
158    }
159
160    // Compile the batch
161    let batch_build = build_dir.join(format!(".batch-{batch_idx}-build"));
162    let batch_site = build_dir.join(format!(".batch-{batch_idx}-site"));
163    fs::create_dir_all(&batch_build).with_path(&batch_build)?;
164    fs::create_dir_all(&batch_site).with_path(&batch_site)?;
165
166    let compile_result = staticdatagen::compile(
167        &batch_build,
168        &batch_content,
169        &batch_site,
170        template_dir,
171    );
172
173    // Merge batch output into the main site directory
174    if compile_result.is_ok() {
175        fs::create_dir_all(site_dir).with_path(site_dir)?;
176        merge_dir(&batch_site, site_dir)?;
177    }
178
179    // Clean up batch temporaries
180    let _ = fs::remove_dir_all(&batch_content);
181    let _ = fs::remove_dir_all(&batch_build);
182    let _ = fs::remove_dir_all(&batch_site);
183
184    compile_result.map_err(|e| {
185        SsgError::io(
186            std::io::Error::other(format!("batch {batch_idx}: {e:?}")),
187            build_dir,
188        )
189    })
190}
191
192/// Recursively merges files from `src` into `dst`, overwriting on conflict.
193fn merge_dir(src: &Path, dst: &Path) -> Result<(), SsgError> {
194    if !src.exists() {
195        return Ok(());
196    }
197
198    for entry in fs::read_dir(src).with_path(src)? {
199        let entry = entry.with_path(src)?;
200        let path = entry.path();
201        let dest = dst.join(entry.file_name());
202
203        if path.is_dir() {
204            fs::create_dir_all(&dest).with_path(&dest)?;
205            merge_dir(&path, &dest)?;
206        } else {
207            let _ = fs::copy(&path, &dest).with_path(&dest)?;
208        }
209    }
210    Ok(())
211}
212
213/// Determines whether streaming compilation should be used.
214///
215/// Returns `true` if the content directory has more files than a single
216/// batch can hold, or if `--max-memory` was explicitly set.
217///
218/// # Examples
219///
220/// ```rust
221/// use ssg::streaming::{should_stream, MemoryBudget};
222/// use tempfile::tempdir;
223///
224/// let dir = tempdir().unwrap();
225/// let budget = MemoryBudget::default_budget();
226/// // Explicitly set ⇒ always stream.
227/// assert!(should_stream(dir.path(), &budget, true));
228/// ```
229#[must_use]
230pub fn should_stream(
231    content_dir: &Path,
232    budget: &MemoryBudget,
233    explicitly_set: bool,
234) -> bool {
235    if explicitly_set {
236        return true;
237    }
238
239    let count = walk::walk_files(content_dir, "md").map_or(0, |f| f.len());
240
241    count > budget.batch_size
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use tempfile::tempdir;
248
249    #[test]
250    fn memory_budget_from_mb() {
251        let budget = MemoryBudget::from_mb(256);
252        assert_eq!(budget.max_bytes, 256 * 1024 * 1024);
253        assert!(budget.batch_size > 0);
254    }
255
256    #[test]
257    fn memory_budget_default() {
258        let budget = MemoryBudget::default_budget();
259        assert_eq!(budget.max_bytes, 512 * 1024 * 1024);
260    }
261
262    #[test]
263    fn memory_budget_minimum_batch_size() {
264        let budget = MemoryBudget::from_mb(0);
265        assert!(
266            budget.batch_size >= 10,
267            "batch size should have a floor of 10"
268        );
269    }
270
271    #[test]
272    fn batched_content_files_empty_dir() {
273        let dir = tempdir().unwrap();
274        let content = dir.path().join("content");
275        fs::create_dir_all(&content).unwrap();
276
277        let budget = MemoryBudget::from_mb(512);
278        let batches = batched_content_files(&content, &budget).unwrap();
279        assert!(batches.is_empty());
280    }
281
282    #[test]
283    fn batched_content_files_splits_correctly() {
284        let dir = tempdir().unwrap();
285        let content = dir.path().join("content");
286        fs::create_dir_all(&content).unwrap();
287
288        for i in 0..25 {
289            fs::write(
290                content.join(format!("page{i}.md")),
291                format!("# Page {i}"),
292            )
293            .unwrap();
294        }
295
296        let budget = MemoryBudget {
297            max_bytes: 0,
298            batch_size: 10,
299        };
300        let batches = batched_content_files(&content, &budget).unwrap();
301
302        assert_eq!(batches.len(), 3); // 10 + 10 + 5
303        assert_eq!(batches[0].len(), 10);
304        assert_eq!(batches[1].len(), 10);
305        assert_eq!(batches[2].len(), 5);
306    }
307
308    #[test]
309    fn merge_dir_combines_files() {
310        let dir = tempdir().unwrap();
311        let src = dir.path().join("src");
312        let dst = dir.path().join("dst");
313        fs::create_dir_all(&src).unwrap();
314        fs::create_dir_all(&dst).unwrap();
315
316        fs::write(src.join("a.html"), "from src").unwrap();
317        fs::write(dst.join("b.html"), "existing").unwrap();
318
319        merge_dir(&src, &dst).unwrap();
320
321        assert_eq!(fs::read_to_string(dst.join("a.html")).unwrap(), "from src");
322        assert_eq!(fs::read_to_string(dst.join("b.html")).unwrap(), "existing");
323    }
324
325    #[test]
326    fn merge_dir_overwrites_on_conflict() {
327        let dir = tempdir().unwrap();
328        let src = dir.path().join("src");
329        let dst = dir.path().join("dst");
330        fs::create_dir_all(&src).unwrap();
331        fs::create_dir_all(&dst).unwrap();
332
333        fs::write(src.join("a.html"), "new").unwrap();
334        fs::write(dst.join("a.html"), "old").unwrap();
335
336        merge_dir(&src, &dst).unwrap();
337
338        assert_eq!(fs::read_to_string(dst.join("a.html")).unwrap(), "new");
339    }
340
341    #[test]
342    fn should_stream_when_explicitly_set() {
343        let dir = tempdir().unwrap();
344        let content = dir.path().join("content");
345        fs::create_dir_all(&content).unwrap();
346
347        let budget = MemoryBudget::default_budget();
348        assert!(should_stream(&content, &budget, true));
349    }
350
351    #[test]
352    fn compile_batch_empty_is_noop() {
353        let dir = tempdir().unwrap();
354        let result = compile_batch(
355            &[],
356            dir.path(),
357            &dir.path().join("build"),
358            &dir.path().join("site"),
359            &dir.path().join("templates"),
360            0,
361        );
362        assert!(result.is_ok());
363    }
364
365    #[test]
366    fn merge_dir_nonexistent_src_is_noop() {
367        let dir = tempdir().unwrap();
368        let result =
369            merge_dir(&dir.path().join("nonexistent"), &dir.path().join("dst"));
370        assert!(result.is_ok());
371    }
372
373    #[test]
374    fn merge_dir_nested() {
375        let dir = tempdir().unwrap();
376        let src = dir.path().join("src");
377        let dst = dir.path().join("dst");
378        let nested = src.join("sub");
379        fs::create_dir_all(&nested).unwrap();
380        fs::create_dir_all(&dst).unwrap();
381        fs::write(nested.join("file.txt"), "nested").unwrap();
382
383        merge_dir(&src, &dst).unwrap();
384        assert_eq!(
385            fs::read_to_string(dst.join("sub/file.txt")).unwrap(),
386            "nested"
387        );
388    }
389
390    #[test]
391    fn should_stream_large_site() {
392        let dir = tempdir().unwrap();
393        let content = dir.path().join("content");
394        fs::create_dir_all(&content).unwrap();
395        // Create more files than default batch size (8192)
396        // Use a tiny budget instead
397        let budget = MemoryBudget {
398            max_bytes: 0,
399            batch_size: 2,
400        };
401        for i in 0..5 {
402            fs::write(content.join(format!("p{i}.md")), "# Hi").unwrap();
403        }
404        assert!(should_stream(&content, &budget, false));
405    }
406
407    #[test]
408    fn should_not_stream_small_site() {
409        let dir = tempdir().unwrap();
410        let content = dir.path().join("content");
411        fs::create_dir_all(&content).unwrap();
412        fs::write(content.join("index.md"), "# Home").unwrap();
413
414        let budget = MemoryBudget::default_budget();
415        assert!(!should_stream(&content, &budget, false));
416    }
417
418    // -----------------------------------------------------------------
419    // MemoryBudget — edge cases
420    // -----------------------------------------------------------------
421
422    #[test]
423    fn memory_budget_from_mb_one() {
424        let budget = MemoryBudget::from_mb(1);
425        assert_eq!(budget.max_bytes, 1024 * 1024);
426        // 1 MB / 64 KB = 16 pages per batch
427        assert_eq!(budget.batch_size, 16);
428    }
429
430    #[test]
431    fn memory_budget_from_mb_very_large() {
432        let budget = MemoryBudget::from_mb(4096);
433        assert_eq!(budget.max_bytes, 4096 * 1024 * 1024);
434        // 4 GB / 64 KB = 65536 pages per batch
435        assert_eq!(budget.batch_size, 65536);
436    }
437
438    #[test]
439    fn memory_budget_batch_size_floor_is_ten() {
440        // Even with 0 MB, the floor ensures at least 10 pages/batch
441        let budget = MemoryBudget::from_mb(0);
442        assert_eq!(budget.max_bytes, 0);
443        assert_eq!(budget.batch_size, 10);
444    }
445
446    #[test]
447    fn memory_budget_default_budget_matches_constant() {
448        let budget = MemoryBudget::default_budget();
449        assert_eq!(budget.max_bytes, DEFAULT_MEMORY_BUDGET_MB * 1024 * 1024);
450        assert_eq!(
451            budget.batch_size,
452            MemoryBudget::from_mb(DEFAULT_MEMORY_BUDGET_MB).batch_size
453        );
454    }
455
456    #[test]
457    fn memory_budget_clone_copy_debug() {
458        let a = MemoryBudget::from_mb(128);
459        let b = a; // Copy
460        assert_eq!(a.max_bytes, b.max_bytes);
461        assert_eq!(a.batch_size, b.batch_size);
462        let debug = format!("{a:?}");
463        assert!(debug.contains("MemoryBudget"));
464    }
465
466    // -----------------------------------------------------------------
467    // batched_content_files — additional scenarios
468    // -----------------------------------------------------------------
469
470    #[test]
471    fn batched_content_files_nonexistent_dir_returns_empty() {
472        let dir = tempdir().unwrap();
473        let budget = MemoryBudget::from_mb(512);
474        let result =
475            batched_content_files(&dir.path().join("nonexistent"), &budget);
476        // walk_files treats a missing dir as empty, so batched returns
477        // Ok([]) — asserted without a conditional so no dead branch.
478        let batches = result.unwrap_or_default();
479        assert!(batches.is_empty());
480    }
481
482    #[test]
483    fn batched_content_files_single_file() {
484        let dir = tempdir().unwrap();
485        let content = dir.path().join("content");
486        fs::create_dir_all(&content).unwrap();
487        fs::write(content.join("index.md"), "# Home").unwrap();
488
489        let budget = MemoryBudget::from_mb(512);
490        let batches = batched_content_files(&content, &budget).unwrap();
491        assert_eq!(batches.len(), 1);
492        assert_eq!(batches[0].len(), 1);
493    }
494
495    #[test]
496    fn batched_content_files_ignores_non_md() {
497        let dir = tempdir().unwrap();
498        let content = dir.path().join("content");
499        fs::create_dir_all(&content).unwrap();
500        fs::write(content.join("page.md"), "# Page").unwrap();
501        fs::write(content.join("image.png"), "fakepng").unwrap();
502        fs::write(content.join("style.css"), "body{}").unwrap();
503
504        let budget = MemoryBudget::from_mb(512);
505        let batches = batched_content_files(&content, &budget).unwrap();
506        let total: usize = batches.iter().map(|b| b.len()).sum();
507        assert_eq!(total, 1, "only .md files should be collected");
508    }
509
510    #[test]
511    fn batched_content_files_exact_batch_boundary() {
512        let dir = tempdir().unwrap();
513        let content = dir.path().join("content");
514        fs::create_dir_all(&content).unwrap();
515        for i in 0..10 {
516            fs::write(content.join(format!("p{i}.md")), "# Hi").unwrap();
517        }
518
519        let budget = MemoryBudget {
520            max_bytes: 0,
521            batch_size: 10,
522        };
523        let batches = batched_content_files(&content, &budget).unwrap();
524        assert_eq!(batches.len(), 1);
525        assert_eq!(batches[0].len(), 10);
526    }
527
528    #[test]
529    fn batched_content_files_many_small_batches() {
530        let dir = tempdir().unwrap();
531        let content = dir.path().join("content");
532        fs::create_dir_all(&content).unwrap();
533        for i in 0..7 {
534            fs::write(content.join(format!("p{i}.md")), "# Hi").unwrap();
535        }
536
537        let budget = MemoryBudget {
538            max_bytes: 0,
539            batch_size: 2,
540        };
541        let batches = batched_content_files(&content, &budget).unwrap();
542        assert_eq!(batches.len(), 4); // 2+2+2+1
543        assert_eq!(batches[3].len(), 1);
544    }
545
546    #[test]
547    fn batched_content_files_nested_directories() {
548        let dir = tempdir().unwrap();
549        let content = dir.path().join("content");
550        fs::create_dir_all(content.join("blog")).unwrap();
551        fs::create_dir_all(content.join("docs")).unwrap();
552        fs::write(content.join("index.md"), "# Index").unwrap();
553        fs::write(content.join("blog/post.md"), "# Post").unwrap();
554        fs::write(content.join("docs/api.md"), "# API").unwrap();
555
556        let budget = MemoryBudget::from_mb(512);
557        let batches = batched_content_files(&content, &budget).unwrap();
558        let total: usize = batches.iter().map(|b| b.len()).sum();
559        assert_eq!(total, 3);
560    }
561
562    // -----------------------------------------------------------------
563    // merge_dir — additional scenarios
564    // -----------------------------------------------------------------
565
566    #[test]
567    fn merge_dir_deeply_nested() {
568        let dir = tempdir().unwrap();
569        let src = dir.path().join("src");
570        let dst = dir.path().join("dst");
571        fs::create_dir_all(src.join("a/b/c")).unwrap();
572        fs::create_dir_all(&dst).unwrap();
573        fs::write(src.join("a/b/c/deep.txt"), "deep content").unwrap();
574
575        merge_dir(&src, &dst).unwrap();
576        assert_eq!(
577            fs::read_to_string(dst.join("a/b/c/deep.txt")).unwrap(),
578            "deep content"
579        );
580    }
581
582    #[test]
583    fn merge_dir_empty_src() {
584        let dir = tempdir().unwrap();
585        let src = dir.path().join("src");
586        let dst = dir.path().join("dst");
587        fs::create_dir_all(&src).unwrap();
588        fs::create_dir_all(&dst).unwrap();
589        fs::write(dst.join("existing.txt"), "keep").unwrap();
590
591        merge_dir(&src, &dst).unwrap();
592        assert_eq!(
593            fs::read_to_string(dst.join("existing.txt")).unwrap(),
594            "keep"
595        );
596    }
597
598    #[test]
599    fn merge_dir_multiple_files() {
600        let dir = tempdir().unwrap();
601        let src = dir.path().join("src");
602        let dst = dir.path().join("dst");
603        fs::create_dir_all(&src).unwrap();
604        fs::create_dir_all(&dst).unwrap();
605        for i in 0..5 {
606            fs::write(src.join(format!("f{i}.txt")), format!("data{i}"))
607                .unwrap();
608        }
609
610        merge_dir(&src, &dst).unwrap();
611        for i in 0..5 {
612            assert_eq!(
613                fs::read_to_string(dst.join(format!("f{i}.txt"))).unwrap(),
614                format!("data{i}")
615            );
616        }
617    }
618
619    // -----------------------------------------------------------------
620    // should_stream — additional scenarios
621    // -----------------------------------------------------------------
622
623    #[test]
624    fn should_stream_with_no_content_dir() {
625        let dir = tempdir().unwrap();
626        let budget = MemoryBudget::from_mb(512);
627        // Non-existent dir, not explicitly set => false (walk returns 0)
628        assert!(!should_stream(
629            &dir.path().join("no-content"),
630            &budget,
631            false
632        ));
633    }
634
635    #[test]
636    fn should_stream_explicitly_set_overrides_count() {
637        // Even with zero files, explicit flag forces streaming
638        let dir = tempdir().unwrap();
639        let content = dir.path().join("content");
640        fs::create_dir_all(&content).unwrap();
641
642        let budget = MemoryBudget::from_mb(512);
643        assert!(should_stream(&content, &budget, true));
644    }
645
646    #[test]
647    fn should_stream_exactly_at_batch_boundary() {
648        let dir = tempdir().unwrap();
649        let content = dir.path().join("content");
650        fs::create_dir_all(&content).unwrap();
651        // Create exactly batch_size files => count == batch_size, not >
652        let budget = MemoryBudget {
653            max_bytes: 0,
654            batch_size: 3,
655        };
656        for i in 0..3 {
657            fs::write(content.join(format!("p{i}.md")), "# Hi").unwrap();
658        }
659        // 3 files, batch_size 3 => count is NOT > batch_size => false
660        assert!(!should_stream(&content, &budget, false));
661    }
662
663    #[test]
664    fn should_stream_one_over_boundary() {
665        let dir = tempdir().unwrap();
666        let content = dir.path().join("content");
667        fs::create_dir_all(&content).unwrap();
668        let budget = MemoryBudget {
669            max_bytes: 0,
670            batch_size: 3,
671        };
672        for i in 0..4 {
673            fs::write(content.join(format!("p{i}.md")), "# Hi").unwrap();
674        }
675        // 4 files, batch_size 3 => true
676        assert!(should_stream(&content, &budget, false));
677    }
678
679    // -----------------------------------------------------------------
680    // compile_batch — additional scenarios
681    // -----------------------------------------------------------------
682
683    #[test]
684    fn compile_batch_with_nonexistent_files_still_creates_dirs() {
685        let dir = tempdir().unwrap();
686        let content = dir.path().join("content");
687        let build = dir.path().join("build");
688        let site = dir.path().join("site");
689        let templates = dir.path().join("templates");
690        fs::create_dir_all(&content).unwrap();
691
692        // Pass paths that don't exist — the copy inside compile_batch
693        // will fail, but the batch content dir should still be created.
694        let result = compile_batch(
695            &[content.join("nonexistent.md")],
696            &content,
697            &build,
698            &site,
699            &templates,
700            0,
701        );
702        // This may error (file not found during copy), which is expected.
703        // The important thing is it doesn't panic.
704        let _ = result;
705    }
706
707    #[test]
708    fn compile_batch_creates_batch_content_dir() {
709        let dir = tempdir().unwrap();
710        let content = dir.path().join("content");
711        let build = dir.path().join("build");
712        let site = dir.path().join("site");
713        let templates = dir.path().join("templates");
714        fs::create_dir_all(&content).unwrap();
715        fs::create_dir_all(&templates).unwrap();
716        fs::write(content.join("page.md"), "---\ntitle: T\n---\n# Hi").unwrap();
717
718        // compile_batch with a real file — may fail at staticdatagen::compile
719        // but should not panic and should create the batch dir
720        let _result = compile_batch(
721            &[content.join("page.md")],
722            &content,
723            &build,
724            &site,
725            &templates,
726            42,
727        );
728        // Batch dirs are cleaned up, so we just verify no panic
729    }
730
731    #[test]
732    fn compile_batch_succeeds_and_merges_valid_page() {
733        // Mirrors the pipeline build fixture (full frontmatter plus a
734        // page template) so staticdatagen's compile succeeds — driving
735        // the merge-into-site_dir branch after a successful batch.
736        let dir = tempdir().unwrap();
737        let content = dir.path().join("content");
738        let build = dir.path().join("build");
739        let site = dir.path().join("public");
740        let templates = dir.path().join("templates");
741        fs::create_dir_all(&content).unwrap();
742        fs::create_dir_all(&build).unwrap();
743        fs::create_dir_all(&templates).unwrap();
744        fs::write(
745            content.join("index.md"),
746            "---\ntitle: \"Home\"\ndescription: \"home\"\n\
747             permalink: \"https://example.com/\"\n---\nhome body",
748        )
749        .unwrap();
750        fs::write(
751            templates.join("page.html"),
752            "<!doctype html><html><body>{{ content }}</body></html>",
753        )
754        .unwrap();
755
756        let result = compile_batch(
757            &[content.join("index.md")],
758            &content,
759            &build,
760            &site,
761            &templates,
762            7,
763        );
764        assert!(result.is_ok(), "expected successful batch: {result:?}");
765        assert!(site.is_dir(), "site dir must be created on success");
766    }
767
768    #[test]
769    fn compile_batch_fails_when_build_dir_is_a_file() {
770        // build_dir exists as a plain file, so creating the batch
771        // content dir underneath it fails immediately.
772        let dir = tempdir().unwrap();
773        let content = dir.path().join("content");
774        let build_file = dir.path().join("build");
775        fs::create_dir_all(&content).unwrap();
776        fs::write(content.join("a.md"), "x").unwrap();
777        fs::write(&build_file, "i am a file").unwrap();
778
779        let result = compile_batch(
780            &[content.join("a.md")],
781            &content,
782            &build_file,
783            &dir.path().join("site"),
784            &dir.path().join("templates"),
785            0,
786        );
787        assert!(result.is_err());
788    }
789
790    #[test]
791    fn compile_batch_fails_when_dest_parent_blocked_by_file() {
792        // The per-file parent create_dir_all fails: the batch content
793        // dir already contains a plain file where a subdirectory is
794        // needed.
795        let dir = tempdir().unwrap();
796        let content = dir.path().join("content");
797        let build = dir.path().join("build");
798        fs::create_dir_all(content.join("sub")).unwrap();
799        fs::write(content.join("sub/a.md"), "x").unwrap();
800        let batch_content = build.join(".batch-3");
801        fs::create_dir_all(&batch_content).unwrap();
802        fs::write(batch_content.join("sub"), "blocking file").unwrap();
803
804        let result = compile_batch(
805            &[content.join("sub/a.md")],
806            &content,
807            &build,
808            &dir.path().join("site"),
809            &dir.path().join("templates"),
810            3,
811        );
812        assert!(result.is_err());
813    }
814
815    #[test]
816    fn compile_batch_fails_when_batch_build_dir_blocked() {
817        let dir = tempdir().unwrap();
818        let content = dir.path().join("content");
819        let build = dir.path().join("build");
820        fs::create_dir_all(&content).unwrap();
821        fs::write(content.join("a.md"), "x").unwrap();
822        fs::create_dir_all(&build).unwrap();
823        // Block the derived batch-build path with a plain file.
824        fs::write(build.join(".batch-5-build"), "blocking file").unwrap();
825
826        let result = compile_batch(
827            &[content.join("a.md")],
828            &content,
829            &build,
830            &dir.path().join("site"),
831            &dir.path().join("templates"),
832            5,
833        );
834        assert!(result.is_err());
835    }
836
837    #[test]
838    fn compile_batch_fails_when_merge_dir_blocked_after_successful_compile() {
839        // Every other failure test in this module fails *before*
840        // reaching the merge step (blocked temp dirs abort the copy or
841        // the compile itself). Here the compile succeeds — two real
842        // pages, one of them nested under `about/` — but the site dir
843        // already has a plain *file* named `about`, so
844        // `merge_dir(&batch_site, site_dir)?` fails on
845        // `create_dir_all` when it tries to recreate that
846        // subdirectory. This drives the `?` propagation inside the
847        // `if compile_result.is_ok()` block.
848        let dir = tempdir().unwrap();
849        let content = dir.path().join("content");
850        let build = dir.path().join("build");
851        let site = dir.path().join("public");
852        let templates = dir.path().join("templates");
853        fs::create_dir_all(&content).unwrap();
854        fs::create_dir_all(&build).unwrap();
855        fs::create_dir_all(&templates).unwrap();
856        fs::write(
857            content.join("index.md"),
858            "---\ntitle: \"Home\"\ndescription: \"home\"\n\
859             permalink: \"https://example.com/\"\n---\nhome body",
860        )
861        .unwrap();
862        fs::write(
863            content.join("about.md"),
864            "---\ntitle: \"About\"\ndescription: \"about\"\n\
865             permalink: \"https://example.com/about/\"\n---\nabout body",
866        )
867        .unwrap();
868        fs::write(
869            templates.join("page.html"),
870            "<!doctype html><html><body>{{ content }}</body></html>",
871        )
872        .unwrap();
873
874        // Pre-block the nested output directory with a plain file so
875        // merge_dir's `create_dir_all(&dest)` fails once compile
876        // succeeds and produces `about/index.html` in the batch output.
877        fs::create_dir_all(&site).unwrap();
878        fs::write(site.join("about"), "blocking file, not a directory")
879            .unwrap();
880
881        let result = compile_batch(
882            &[content.join("index.md"), content.join("about.md")],
883            &content,
884            &build,
885            &site,
886            &templates,
887            11,
888        );
889        assert!(
890            result.is_err(),
891            "merge_dir failure after a successful compile must propagate: {result:?}"
892        );
893    }
894
895    #[test]
896    fn compile_batch_fails_when_batch_site_dir_blocked() {
897        let dir = tempdir().unwrap();
898        let content = dir.path().join("content");
899        let build = dir.path().join("build");
900        fs::create_dir_all(&content).unwrap();
901        fs::write(content.join("a.md"), "x").unwrap();
902        fs::create_dir_all(&build).unwrap();
903        fs::write(build.join(".batch-6-site"), "blocking file").unwrap();
904
905        let result = compile_batch(
906            &[content.join("a.md")],
907            &content,
908            &build,
909            &dir.path().join("site"),
910            &dir.path().join("templates"),
911            6,
912        );
913        assert!(result.is_err());
914    }
915
916    // -----------------------------------------------------------------
917    // merge_dir — error branches
918    // -----------------------------------------------------------------
919
920    #[test]
921    fn merge_dir_src_is_file_fails_at_read_dir() {
922        let dir = tempdir().unwrap();
923        let src_file = dir.path().join("plain.txt");
924        fs::write(&src_file, "x").unwrap();
925
926        let result = merge_dir(&src_file, &dir.path().join("dst"));
927        assert!(result.is_err());
928    }
929
930    #[test]
931    fn merge_dir_subdir_blocked_by_file_in_dst() {
932        let dir = tempdir().unwrap();
933        let src = dir.path().join("src");
934        let dst = dir.path().join("dst");
935        fs::create_dir_all(src.join("sub")).unwrap();
936        fs::write(src.join("sub/f.html"), "x").unwrap();
937        fs::create_dir_all(&dst).unwrap();
938        fs::write(dst.join("sub"), "blocking file").unwrap();
939
940        let result = merge_dir(&src, &dst);
941        assert!(result.is_err());
942    }
943
944    #[test]
945    fn merge_dir_nested_failure_propagates_through_recursion() {
946        // The inner merge_dir call fails (blocked sub-subdir), and the
947        // error propagates through the recursive `?`.
948        let dir = tempdir().unwrap();
949        let src = dir.path().join("src");
950        let dst = dir.path().join("dst");
951        fs::create_dir_all(src.join("a/b")).unwrap();
952        fs::write(src.join("a/b/f.html"), "x").unwrap();
953        fs::create_dir_all(dst.join("a")).unwrap();
954        fs::write(dst.join("a/b"), "blocking file").unwrap();
955
956        let result = merge_dir(&src, &dst);
957        assert!(result.is_err());
958    }
959
960    #[test]
961    fn merge_dir_copy_into_missing_dst_fails() {
962        let dir = tempdir().unwrap();
963        let src = dir.path().join("src");
964        fs::create_dir_all(&src).unwrap();
965        fs::write(src.join("f.html"), "x").unwrap();
966
967        // dst does not exist: fs::copy into it fails.
968        let result = merge_dir(&src, &dir.path().join("missing-dst"));
969        assert!(result.is_err());
970    }
971}