#![allow(dead_code)]
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CopyMode {
#[default]
Buffered,
Sparse,
Chunked,
}
impl std::fmt::Display for CopyMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CopyMode::Buffered => write!(f, "buffered"),
CopyMode::Sparse => write!(f, "sparse"),
CopyMode::Chunked => write!(f, "chunked"),
}
}
}
#[derive(Debug, Clone)]
pub struct CopyJob {
pub src: PathBuf,
pub dst: PathBuf,
pub mode: CopyMode,
pub chunk_size: usize,
pub overwrite: bool,
}
impl CopyJob {
pub fn new(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Self {
Self {
src: src.as_ref().to_path_buf(),
dst: dst.as_ref().to_path_buf(),
mode: CopyMode::default(),
chunk_size: 64 * 1024,
overwrite: true,
}
}
#[must_use]
pub fn with_mode(mut self, mode: CopyMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub fn with_chunk_size(mut self, size: usize) -> Self {
self.chunk_size = size;
self
}
#[must_use]
pub fn with_overwrite(mut self, overwrite: bool) -> Self {
self.overwrite = overwrite;
self
}
}
#[derive(Debug, Clone)]
pub struct CopyResult {
pub bytes_copied: u64,
pub elapsed_secs: f64,
}
impl CopyResult {
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn throughput_mbps(&self) -> f64 {
if self.elapsed_secs == 0.0 {
return 0.0;
}
(self.bytes_copied as f64) / (self.elapsed_secs * 1024.0 * 1024.0)
}
}
pub struct CopyEngine;
impl CopyEngine {
#[must_use]
pub fn new() -> Self {
Self
}
pub fn run(&self, job: &CopyJob) -> io::Result<CopyResult> {
if !job.overwrite && job.dst.exists() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"destination already exists and overwrite is disabled",
));
}
let start = Instant::now();
let bytes_copied = match job.mode {
CopyMode::Buffered | CopyMode::Sparse => std::fs::copy(&job.src, &job.dst)?,
CopyMode::Chunked => Self::copy_chunked(&job.src, &job.dst, job.chunk_size)?,
};
let elapsed_secs = start.elapsed().as_secs_f64();
Ok(CopyResult {
bytes_copied,
elapsed_secs,
})
}
fn copy_chunked(src: &Path, dst: &Path, chunk_size: usize) -> io::Result<u64> {
let mut reader = std::fs::File::open(src)?;
let mut writer = std::fs::File::create(dst)?;
let mut buf = vec![0u8; chunk_size];
let mut total: u64 = 0;
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
writer.write_all(&buf[..n])?;
total += n as u64;
}
Ok(total)
}
}
impl Default for CopyEngine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn make_src(content: &[u8]) -> (tempfile::NamedTempFile, PathBuf) {
let mut f = tempfile::NamedTempFile::new().expect("failed to create temp file");
f.write_all(content).expect("failed to write");
let p = f.path().to_path_buf();
(f, p)
}
fn dst_path() -> (tempfile::NamedTempFile, PathBuf) {
let f = tempfile::NamedTempFile::new().expect("failed to create temp file");
let p = f.path().to_path_buf();
(f, p)
}
#[test]
fn test_copy_buffered_basic() {
let (_sf, src) = make_src(b"hello world");
let (_df, dst) = dst_path();
let engine = CopyEngine::new();
let job = CopyJob::new(&src, &dst);
let result = engine.run(&job).expect("copy should succeed");
assert_eq!(result.bytes_copied, 11);
assert_eq!(
std::fs::read(&dst).expect("failed to read file"),
b"hello world"
);
}
#[test]
fn test_copy_chunked() {
let data: Vec<u8> = (0u8..=255).collect();
let (_sf, src) = make_src(&data);
let (_df, dst) = dst_path();
let engine = CopyEngine::new();
let job = CopyJob::new(&src, &dst)
.with_mode(CopyMode::Chunked)
.with_chunk_size(32);
let result = engine.run(&job).expect("copy should succeed");
assert_eq!(result.bytes_copied, 256);
assert_eq!(std::fs::read(&dst).expect("failed to read file"), data);
}
#[test]
fn test_copy_sparse_mode() {
let (_sf, src) = make_src(b"sparse data");
let (_df, dst) = dst_path();
let engine = CopyEngine::new();
let job = CopyJob::new(&src, &dst).with_mode(CopyMode::Sparse);
let result = engine.run(&job).expect("copy should succeed");
assert_eq!(result.bytes_copied, 11);
}
#[test]
fn test_copy_no_overwrite_existing_fails() {
let (_sf, src) = make_src(b"original");
let (_df, dst) = dst_path();
let engine = CopyEngine::new();
engine
.run(&CopyJob::new(&src, &dst))
.expect("copy should succeed");
let job = CopyJob::new(&src, &dst).with_overwrite(false);
let err = engine.run(&job).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
}
#[test]
fn test_copy_overwrite_existing_succeeds() {
let (_sf, src) = make_src(b"new content");
let (_df, dst) = dst_path();
let engine = CopyEngine::new();
engine
.run(&CopyJob::new(&src, &dst))
.expect("copy should succeed");
let result = engine
.run(&CopyJob::new(&src, &dst))
.expect("copy should succeed");
assert_eq!(result.bytes_copied, 11);
}
#[test]
fn test_throughput_mbps_nonzero() {
let data = vec![42u8; 1024 * 1024];
let (_sf, src) = make_src(&data);
let (_df, dst) = dst_path();
let engine = CopyEngine::new();
let result = engine
.run(&CopyJob::new(&src, &dst))
.expect("copy should succeed");
assert!(result.throughput_mbps() > 0.0);
}
#[test]
fn test_throughput_zero_elapsed() {
let r = CopyResult {
bytes_copied: 1000,
elapsed_secs: 0.0,
};
assert_eq!(r.throughput_mbps(), 0.0);
}
#[test]
fn test_copy_empty_file() {
let (_sf, src) = make_src(b"");
let (_df, dst) = dst_path();
let engine = CopyEngine::new();
let result = engine
.run(&CopyJob::new(&src, &dst))
.expect("copy should succeed");
assert_eq!(result.bytes_copied, 0);
}
#[test]
fn test_copy_mode_default() {
assert_eq!(CopyMode::default(), CopyMode::Buffered);
}
#[test]
fn test_copy_mode_display() {
assert_eq!(CopyMode::Buffered.to_string(), "buffered");
assert_eq!(CopyMode::Sparse.to_string(), "sparse");
assert_eq!(CopyMode::Chunked.to_string(), "chunked");
}
#[test]
fn test_copy_job_builder_chain() {
let job = CopyJob::new("/src", "/dst")
.with_mode(CopyMode::Chunked)
.with_chunk_size(8192)
.with_overwrite(false);
assert_eq!(job.mode, CopyMode::Chunked);
assert_eq!(job.chunk_size, 8192);
assert!(!job.overwrite);
}
#[test]
fn test_copy_nonexistent_src_fails() {
let engine = CopyEngine::new();
let job = CopyJob::new("/nonexistent/file.bin", "/tmp/dst_oxi_test.bin");
assert!(engine.run(&job).is_err());
}
#[test]
fn test_elapsed_secs_positive() {
let data = vec![0u8; 512];
let (_sf, src) = make_src(&data);
let (_df, dst) = dst_path();
let engine = CopyEngine::new();
let result = engine
.run(&CopyJob::new(&src, &dst))
.expect("copy should succeed");
assert!(result.elapsed_secs >= 0.0);
}
#[test]
fn test_copy_engine_default() {
let _engine = CopyEngine::default();
}
#[test]
fn test_copy_large_chunk_larger_than_file() {
let data = b"small";
let (_sf, src) = make_src(data);
let (_df, dst) = dst_path();
let engine = CopyEngine::new();
let job = CopyJob::new(&src, &dst)
.with_mode(CopyMode::Chunked)
.with_chunk_size(1024 * 1024);
let result = engine.run(&job).expect("copy should succeed");
assert_eq!(result.bytes_copied, data.len() as u64);
}
}