1use std::fs::{File, OpenOptions};
4use std::io::{Read, Seek, SeekFrom, Write};
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use crate::IoError;
9
10static NEXT_SPOOL_ID: AtomicU64 = AtomicU64::new(0);
11
12#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct SpoolOptions {
15 directory: PathBuf,
16 limit_bytes: u64,
17}
18
19impl SpoolOptions {
20 pub fn new(directory: impl Into<PathBuf>, limit_bytes: u64) -> Result<Self, IoError> {
22 let directory = directory.into();
23 if limit_bytes == 0 {
24 return Err(IoError::Streaming("spool limit must be positive".into()));
25 }
26 if !directory.is_dir() {
27 return Err(IoError::Streaming(format!(
28 "spool directory does not exist: {}",
29 directory.display()
30 )));
31 }
32 Ok(Self { directory, limit_bytes })
33 }
34
35 #[must_use]
37 pub fn directory(&self) -> &Path {
38 &self.directory
39 }
40
41 #[must_use]
43 pub const fn limit_bytes(&self) -> u64 {
44 self.limit_bytes
45 }
46}
47
48pub struct BoundedSpool {
51 file: Option<File>,
52 path: PathBuf,
53 limit_bytes: u64,
54 extent_bytes: u64,
55 committed: bool,
56}
57
58impl BoundedSpool {
59 pub fn create(options: &SpoolOptions, stem: &str) -> Result<Self, IoError> {
61 let safe_stem: String = stem
62 .chars()
63 .map(|character| {
64 if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
65 character
66 } else {
67 '_'
68 }
69 })
70 .collect();
71 for _ in 0..100 {
72 let id = NEXT_SPOOL_ID.fetch_add(1, Ordering::Relaxed);
73 let path =
74 options.directory.join(format!(".{safe_stem}.{}.{}.part", std::process::id(), id));
75 match OpenOptions::new().read(true).write(true).create_new(true).open(&path) {
76 Ok(file) => {
77 return Ok(Self {
78 file: Some(file),
79 path,
80 limit_bytes: options.limit_bytes,
81 extent_bytes: 0,
82 committed: false,
83 });
84 }
85 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
86 Err(error) => return Err(error.into()),
87 }
88 }
89 Err(IoError::Streaming("could not allocate a unique temporary spool".into()))
90 }
91
92 #[must_use]
94 pub fn path(&self) -> &Path {
95 &self.path
96 }
97
98 #[must_use]
100 pub const fn extent_bytes(&self) -> u64 {
101 self.extent_bytes
102 }
103
104 pub fn commit(mut self, destination: impl AsRef<Path>) -> Result<PathBuf, IoError> {
108 let destination = destination.as_ref();
109 if destination.exists() {
110 return Err(IoError::Streaming(format!(
111 "spool destination already exists: {}",
112 destination.display()
113 )));
114 }
115 if let Some(mut file) = self.file.take() {
116 file.flush()?;
117 file.sync_all()?;
118 }
119 std::fs::rename(&self.path, destination)?;
120 self.committed = true;
121 Ok(destination.to_path_buf())
122 }
123
124 fn file_mut(&mut self) -> std::io::Result<&mut File> {
125 self.file.as_mut().ok_or_else(|| std::io::Error::other("spool is closed"))
126 }
127}
128
129impl Write for BoundedSpool {
130 fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
131 let position = self.file_mut()?.stream_position()?;
132 let requested_end = position
133 .checked_add(buffer.len() as u64)
134 .ok_or_else(|| std::io::Error::other("spool extent overflow"))?;
135 if requested_end > self.limit_bytes {
136 return Err(std::io::Error::other(format!(
137 "spool limit exceeded: requested extent {requested_end}, limit {}",
138 self.limit_bytes
139 )));
140 }
141 let written = self.file_mut()?.write(buffer)?;
142 self.extent_bytes = self.extent_bytes.max(position + written as u64);
143 Ok(written)
144 }
145
146 fn flush(&mut self) -> std::io::Result<()> {
147 self.file_mut()?.flush()
148 }
149}
150
151impl Read for BoundedSpool {
152 fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
153 self.file_mut()?.read(buffer)
154 }
155}
156
157impl Seek for BoundedSpool {
158 fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
159 self.file_mut()?.seek(position)
160 }
161}
162
163impl Drop for BoundedSpool {
164 fn drop(&mut self) {
165 self.file.take();
166 if !self.committed {
167 let _ = std::fs::remove_file(&self.path);
168 }
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::{BoundedSpool, SpoolOptions};
175 use std::io::{Seek, SeekFrom, Write};
176
177 #[test]
178 fn rejects_growth_before_limit_is_crossed_and_cleans_up() {
179 let options = SpoolOptions::new(std::env::temp_dir(), 4).unwrap();
180 let path;
181 {
182 let mut spool = BoundedSpool::create(&options, "bounded-test").unwrap();
183 path = spool.path().to_path_buf();
184 spool.write_all(b"1234").unwrap();
185 assert!(spool.write_all(b"5").is_err());
186 spool.seek(SeekFrom::Start(1)).unwrap();
187 spool.write_all(b"x").unwrap();
188 assert_eq!(spool.extent_bytes(), 4);
189 }
190 assert!(!path.exists());
191 }
192}