Skip to main content

ssg/core/
stream.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! High-performance streaming file processor.
5//!
6//! Provides constant-memory file processing for workloads from 1K to 50K+
7//! files. All I/O uses fixed-size buffers — memory usage does not grow
8//! with file size or transaction count.
9//!
10//! # Performance targets
11//!
12//! - Time to first result: < 2 ms
13//! - Throughput: >= 50,000 files/second
14//! - Memory: constant O(1) per file via streaming
15//!
16//! # Architecture
17//!
18//! Files are processed through a pipeline of `StreamProcessor` stages.
19//! Each stage reads from a buffered input, transforms in a fixed-size
20//! buffer, and writes to a buffered output. No file is ever fully loaded
21//! into memory unless it fits within the buffer size.
22
23use anyhow::{Context, Result};
24use std::fs::{self, File};
25use std::io::{BufReader, BufWriter, Read, Write};
26use std::path::{Path, PathBuf};
27use std::time::Instant;
28
29/// Default buffer size for streaming I/O (8 KB).
30/// Aligned to typical filesystem block size for optimal throughput.
31pub const STREAM_BUFFER_SIZE: usize = 8 * 1024;
32
33/// Maximum number of files to process in a single batch.
34/// Bounds memory for directory listings per Power of Ten Rule 2.
35pub const MAX_BATCH_SIZE: usize = 100_000;
36
37/// Result of processing a batch of files.
38#[derive(Debug, Clone, Copy)]
39pub struct BatchResult {
40    /// Number of files processed.
41    pub files_processed: usize,
42    /// Total bytes read across all files.
43    pub bytes_read: u64,
44    /// Total bytes written across all files.
45    pub bytes_written: u64,
46    /// Wall-clock duration of the batch.
47    pub duration_ms: f64,
48    /// Throughput in files per second.
49    pub throughput: f64,
50}
51
52/// Copies a single file using buffered streaming I/O.
53///
54/// Reads and writes in `STREAM_BUFFER_SIZE` chunks. Memory usage is
55/// constant regardless of file size — a 1 KB file and a 1 GB file
56/// use the same buffer.
57///
58/// # Examples
59///
60/// ```rust
61/// use ssg::stream::stream_copy;
62/// use tempfile::tempdir;
63/// use std::fs;
64///
65/// let dir = tempdir().unwrap();
66/// let src = dir.path().join("src.txt");
67/// let dst = dir.path().join("dst.txt");
68/// fs::write(&src, "hello").unwrap();
69/// let bytes = stream_copy(&src, &dst).unwrap();
70/// assert_eq!(bytes, 5);
71/// ```
72///
73/// # Errors
74///
75/// Returns an error if the source cannot be read or the destination
76/// cannot be written.
77pub fn stream_copy(src: &Path, dst: &Path) -> Result<u64> {
78    let file_in = File::open(src)
79        .with_context(|| format!("cannot open {}", src.display()))?;
80    let file_out = File::create(dst)
81        .with_context(|| format!("cannot create {}", dst.display()))?;
82
83    let reader = BufReader::with_capacity(STREAM_BUFFER_SIZE, file_in);
84    let writer = BufWriter::with_capacity(STREAM_BUFFER_SIZE, file_out);
85
86    copy_streams(reader, writer, src, dst)
87}
88
89/// Inner copy loop over generic reader/writer pairs.
90///
91/// Extracted from `stream_copy` so unit tests can drive the read,
92/// write, and flush error paths with failing mock streams — those
93/// branches are unreachable through the filesystem on all supported
94/// platforms once `File::open`/`File::create` have succeeded.
95fn copy_streams<R: Read, W: Write>(
96    mut reader: R,
97    mut writer: W,
98    src: &Path,
99    dst: &Path,
100) -> Result<u64> {
101    let mut buf = [0u8; STREAM_BUFFER_SIZE];
102    let mut total: u64 = 0;
103
104    loop {
105        let n = reader
106            .read(&mut buf)
107            .with_context(|| format!("read error: {}", src.display()))?;
108        if n == 0 {
109            break;
110        }
111        writer
112            .write_all(&buf[..n])
113            .with_context(|| format!("write error: {}", dst.display()))?;
114        total += n as u64;
115    }
116
117    writer
118        .flush()
119        .with_context(|| format!("flush error: {}", dst.display()))?;
120
121    Ok(total)
122}
123
124/// Hashes a file using streaming I/O with constant memory.
125///
126/// Reads in `STREAM_BUFFER_SIZE` chunks and feeds each chunk to a
127/// `DefaultHasher`. Never loads the entire file into memory.
128///
129/// Returns a 16-character hex fingerprint.
130///
131/// # Examples
132///
133/// ```rust
134/// use ssg::stream::stream_hash;
135/// use tempfile::tempdir;
136/// use std::fs;
137///
138/// let dir = tempdir().unwrap();
139/// let p = dir.path().join("h.txt");
140/// fs::write(&p, "hello").unwrap();
141/// let h = stream_hash(&p).unwrap();
142/// assert_eq!(h.len(), 16);
143/// ```
144pub fn stream_hash(path: &Path) -> Result<String> {
145    use std::hash::{DefaultHasher, Hasher};
146
147    let file = File::open(path)
148        .with_context(|| format!("cannot open {}", path.display()))?;
149    let mut reader = BufReader::with_capacity(STREAM_BUFFER_SIZE, file);
150    let mut hasher = DefaultHasher::new();
151    let mut buf = [0u8; STREAM_BUFFER_SIZE];
152
153    loop {
154        let n = reader
155            .read(&mut buf)
156            .with_context(|| format!("read error: {}", path.display()))?;
157        if n == 0 {
158            break;
159        }
160        hasher.write(&buf[..n]);
161    }
162
163    Ok(format!("{:016x}", hasher.finish()))
164}
165
166/// Processes a batch of files through a streaming pipeline.
167///
168/// Applies `processor` to each file in `src_dir`, writing results to
169/// `dst_dir`. Processes files sequentially with constant memory. For
170/// parallel processing, use `process_batch_parallel`.
171///
172/// # Errors
173///
174/// Returns an error if any file cannot be read, processed, or written.
175/// Processing stops at the first error.
176///
177/// # Examples
178///
179/// ```rust
180/// use ssg::stream::{process_batch, stream_copy};
181/// use tempfile::tempdir;
182/// use std::fs;
183///
184/// let dir = tempdir().unwrap();
185/// let src = dir.path().join("src");
186/// let dst = dir.path().join("dst");
187/// fs::create_dir(&src).unwrap();
188/// fs::write(src.join("f.txt"), "x").unwrap();
189/// let res = process_batch(&src, &dst, stream_copy).unwrap();
190/// assert_eq!(res.files_processed, 1);
191/// ```
192pub fn process_batch<F>(
193    src_dir: &Path,
194    dst_dir: &Path,
195    processor: F,
196) -> Result<BatchResult>
197where
198    F: Fn(&Path, &Path) -> Result<u64>,
199{
200    let start = Instant::now();
201
202    fs::create_dir_all(dst_dir)
203        .with_context(|| format!("cannot create {}", dst_dir.display()))?;
204
205    let entries: Vec<PathBuf> = collect_files_bounded(src_dir)?;
206    let mut bytes_read: u64 = 0;
207    let mut bytes_written: u64 = 0;
208    let mut count: usize = 0;
209
210    for src_path in &entries {
211        // Unreachable in practice: every `src_path` comes from
212        // `collect_files_bounded(src_dir)`, which always joins onto
213        // this exact `src_dir`, so `strip_prefix` cannot fail through
214        // the public API. Exercised only via the `stream::strip-prefix`
215        // failpoint under the `test-fault-injection` feature.
216        fail_point!("stream::strip-prefix", |_| Err(anyhow::anyhow!(
217            "injected: stream::strip-prefix"
218        )));
219        let rel = src_path
220            .strip_prefix(src_dir)
221            .with_context(|| "strip_prefix failed")?;
222        let dst_path = dst_dir.join(rel);
223
224        if let Some(parent) = dst_path.parent() {
225            fs::create_dir_all(parent)?;
226        }
227
228        let src_size = fs::metadata(src_path).map_or(0, |m| m.len());
229        let written = processor(src_path, &dst_path)?;
230
231        bytes_read += src_size;
232        bytes_written += written;
233        count += 1;
234    }
235
236    let (duration_ms, throughput) = compute_throughput(count, start.elapsed());
237
238    Ok(BatchResult {
239        files_processed: count,
240        bytes_read,
241        bytes_written,
242        duration_ms,
243        throughput,
244    })
245}
246
247/// Derives `(duration_ms, throughput)` from a batch's elapsed time.
248///
249/// Extracted from `process_batch` so the zero-duration guard (which
250/// yields `f64::INFINITY`) is unit-testable — a real batch never
251/// observes a zero `Instant` delta on supported platforms.
252fn compute_throughput(
253    count: usize,
254    elapsed: std::time::Duration,
255) -> (f64, f64) {
256    let duration_ms = elapsed.as_secs_f64() * 1000.0;
257    let throughput = if duration_ms > 0.0 {
258        count as f64 / elapsed.as_secs_f64()
259    } else {
260        f64::INFINITY
261    };
262    (duration_ms, throughput)
263}
264
265/// Collects files from a directory with a bounded iteration count.
266///
267/// Returns at most `MAX_BATCH_SIZE` files. Uses iterative traversal
268/// (no recursion) with depth tracking.
269fn collect_files_bounded(dir: &Path) -> Result<Vec<PathBuf>> {
270    collect_files_bounded_with_limit(dir, MAX_BATCH_SIZE)
271}
272
273/// Inner walker accepting an explicit limit.
274///
275/// Extracted so unit tests can exercise the saturation `break`
276/// branches without allocating `MAX_BATCH_SIZE` (100k) files on disk.
277fn collect_files_bounded_with_limit(
278    dir: &Path,
279    limit: usize,
280) -> Result<Vec<PathBuf>> {
281    let mut files = Vec::new();
282    let mut stack = vec![dir.to_path_buf()];
283    let mut iterations: usize = 0;
284
285    while let Some(current) = stack.pop() {
286        if iterations >= limit {
287            break;
288        }
289
290        let entries = fs::read_dir(&current)
291            .with_context(|| format!("cannot read {}", current.display()))?;
292
293        for entry in entries {
294            let path = entry?.path();
295            if path.is_dir() {
296                stack.push(path);
297            } else {
298                files.push(path);
299                iterations += 1;
300                if iterations >= limit {
301                    break;
302                }
303            }
304        }
305    }
306
307    Ok(files)
308}
309
310/// Processes a file by reading line-by-line with constant memory.
311///
312/// Calls `line_fn` for each line. The line buffer is reused across
313/// iterations — memory does not grow with file length.
314///
315/// # Examples
316///
317/// ```rust
318/// use ssg::stream::stream_lines;
319/// use tempfile::tempdir;
320/// use std::fs;
321///
322/// let dir = tempdir().unwrap();
323/// let p = dir.path().join("f.txt");
324/// fs::write(&p, "a\nb\nc").unwrap();
325/// let mut seen = Vec::new();
326/// let n = stream_lines(&p, |_, line| { seen.push(line.to_string()); Ok(()) }).unwrap();
327/// assert_eq!(n, 3);
328/// assert_eq!(seen[0], "a");
329/// ```
330///
331/// # Errors
332///
333/// Returns an error if the file cannot be read.
334pub fn stream_lines<F>(path: &Path, mut line_fn: F) -> Result<usize>
335where
336    F: FnMut(usize, &str) -> Result<()>,
337{
338    use std::io::BufRead;
339
340    let file = File::open(path)
341        .with_context(|| format!("cannot open {}", path.display()))?;
342    let reader = BufReader::with_capacity(STREAM_BUFFER_SIZE, file);
343    let mut count: usize = 0;
344
345    for line in reader.lines() {
346        let line =
347            line.with_context(|| format!("read error at line {count}"))?;
348        line_fn(count, &line)?;
349        count += 1;
350    }
351
352    Ok(count)
353}
354
355/// Returns the throughput of a no-op pipeline to measure overhead.
356///
357/// Creates `n` temporary files and streams them through `stream_copy`.
358/// Returns the measured throughput in files/second.
359///
360/// # Examples
361///
362/// ```rust
363/// # #[cfg(test)]
364/// # fn doctest() {
365/// use ssg::stream::benchmark_throughput;
366///
367/// let result = benchmark_throughput(5).unwrap();
368/// assert_eq!(result.files_processed, 5);
369/// # }
370/// ```
371#[cfg(any(test, feature = "benchmark"))]
372pub fn benchmark_throughput(n: usize) -> Result<BatchResult> {
373    let tmp = tempfile::tempdir().context("cannot create temp dir")?;
374    let src = tmp.path().join("src");
375    let dst = tmp.path().join("dst");
376    fs::create_dir_all(&src)?;
377
378    // Create n small files (64 bytes each)
379    for i in 0..n {
380        fs::write(src.join(format!("f{i}.txt")), "a]".repeat(32))?;
381    }
382
383    process_batch(&src, &dst, stream_copy)
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use tempfile::tempdir;
390
391    #[test]
392    fn test_stream_copy_small_file() {
393        let tmp = tempdir().unwrap();
394        let src = tmp.path().join("src.txt");
395        let dst = tmp.path().join("dst.txt");
396        fs::write(&src, "hello world").unwrap();
397
398        let bytes = stream_copy(&src, &dst).unwrap();
399        assert_eq!(bytes, 11);
400        assert_eq!(fs::read_to_string(&dst).unwrap(), "hello world");
401    }
402
403    #[test]
404    fn test_stream_copy_large_file() {
405        let tmp = tempdir().unwrap();
406        let src = tmp.path().join("large.bin");
407        let dst = tmp.path().join("large_copy.bin");
408
409        // 1 MB file — larger than STREAM_BUFFER_SIZE
410        let data = vec![0xABu8; 1024 * 1024];
411        fs::write(&src, &data).unwrap();
412
413        let bytes = stream_copy(&src, &dst).unwrap();
414        assert_eq!(bytes, 1024 * 1024);
415        assert_eq!(fs::read(&dst).unwrap(), data);
416    }
417
418    #[test]
419    fn test_stream_copy_empty_file() {
420        let tmp = tempdir().unwrap();
421        let src = tmp.path().join("empty.txt");
422        let dst = tmp.path().join("empty_copy.txt");
423        fs::write(&src, "").unwrap();
424
425        let bytes = stream_copy(&src, &dst).unwrap();
426        assert_eq!(bytes, 0);
427    }
428
429    #[test]
430    fn test_stream_hash_deterministic() {
431        let tmp = tempdir().unwrap();
432        let path = tmp.path().join("test.txt");
433        fs::write(&path, "consistent content").unwrap();
434
435        let h1 = stream_hash(&path).unwrap();
436        let h2 = stream_hash(&path).unwrap();
437        assert_eq!(h1, h2);
438        assert_eq!(h1.len(), 16);
439    }
440
441    #[test]
442    fn test_stream_hash_differs_for_different_content() {
443        let tmp = tempdir().unwrap();
444        let a = tmp.path().join("a.txt");
445        let b = tmp.path().join("b.txt");
446        fs::write(&a, "content a").unwrap();
447        fs::write(&b, "content b").unwrap();
448
449        assert_ne!(stream_hash(&a).unwrap(), stream_hash(&b).unwrap());
450    }
451
452    #[test]
453    fn test_stream_hash_large_file() {
454        let tmp = tempdir().unwrap();
455        let path = tmp.path().join("big.bin");
456        fs::write(&path, vec![0u8; 100_000]).unwrap();
457
458        let hash = stream_hash(&path).unwrap();
459        assert_eq!(hash.len(), 16);
460    }
461
462    #[test]
463    #[serial_test::parallel(stream_strip_prefix)]
464    fn test_process_batch_copies_files() {
465        let tmp = tempdir().unwrap();
466        let src = tmp.path().join("src");
467        let dst = tmp.path().join("dst");
468        fs::create_dir_all(&src).unwrap();
469
470        for i in 0..10 {
471            fs::write(src.join(format!("f{i}.txt")), format!("data {i}"))
472                .unwrap();
473        }
474
475        let result = process_batch(&src, &dst, stream_copy).unwrap();
476        assert_eq!(result.files_processed, 10);
477        assert!(result.bytes_written > 0);
478        assert!(result.throughput > 0.0);
479    }
480
481    #[test]
482    #[serial_test::parallel(stream_strip_prefix)]
483    fn test_process_batch_empty_directory() {
484        let tmp = tempdir().unwrap();
485        let src = tmp.path().join("src");
486        let dst = tmp.path().join("dst");
487        fs::create_dir_all(&src).unwrap();
488
489        let result = process_batch(&src, &dst, stream_copy).unwrap();
490        assert_eq!(result.files_processed, 0);
491    }
492
493    #[test]
494    #[serial_test::parallel(stream_strip_prefix)]
495    fn test_process_batch_nested_dirs() {
496        let tmp = tempdir().unwrap();
497        let src = tmp.path().join("src");
498        let dst = tmp.path().join("dst");
499        fs::create_dir_all(src.join("sub/deep")).unwrap();
500        fs::write(src.join("root.txt"), "root").unwrap();
501        fs::write(src.join("sub/mid.txt"), "mid").unwrap();
502        fs::write(src.join("sub/deep/leaf.txt"), "leaf").unwrap();
503
504        let result = process_batch(&src, &dst, stream_copy).unwrap();
505        assert_eq!(result.files_processed, 3);
506        assert_eq!(
507            fs::read_to_string(dst.join("sub/deep/leaf.txt")).unwrap(),
508            "leaf"
509        );
510    }
511
512    #[test]
513    fn test_stream_lines_counts_correctly() {
514        let tmp = tempdir().unwrap();
515        let path = tmp.path().join("lines.txt");
516        fs::write(&path, "line1\nline2\nline3\n").unwrap();
517
518        let count = stream_lines(&path, |_i, _line| Ok(())).unwrap();
519        assert_eq!(count, 3);
520    }
521
522    #[test]
523    fn test_stream_lines_provides_content() {
524        let tmp = tempdir().unwrap();
525        let path = tmp.path().join("data.txt");
526        fs::write(&path, "alpha\nbeta\ngamma").unwrap();
527
528        let mut collected = Vec::new();
529        let _ = stream_lines(&path, |_i, line| {
530            collected.push(line.to_string());
531            Ok(())
532        })
533        .unwrap();
534        assert_eq!(collected, vec!["alpha", "beta", "gamma"]);
535    }
536
537    #[test]
538    fn test_collect_files_bounded_respects_limit() {
539        let tmp = tempdir().unwrap();
540        // MAX_BATCH_SIZE is 100_000 — just verify it doesn't panic
541        for i in 0..50 {
542            fs::write(tmp.path().join(format!("f{i}.txt")), "x").unwrap();
543        }
544        let files = collect_files_bounded(tmp.path()).unwrap();
545        assert_eq!(files.len(), 50);
546    }
547
548    #[test]
549    fn collect_files_bounded_with_limit_breaks_on_outer_loop_saturation() {
550        // Hits the `if iterations >= limit { break }` at the top of
551        // the outer while loop (line 196 of the public version).
552        // We add files in batches across multiple subdirectories so
553        // the inner break fires first, leaves leftover stack entries,
554        // and then the next outer-loop pop sees iterations == limit.
555        let tmp = tempdir().unwrap();
556        let a = tmp.path().join("a");
557        let b = tmp.path().join("b");
558        fs::create_dir_all(&a).unwrap();
559        fs::create_dir_all(&b).unwrap();
560        for i in 0..3 {
561            fs::write(a.join(format!("f{i}.txt")), "x").unwrap();
562            fs::write(b.join(format!("f{i}.txt")), "x").unwrap();
563        }
564
565        let files = collect_files_bounded_with_limit(tmp.path(), 2).unwrap();
566        // The cap is honoured: at most `limit` files returned
567        // (may be slightly more depending on which subdir is popped
568        // first; the contract is "at most" with break-on-saturation).
569        assert!(files.len() <= 4);
570    }
571
572    #[test]
573    fn collect_files_bounded_with_limit_breaks_on_inner_loop_saturation() {
574        // Hits the inner `if iterations >= limit { break }` (line 210
575        // of the public version) — file count exceeds limit during
576        // a single read_dir iteration.
577        let tmp = tempdir().unwrap();
578        for i in 0..10 {
579            fs::write(tmp.path().join(format!("f{i}.txt")), "x").unwrap();
580        }
581        let files = collect_files_bounded_with_limit(tmp.path(), 3).unwrap();
582        assert_eq!(files.len(), 3);
583    }
584
585    #[test]
586    #[serial_test::parallel(stream_strip_prefix)]
587    fn test_benchmark_throughput_runs() {
588        let result = benchmark_throughput(100).unwrap();
589        assert_eq!(result.files_processed, 100);
590        assert!(
591            result.throughput.is_finite() && result.throughput > 0.0,
592            "invalid throughput: {}",
593            result.throughput
594        );
595        println!(
596            "Benchmark: {} files in {:.2} ms ({:.0} files/sec)",
597            result.files_processed, result.duration_ms, result.throughput
598        );
599    }
600
601    #[test]
602    fn test_batch_result_fields() {
603        let r = BatchResult {
604            files_processed: 10,
605            bytes_read: 1000,
606            bytes_written: 900,
607            duration_ms: 1.5,
608            throughput: 6666.0,
609        };
610        assert_eq!(r.files_processed, 10);
611        assert!(r.throughput > 0.0);
612    }
613
614    #[test]
615    fn test_stream_copy_nonexistent_source() {
616        let dst = std::env::temp_dir().join("ssg_stream_copy_out");
617        let result =
618            stream_copy(Path::new("/definitely-does-not-exist-ssg"), &dst);
619        assert!(result.is_err());
620    }
621
622    #[test]
623    fn test_stream_hash_nonexistent() {
624        let result = stream_hash(Path::new("/nonexistent"));
625        assert!(result.is_err());
626    }
627
628    #[test]
629    fn test_stream_lines_empty_file() {
630        let tmp = tempdir().unwrap();
631        let path = tmp.path().join("empty.txt");
632        fs::write(&path, "").unwrap();
633
634        let count = stream_lines(&path, |_i, _line| Ok(())).unwrap();
635        assert_eq!(count, 0);
636    }
637
638    #[test]
639    fn stream_copy_exact_buffer_size_file() {
640        // Arrange
641        let tmp = tempdir().unwrap();
642        let src = tmp.path().join("exact.bin");
643        let dst = tmp.path().join("exact_copy.bin");
644        let data = vec![0xCDu8; STREAM_BUFFER_SIZE];
645        fs::write(&src, &data).unwrap();
646
647        // Act
648        let bytes = stream_copy(&src, &dst).unwrap();
649
650        // Assert
651        assert_eq!(bytes, STREAM_BUFFER_SIZE as u64);
652        assert_eq!(fs::read(&dst).unwrap(), data);
653    }
654
655    #[test]
656    fn stream_hash_empty_file() {
657        // Arrange
658        let tmp = tempdir().unwrap();
659        let path = tmp.path().join("empty.bin");
660        fs::write(&path, b"").unwrap();
661
662        // Act
663        let h1 = stream_hash(&path).unwrap();
664        let h2 = stream_hash(&path).unwrap();
665
666        // Assert
667        assert_eq!(h1, h2, "hash of empty file must be deterministic");
668        assert_eq!(h1.len(), 16);
669    }
670
671    #[test]
672    fn stream_hash_same_content_same_hash() {
673        // Arrange
674        let tmp = tempdir().unwrap();
675        let a = tmp.path().join("file_a.txt");
676        let b = tmp.path().join("file_b.txt");
677        let content = "identical content in both files";
678        fs::write(&a, content).unwrap();
679        fs::write(&b, content).unwrap();
680
681        // Act
682        let hash_a = stream_hash(&a).unwrap();
683        let hash_b = stream_hash(&b).unwrap();
684
685        // Assert
686        assert_eq!(hash_a, hash_b, "same content must produce same hash");
687    }
688
689    #[test]
690    fn stream_lines_binary_content() {
691        // Arrange — file with no newline characters
692        let tmp = tempdir().unwrap();
693        let path = tmp.path().join("binary.bin");
694        fs::write(&path, "no-newlines-here").unwrap();
695
696        // Act
697        let mut lines_seen = Vec::new();
698        let count = stream_lines(&path, |_i, line| {
699            lines_seen.push(line.to_string());
700            Ok(())
701        })
702        .unwrap();
703
704        // Assert — single line, no newline splitting
705        assert_eq!(count, 1);
706        assert_eq!(lines_seen, vec!["no-newlines-here"]);
707    }
708
709    #[test]
710    #[serial_test::parallel(stream_strip_prefix)]
711    fn process_batch_empty_directory() {
712        // Arrange — source directory with no files
713        let tmp = tempdir().unwrap();
714        let src = tmp.path().join("empty_src");
715        let dst = tmp.path().join("empty_dst");
716        fs::create_dir_all(&src).unwrap();
717
718        // Act
719        let result = process_batch(&src, &dst, stream_copy).unwrap();
720
721        // Assert
722        assert_eq!(result.files_processed, 0);
723        assert_eq!(result.bytes_read, 0);
724        assert_eq!(result.bytes_written, 0);
725    }
726
727    // -----------------------------------------------------------------
728    // stream_copy — additional edge cases
729    // -----------------------------------------------------------------
730
731    #[test]
732    fn stream_copy_file_just_over_buffer_boundary() {
733        let tmp = tempdir().unwrap();
734        let src = tmp.path().join("over.bin");
735        let dst = tmp.path().join("over_copy.bin");
736        // One byte beyond buffer size forces two reads.
737        let data = vec![0xEFu8; STREAM_BUFFER_SIZE + 1];
738        fs::write(&src, &data).unwrap();
739
740        let bytes = stream_copy(&src, &dst).unwrap();
741        assert_eq!(bytes, (STREAM_BUFFER_SIZE + 1) as u64);
742        assert_eq!(fs::read(&dst).unwrap(), data);
743    }
744
745    #[test]
746    fn stream_copy_file_just_under_buffer_boundary() {
747        let tmp = tempdir().unwrap();
748        let src = tmp.path().join("under.bin");
749        let dst = tmp.path().join("under_copy.bin");
750        let data = vec![0xAAu8; STREAM_BUFFER_SIZE - 1];
751        fs::write(&src, &data).unwrap();
752
753        let bytes = stream_copy(&src, &dst).unwrap();
754        assert_eq!(bytes, (STREAM_BUFFER_SIZE - 1) as u64);
755        assert_eq!(fs::read(&dst).unwrap(), data);
756    }
757
758    #[test]
759    fn stream_copy_multiple_of_buffer_size() {
760        let tmp = tempdir().unwrap();
761        let src = tmp.path().join("multi.bin");
762        let dst = tmp.path().join("multi_copy.bin");
763        let data = vec![0xBBu8; STREAM_BUFFER_SIZE * 3];
764        fs::write(&src, &data).unwrap();
765
766        let bytes = stream_copy(&src, &dst).unwrap();
767        assert_eq!(bytes, (STREAM_BUFFER_SIZE * 3) as u64);
768        assert_eq!(fs::read(&dst).unwrap(), data);
769    }
770
771    #[test]
772    fn stream_copy_single_byte() {
773        let tmp = tempdir().unwrap();
774        let src = tmp.path().join("one.bin");
775        let dst = tmp.path().join("one_copy.bin");
776        fs::write(&src, [0x42]).unwrap();
777
778        let bytes = stream_copy(&src, &dst).unwrap();
779        assert_eq!(bytes, 1);
780        assert_eq!(fs::read(&dst).unwrap(), vec![0x42]);
781    }
782
783    #[test]
784    fn stream_copy_dst_parent_does_not_exist() {
785        let tmp = tempdir().unwrap();
786        let src = tmp.path().join("src.txt");
787        fs::write(&src, "data").unwrap();
788        let dst = tmp.path().join("no/such/parent/out.txt");
789
790        let err = stream_copy(&src, &dst);
791        assert!(err.is_err());
792    }
793
794    // -----------------------------------------------------------------
795    // stream_hash — additional edge cases
796    // -----------------------------------------------------------------
797
798    #[test]
799    fn stream_hash_multi_chunk_file() {
800        let tmp = tempdir().unwrap();
801        let path = tmp.path().join("multi_chunk.bin");
802        // Force multiple read iterations
803        let data = vec![0xCCu8; STREAM_BUFFER_SIZE * 2 + 100];
804        fs::write(&path, &data).unwrap();
805
806        let h1 = stream_hash(&path).unwrap();
807        let h2 = stream_hash(&path).unwrap();
808        assert_eq!(h1, h2);
809        assert_eq!(h1.len(), 16);
810    }
811
812    #[test]
813    fn stream_hash_exact_buffer_boundary() {
814        let tmp = tempdir().unwrap();
815        let path = tmp.path().join("exact_buf.bin");
816        let data = vec![0xDDu8; STREAM_BUFFER_SIZE];
817        fs::write(&path, &data).unwrap();
818
819        let hash = stream_hash(&path).unwrap();
820        assert_eq!(hash.len(), 16);
821    }
822
823    // -----------------------------------------------------------------
824    // stream_lines — additional edge cases
825    // -----------------------------------------------------------------
826
827    #[test]
828    fn stream_lines_callback_error_propagates() {
829        let tmp = tempdir().unwrap();
830        let path = tmp.path().join("err.txt");
831        fs::write(&path, "line1\nline2\nline3\n").unwrap();
832
833        let result = stream_lines(&path, |i, _line| {
834            if i == 1 {
835                anyhow::bail!("stop at line 1");
836            }
837            Ok(())
838        });
839
840        assert!(result.is_err());
841        let msg = format!("{}", result.unwrap_err());
842        assert!(msg.contains("stop at line 1"));
843    }
844
845    #[test]
846    fn stream_lines_nonexistent_file() {
847        let result = stream_lines(Path::new("/nonexistent_ssg"), |_, _| Ok(()));
848        assert!(result.is_err());
849    }
850
851    #[test]
852    fn stream_lines_line_index_is_zero_based() {
853        let tmp = tempdir().unwrap();
854        let path = tmp.path().join("indexed.txt");
855        fs::write(&path, "a\nb\nc").unwrap();
856
857        let mut indices = Vec::new();
858        let _ = stream_lines(&path, |i, _| {
859            indices.push(i);
860            Ok(())
861        })
862        .unwrap();
863        assert_eq!(indices, vec![0, 1, 2]);
864    }
865
866    #[test]
867    fn stream_lines_trailing_newline_does_not_create_extra_line() {
868        let tmp = tempdir().unwrap();
869        let path = tmp.path().join("trailing.txt");
870        fs::write(&path, "a\nb\n").unwrap();
871
872        let count = stream_lines(&path, |_, _| Ok(())).unwrap();
873        assert_eq!(count, 2);
874    }
875
876    #[test]
877    fn stream_lines_many_lines() {
878        let tmp = tempdir().unwrap();
879        let path = tmp.path().join("many.txt");
880        let mut content = String::new();
881        for i in 0..1000 {
882            content.push_str(&format!("line {i}\n"));
883        }
884        fs::write(&path, &content).unwrap();
885
886        let count = stream_lines(&path, |_, _| Ok(())).unwrap();
887        assert_eq!(count, 1000);
888    }
889
890    // -----------------------------------------------------------------
891    // process_batch — additional edge cases
892    // -----------------------------------------------------------------
893
894    #[test]
895    #[serial_test::parallel(stream_strip_prefix)]
896    fn process_batch_nonexistent_src_dir() {
897        let tmp = tempdir().unwrap();
898        let result = process_batch(
899            &tmp.path().join("no-such-dir"),
900            &tmp.path().join("dst"),
901            stream_copy,
902        );
903        assert!(result.is_err());
904    }
905
906    #[test]
907    #[serial_test::parallel(stream_strip_prefix)]
908    fn process_batch_processor_error_stops_batch() {
909        let tmp = tempdir().unwrap();
910        let src = tmp.path().join("src");
911        let dst = tmp.path().join("dst");
912        fs::create_dir_all(&src).unwrap();
913        fs::write(src.join("a.txt"), "hello").unwrap();
914
915        let result = process_batch(&src, &dst, |_s, _d| {
916            anyhow::bail!("processor error")
917        });
918        assert!(result.is_err());
919    }
920
921    #[test]
922    #[serial_test::parallel(stream_strip_prefix)]
923    fn process_batch_throughput_finite_for_fast_run() {
924        let tmp = tempdir().unwrap();
925        let src = tmp.path().join("src");
926        let dst = tmp.path().join("dst");
927        fs::create_dir_all(&src).unwrap();
928        for i in 0..5 {
929            fs::write(src.join(format!("f{i}.txt")), "x").unwrap();
930        }
931
932        let result = process_batch(&src, &dst, stream_copy).unwrap();
933        assert_eq!(result.files_processed, 5);
934        assert!(result.duration_ms >= 0.0);
935    }
936
937    // -----------------------------------------------------------------
938    // collect_files_bounded_with_limit — additional edge cases
939    // -----------------------------------------------------------------
940
941    #[test]
942    fn collect_files_bounded_with_limit_zero() {
943        let tmp = tempdir().unwrap();
944        fs::write(tmp.path().join("a.txt"), "x").unwrap();
945
946        let files = collect_files_bounded_with_limit(tmp.path(), 0).unwrap();
947        assert!(files.is_empty());
948    }
949
950    #[test]
951    fn collect_files_bounded_with_limit_exact() {
952        let tmp = tempdir().unwrap();
953        for i in 0..5 {
954            fs::write(tmp.path().join(format!("f{i}.txt")), "x").unwrap();
955        }
956
957        let files = collect_files_bounded_with_limit(tmp.path(), 5).unwrap();
958        assert_eq!(files.len(), 5);
959    }
960
961    #[test]
962    fn collect_files_bounded_with_limit_deeply_nested() {
963        let tmp = tempdir().unwrap();
964        let deep = tmp.path().join("a/b/c/d/e");
965        fs::create_dir_all(&deep).unwrap();
966        fs::write(deep.join("leaf.txt"), "deep").unwrap();
967        fs::write(tmp.path().join("root.txt"), "root").unwrap();
968
969        let files = collect_files_bounded(tmp.path()).unwrap();
970        assert_eq!(files.len(), 2);
971    }
972
973    #[test]
974    fn collect_files_bounded_empty_dir() {
975        let tmp = tempdir().unwrap();
976        let files = collect_files_bounded(tmp.path()).unwrap();
977        assert!(files.is_empty());
978    }
979
980    #[test]
981    fn collect_files_bounded_nonexistent_dir() {
982        let result =
983            collect_files_bounded(Path::new("/nonexistent_ssg_walker"));
984        assert!(result.is_err());
985    }
986
987    // -----------------------------------------------------------------
988    // BatchResult — Clone / Copy / Debug
989    // -----------------------------------------------------------------
990
991    #[test]
992    fn batch_result_clone_and_debug() {
993        let r = BatchResult {
994            files_processed: 5,
995            bytes_read: 500,
996            bytes_written: 400,
997            duration_ms: 2.0,
998            throughput: 2500.0,
999        };
1000        let r2 = r;
1001        assert_eq!(r.files_processed, r2.files_processed);
1002        assert_eq!(format!("{r:?}"), format!("{r2:?}"));
1003    }
1004
1005    // -----------------------------------------------------------------
1006    // benchmark_throughput — edge cases
1007    // -----------------------------------------------------------------
1008
1009    #[test]
1010    fn benchmark_throughput_zero_files() {
1011        let result = benchmark_throughput(0).unwrap();
1012        assert_eq!(result.files_processed, 0);
1013    }
1014
1015    #[test]
1016    #[serial_test::parallel(stream_strip_prefix)]
1017    fn benchmark_throughput_single_file() {
1018        let result = benchmark_throughput(1).unwrap();
1019        assert_eq!(result.files_processed, 1);
1020    }
1021
1022    // -----------------------------------------------------------------
1023    // Constants — sanity checks
1024    // -----------------------------------------------------------------
1025
1026    #[test]
1027    fn constants_are_sensible() {
1028        assert_eq!(STREAM_BUFFER_SIZE, 8192);
1029        assert_eq!(MAX_BATCH_SIZE, 100_000);
1030    }
1031
1032    // -----------------------------------------------------------------
1033    // copy_streams — read / write / flush error paths via mock streams
1034    // -----------------------------------------------------------------
1035
1036    /// Reader whose `read` always fails.
1037    struct FailingReader;
1038
1039    impl Read for FailingReader {
1040        fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
1041            Err(std::io::Error::other("simulated read failure"))
1042        }
1043    }
1044
1045    /// Writer that can be configured to fail on write or on flush.
1046    struct FailingWriter {
1047        fail_write: bool,
1048        fail_flush: bool,
1049    }
1050
1051    impl Write for FailingWriter {
1052        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1053            if self.fail_write {
1054                Err(std::io::Error::other("simulated write failure"))
1055            } else {
1056                Ok(buf.len())
1057            }
1058        }
1059
1060        fn flush(&mut self) -> std::io::Result<()> {
1061            if self.fail_flush {
1062                Err(std::io::Error::other("simulated flush failure"))
1063            } else {
1064                Ok(())
1065            }
1066        }
1067    }
1068
1069    #[test]
1070    fn copy_streams_read_error_carries_source_path() {
1071        let writer = FailingWriter {
1072            fail_write: false,
1073            fail_flush: false,
1074        };
1075        let err = copy_streams(
1076            FailingReader,
1077            writer,
1078            Path::new("in.bin"),
1079            Path::new("out.bin"),
1080        )
1081        .unwrap_err();
1082        let msg = format!("{err:?}");
1083        assert!(msg.contains("read error: in.bin"), "got: {msg}");
1084    }
1085
1086    #[test]
1087    fn copy_streams_write_error_carries_dest_path() {
1088        let reader = std::io::Cursor::new(vec![7u8; 32]);
1089        let writer = FailingWriter {
1090            fail_write: true,
1091            fail_flush: false,
1092        };
1093        let err = copy_streams(
1094            reader,
1095            writer,
1096            Path::new("in.bin"),
1097            Path::new("out.bin"),
1098        )
1099        .unwrap_err();
1100        let msg = format!("{err:?}");
1101        assert!(msg.contains("write error: out.bin"), "got: {msg}");
1102    }
1103
1104    #[test]
1105    fn copy_streams_flush_error_carries_dest_path() {
1106        let reader = std::io::Cursor::new(vec![7u8; 32]);
1107        let writer = FailingWriter {
1108            fail_write: false,
1109            fail_flush: true,
1110        };
1111        let err = copy_streams(
1112            reader,
1113            writer,
1114            Path::new("in.bin"),
1115            Path::new("out.bin"),
1116        )
1117        .unwrap_err();
1118        let msg = format!("{err:?}");
1119        assert!(msg.contains("flush error: out.bin"), "got: {msg}");
1120    }
1121
1122    // -----------------------------------------------------------------
1123    // stream_hash / stream_lines — read error paths
1124    // -----------------------------------------------------------------
1125
1126    #[cfg(unix)]
1127    #[test]
1128    fn stream_hash_read_error_on_directory() {
1129        // On Unix, `File::open` on a directory succeeds but the first
1130        // `read` fails with EISDIR — driving the read-error context
1131        // closure inside the hash loop.
1132        let tmp = tempdir().unwrap();
1133        let err = stream_hash(tmp.path()).unwrap_err();
1134        let msg = format!("{err:?}");
1135        assert!(msg.contains("read error:"), "got: {msg}");
1136    }
1137
1138    #[test]
1139    fn stream_lines_invalid_utf8_fires_line_error_context() {
1140        let tmp = tempdir().unwrap();
1141        let path = tmp.path().join("bad.bin");
1142        fs::write(&path, [b'o', b'k', b'\n', 0xFF, 0xFE, 0xFD]).unwrap();
1143
1144        let err = stream_lines(&path, |_, _| Ok(())).unwrap_err();
1145        let msg = format!("{err:?}");
1146        assert!(msg.contains("read error at line 1"), "got: {msg}");
1147    }
1148
1149    // -----------------------------------------------------------------
1150    // process_batch — directory-creation error paths
1151    // -----------------------------------------------------------------
1152
1153    #[test]
1154    #[serial_test::parallel(stream_strip_prefix)]
1155    fn process_batch_dst_creation_failure_fires_context_closure() {
1156        // dst_dir nests under an existing *file*, so create_dir_all
1157        // fails and the `cannot create` context closure runs.
1158        let tmp = tempdir().unwrap();
1159        let src = tmp.path().join("src");
1160        fs::create_dir_all(&src).unwrap();
1161        let blocker = tmp.path().join("blocker");
1162        fs::write(&blocker, "file, not dir").unwrap();
1163
1164        let err =
1165            process_batch(&src, &blocker.join("dst"), stream_copy).unwrap_err();
1166        let msg = format!("{err:?}");
1167        assert!(msg.contains("cannot create"), "got: {msg}");
1168    }
1169
1170    #[test]
1171    #[serial_test::parallel(stream_strip_prefix)]
1172    fn process_batch_per_file_parent_creation_failure_propagates() {
1173        // The per-file `create_dir_all(parent)?` fails when the
1174        // destination subdirectory path is blocked by a plain file.
1175        let tmp = tempdir().unwrap();
1176        let src = tmp.path().join("src");
1177        let dst = tmp.path().join("dst");
1178        fs::create_dir_all(src.join("sub")).unwrap();
1179        fs::write(src.join("sub/x.txt"), "x").unwrap();
1180        fs::create_dir_all(&dst).unwrap();
1181        fs::write(dst.join("sub"), "file blocking subdir").unwrap();
1182
1183        let result = process_batch(&src, &dst, stream_copy);
1184        assert!(result.is_err());
1185    }
1186
1187    // -----------------------------------------------------------------
1188    // compute_throughput — zero and non-zero durations
1189    // -----------------------------------------------------------------
1190
1191    #[test]
1192    fn compute_throughput_zero_duration_is_infinite() {
1193        let (duration_ms, throughput) =
1194            compute_throughput(10, std::time::Duration::ZERO);
1195        // `Duration::ZERO.as_secs_f64() * 1000.0` is exactly 0.0 by
1196        // IEEE 754 (zero times any finite value is zero) — an exact
1197        // bit-pattern comparison, not an epsilon-worthy approximation.
1198        assert_eq!(duration_ms.to_bits(), 0.0_f64.to_bits());
1199        assert!(throughput.is_infinite());
1200    }
1201
1202    #[test]
1203    fn compute_throughput_positive_duration_is_finite() {
1204        let (duration_ms, throughput) =
1205            compute_throughput(10, std::time::Duration::from_millis(5));
1206        assert!(duration_ms > 0.0);
1207        assert!(throughput.is_finite());
1208        assert!((throughput - 2000.0).abs() < f64::EPSILON);
1209    }
1210}
1211
1212/// Fault-injection tests for `stream.rs` failpoints. Mirrors the
1213/// pattern used in `core/cache.rs` / `core/io_pool.rs`: the failpoint
1214/// registry is process-global, so these live in their own `mod` and
1215/// are `#[serial]` on a dedicated key.
1216#[cfg(all(test, feature = "test-fault-injection"))]
1217mod fault_injection {
1218    use super::*;
1219    use std::fs;
1220    use tempfile::tempdir;
1221
1222    /// RAII guard that disables a failpoint on drop.
1223    struct FailGuard<'a>(&'a str);
1224
1225    impl Drop for FailGuard<'_> {
1226        fn drop(&mut self) {
1227            let _ = fail::cfg(self.0, "off");
1228        }
1229    }
1230
1231    #[test]
1232    #[serial_test::serial(stream_strip_prefix)]
1233    fn process_batch_strip_prefix_failpoint_injects_error() {
1234        let tmp = tempdir().unwrap();
1235        let src = tmp.path().join("src");
1236        let dst = tmp.path().join("dst");
1237        fs::create_dir_all(&src).unwrap();
1238        fs::write(src.join("a.txt"), "x").unwrap();
1239
1240        let _guard = FailGuard("stream::strip-prefix");
1241        fail::cfg("stream::strip-prefix", "return")
1242            .expect("activate failpoint");
1243        let err = process_batch(&src, &dst, stream_copy).unwrap_err();
1244        assert!(
1245            format!("{err:?}").contains("injected: stream::strip-prefix"),
1246            "got: {err:?}"
1247        );
1248    }
1249}
1250
1251#[cfg(test)]
1252mod proptests {
1253    use super::*;
1254    use proptest::prelude::*;
1255
1256    proptest! {
1257        #![proptest_config(ProptestConfig::with_cases(1000))]
1258
1259        /// Hashing the same content twice must yield the same fingerprint.
1260        #[test]
1261        fn stream_hash_deterministic(data in proptest::collection::vec(any::<u8>(), 0..4096)) {
1262            let dir = tempfile::tempdir().unwrap();
1263            let path = dir.path().join("input.bin");
1264            fs::write(&path, &data).unwrap();
1265
1266            let h1 = stream_hash(&path).unwrap();
1267            let h2 = stream_hash(&path).unwrap();
1268            prop_assert_eq!(h1, h2);
1269        }
1270    }
1271}