use std::path::Path;
use crate::error::BatchError;
use crate::handle::Handle;
use crate::pipeline::BatchOp;
#[must_use = "a Batch does nothing until committed; call .commit() or drop it explicitly"]
pub struct Batch<'a> {
handle: &'a Handle,
ops: Vec<BatchOp>,
}
impl<'a> Batch<'a> {
pub(crate) fn new(handle: &'a Handle) -> Self {
Self {
handle,
ops: Vec::new(),
}
}
pub fn write<P: AsRef<Path>>(&mut self, path: P, data: &[u8]) -> &mut Self {
self.ops.push(BatchOp::Write {
path: path.as_ref().to_path_buf(),
data: data.to_vec(),
});
self
}
pub fn delete<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
self.ops.push(BatchOp::Delete {
path: path.as_ref().to_path_buf(),
});
self
}
pub fn copy<P: AsRef<Path>, Q: AsRef<Path>>(&mut self, src: P, dst: Q) -> &mut Self {
self.ops.push(BatchOp::Copy {
src: src.as_ref().to_path_buf(),
dst: dst.as_ref().to_path_buf(),
});
self
}
#[must_use]
pub fn len(&self) -> usize {
self.ops.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.ops.is_empty()
}
#[must_use = "Batch::commit returns a Result reporting per-op failures; \
discarding it loses the failure-position information"]
pub fn commit(self) -> std::result::Result<(), BatchError> {
let Self { handle, ops } = self;
let resolved = resolve_ops(handle, ops)?;
handle.submit_batch(resolved)
}
#[must_use = "Batch::commit_grouped returns a Result reporting per-op failures; \
discarding it loses the failure-position information"]
pub fn commit_grouped(self) -> std::result::Result<(), BatchError> {
let Self { handle, ops } = self;
let resolved = resolve_ops(handle, ops)?;
handle.submit_batch_grouped(resolved)
}
}
fn resolve_ops(
handle: &Handle,
ops: Vec<BatchOp>,
) -> std::result::Result<Vec<BatchOp>, BatchError> {
let mut resolved: Vec<BatchOp> = Vec::with_capacity(ops.len());
for (i, op) in ops.into_iter().enumerate() {
match op {
BatchOp::Write { path, data } => {
let p = handle
.resolve_path(&path)
.map_err(|e| pre_submit_err(i, e))?;
resolved.push(BatchOp::Write { path: p, data });
}
BatchOp::Delete { path } => {
let p = handle
.resolve_path(&path)
.map_err(|e| pre_submit_err(i, e))?;
resolved.push(BatchOp::Delete { path: p });
}
BatchOp::Copy { src, dst } => {
let s = handle
.resolve_path(&src)
.map_err(|e| pre_submit_err(i, e))?;
let d = handle
.resolve_path(&dst)
.map_err(|e| pre_submit_err(i, e))?;
resolved.push(BatchOp::Copy { src: s, dst: d });
}
}
}
Ok(resolved)
}
fn pre_submit_err(index: usize, e: crate::Error) -> BatchError {
BatchError {
failed_at: index,
completed: 0,
source: Box::new(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builder::Builder;
use crate::method::Method;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn tmp_path(suffix: &str) -> PathBuf {
let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"fsys_batch_test_{}_{}_{}",
std::process::id(),
n,
suffix
))
}
fn handle() -> Handle {
Builder::new()
.method(Method::Sync)
.build()
.expect("build handle")
}
struct TmpFile(PathBuf);
impl Drop for TmpFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[test]
fn test_new_batch_is_empty() {
let h = handle();
let b = h.batch();
assert_eq!(b.len(), 0);
assert!(b.is_empty());
}
#[test]
fn test_write_appends_op() {
let h = handle();
let mut b = h.batch();
let _ = b.write("/tmp/x", b"y");
assert_eq!(b.len(), 1);
assert!(!b.is_empty());
}
#[test]
fn test_chained_calls_accumulate() {
let h = handle();
let mut b = h.batch();
let _ = b.write("a", b"1").write("b", b"2").delete("c");
assert_eq!(b.len(), 3);
}
#[test]
fn test_commit_executes_writes_in_order() {
let h = handle();
let p = tmp_path("commit_order");
let _g = TmpFile(p.clone());
let mut b = h.batch();
let _ = b
.write(&p, b"first")
.write(&p, b"second")
.write(&p, b"third");
b.commit().expect("commit");
assert_eq!(std::fs::read(&p).unwrap(), b"third");
}
#[test]
fn test_commit_executes_mixed_ops() {
let h = handle();
let written = tmp_path("mixed_written");
let copied_from = tmp_path("mixed_copy_src");
let copied_to = tmp_path("mixed_copy_dst");
let to_delete = tmp_path("mixed_delete");
let _g1 = TmpFile(written.clone());
let _g2 = TmpFile(copied_from.clone());
let _g3 = TmpFile(copied_to.clone());
let _g4 = TmpFile(to_delete.clone());
std::fs::write(&copied_from, b"src-data").unwrap();
std::fs::write(&to_delete, b"existing").unwrap();
let mut b = h.batch();
let _ = b
.write(&written, b"freshly-written")
.copy(&copied_from, &copied_to)
.delete(&to_delete);
b.commit().expect("commit");
assert_eq!(std::fs::read(&written).unwrap(), b"freshly-written");
assert_eq!(std::fs::read(&copied_to).unwrap(), b"src-data");
assert!(!to_delete.exists());
}
#[test]
fn test_commit_empty_batch_succeeds() {
let h = handle();
let b = h.batch();
b.commit().expect("empty batch should succeed");
}
#[test]
fn test_commit_reports_failure_index_for_op_error() {
let h = handle();
let good = tmp_path("good_op");
let bad_dir = tmp_path("bad_dir_op");
let _g1 = TmpFile(good.clone());
std::fs::create_dir_all(&bad_dir).unwrap();
struct DirGuard(PathBuf);
impl Drop for DirGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _g2 = DirGuard(bad_dir.clone());
let mut b = h.batch();
let _ = b
.write(&good, b"ok")
.write(&bad_dir, b"this-must-fail-target-is-dir")
.write(&good, b"never-reached");
let err = b.commit().expect_err("expected failure on op 1");
assert_eq!(err.failed_at, 1);
assert_eq!(err.completed, 1);
assert_eq!(std::fs::read(&good).unwrap(), b"ok");
}
#[test]
fn test_batch_holds_borrow_to_handle() {
let h = handle();
let mut b = h.batch();
let _ = b.write("x", b"y");
let len = b.len();
assert_eq!(len, 1);
drop(b);
drop(h);
}
#[test]
fn test_batch_must_use_attribute_documented() {
let h = handle();
let _ = h.batch(); }
#[test]
fn test_path_resolution_failure_reports_correct_index() {
let root = std::env::temp_dir().join(format!(
"fsys_batch_root_{}_{}",
std::process::id(),
TEST_COUNTER.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&root).unwrap();
struct DirGuard(PathBuf);
impl Drop for DirGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _rg = DirGuard(root.clone());
let h = Builder::new()
.root(&root)
.method(Method::Sync)
.build()
.expect("build with root");
let mut b = h.batch();
let _ = b
.write("ok-1", b"a")
.write("../../etc/passwd", b"escape")
.write("ok-2", b"b");
let err = b.commit().expect_err("escape must be rejected");
assert_eq!(err.failed_at, 1);
assert_eq!(err.completed, 0); match *err.source {
crate::Error::InvalidPath { .. } => { }
ref other => panic!("expected InvalidPath, got {:?}", other),
}
}
#[test]
fn test_commit_grouped_executes_writes_in_order() {
let h = handle();
let p = tmp_path("grouped_order");
let _g = TmpFile(p.clone());
let mut b = h.batch();
let _ = b
.write(&p, b"first")
.write(&p, b"second")
.write(&p, b"third");
b.commit_grouped().expect("commit_grouped");
assert_eq!(std::fs::read(&p).unwrap(), b"third");
}
#[test]
fn test_commit_grouped_mixed_ops_into_one_directory() {
let h = handle();
let a = tmp_path("grouped_a");
let bp = tmp_path("grouped_b");
let c = tmp_path("grouped_c");
let _ga = TmpFile(a.clone());
let _gb = TmpFile(bp.clone());
let _gc = TmpFile(c.clone());
let mut b = h.batch();
let _ = b
.write(&a, b"alpha")
.write(&bp, b"bravo")
.write(&c, b"charlie");
b.commit_grouped().expect("commit_grouped mixed");
assert_eq!(std::fs::read(&a).unwrap(), b"alpha");
assert_eq!(std::fs::read(&bp).unwrap(), b"bravo");
assert_eq!(std::fs::read(&c).unwrap(), b"charlie");
}
#[test]
fn test_commit_grouped_empty_batch_succeeds() {
let h = handle();
let b = h.batch();
b.commit_grouped()
.expect("empty grouped batch should succeed");
}
#[test]
fn test_commit_grouped_reports_failure_index() {
let h = handle();
let good = tmp_path("grouped_good");
let bad_dir = tmp_path("grouped_bad_dir");
let _g1 = TmpFile(good.clone());
std::fs::create_dir_all(&bad_dir).unwrap();
struct DirGuard(PathBuf);
impl Drop for DirGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _g2 = DirGuard(bad_dir.clone());
let mut b = h.batch();
let _ = b
.write(&good, b"ok")
.write(&bad_dir, b"target-is-dir-must-fail")
.write(&good, b"never-reached");
let err = b.commit_grouped().expect_err("expected failure on op 1");
assert_eq!(err.failed_at, 1);
assert_eq!(err.completed, 1);
assert_eq!(std::fs::read(&good).unwrap(), b"ok");
}
#[test]
fn test_commit_grouped_path_resolution_failure() {
let root = std::env::temp_dir().join(format!(
"fsys_batch_grouped_root_{}_{}",
std::process::id(),
TEST_COUNTER.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&root).unwrap();
struct DirGuard(PathBuf);
impl Drop for DirGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _rg = DirGuard(root.clone());
let h = Builder::new()
.root(&root)
.method(Method::Sync)
.build()
.expect("build with root");
let mut b = h.batch();
let _ = b
.write("ok-1", b"a")
.write("../../etc/passwd", b"escape")
.write("ok-2", b"b");
let err = b
.commit_grouped()
.expect_err("escape must be rejected in grouped mode too");
assert_eq!(err.failed_at, 1);
assert_eq!(err.completed, 0);
}
}