1use 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
29pub const STREAM_BUFFER_SIZE: usize = 8 * 1024;
32
33pub const MAX_BATCH_SIZE: usize = 100_000;
36
37#[derive(Debug, Clone, Copy)]
39pub struct BatchResult {
40 pub files_processed: usize,
42 pub bytes_read: u64,
44 pub bytes_written: u64,
46 pub duration_ms: f64,
48 pub throughput: f64,
50}
51
52pub 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
89fn 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
124pub 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
166pub 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 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
247fn 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
265fn collect_files_bounded(dir: &Path) -> Result<Vec<PathBuf>> {
270 collect_files_bounded_with_limit(dir, MAX_BATCH_SIZE)
271}
272
273fn 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(¤t)
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
310pub 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#[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 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 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 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 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 assert!(files.len() <= 4);
570 }
571
572 #[test]
573 fn collect_files_bounded_with_limit_breaks_on_inner_loop_saturation() {
574 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 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 let bytes = stream_copy(&src, &dst).unwrap();
649
650 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 let tmp = tempdir().unwrap();
659 let path = tmp.path().join("empty.bin");
660 fs::write(&path, b"").unwrap();
661
662 let h1 = stream_hash(&path).unwrap();
664 let h2 = stream_hash(&path).unwrap();
665
666 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 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 let hash_a = stream_hash(&a).unwrap();
683 let hash_b = stream_hash(&b).unwrap();
684
685 assert_eq!(hash_a, hash_b, "same content must produce same hash");
687 }
688
689 #[test]
690 fn stream_lines_binary_content() {
691 let tmp = tempdir().unwrap();
693 let path = tmp.path().join("binary.bin");
694 fs::write(&path, "no-newlines-here").unwrap();
695
696 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_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 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 let result = process_batch(&src, &dst, stream_copy).unwrap();
720
721 assert_eq!(result.files_processed, 0);
723 assert_eq!(result.bytes_read, 0);
724 assert_eq!(result.bytes_written, 0);
725 }
726
727 #[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 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 #[test]
799 fn stream_hash_multi_chunk_file() {
800 let tmp = tempdir().unwrap();
801 let path = tmp.path().join("multi_chunk.bin");
802 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 #[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 #[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 #[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 #[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 #[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 #[test]
1027 fn constants_are_sensible() {
1028 assert_eq!(STREAM_BUFFER_SIZE, 8192);
1029 assert_eq!(MAX_BATCH_SIZE, 100_000);
1030 }
1031
1032 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 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 #[cfg(unix)]
1127 #[test]
1128 fn stream_hash_read_error_on_directory() {
1129 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 #[test]
1154 #[serial_test::parallel(stream_strip_prefix)]
1155 fn process_batch_dst_creation_failure_fires_context_closure() {
1156 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 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 #[test]
1192 fn compute_throughput_zero_duration_is_infinite() {
1193 let (duration_ms, throughput) =
1194 compute_throughput(10, std::time::Duration::ZERO);
1195 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#[cfg(all(test, feature = "test-fault-injection"))]
1217mod fault_injection {
1218 use super::*;
1219 use std::fs;
1220 use tempfile::tempdir;
1221
1222 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 #[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}