1use std::path::PathBuf;
4use std::time::{Duration, Instant};
5
6use serde::{Deserialize, Serialize};
7
8use crate::OperationError;
9
10const MAX_DIAGNOSTIC_PAYLOAD_BYTES: usize = 8 * 1024;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum OperationType {
15 Copy,
16 Move,
17 Delete,
18 Restore,
20 Rename,
21 CreateFile,
22 CreateDirectory,
23}
24
25impl std::fmt::Display for OperationType {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 match self {
28 Self::Copy => write!(f, "Copy"),
29 Self::Move => write!(f, "Move"),
30 Self::Delete => write!(f, "Delete"),
31 Self::Restore => write!(f, "Restore"),
32 Self::Rename => write!(f, "Rename"),
33 Self::CreateFile => write!(f, "Create file"),
34 Self::CreateDirectory => write!(f, "Create directory"),
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct OperationProgress {
42 pub operation_type: OperationType,
44 pub files_completed: usize,
46 pub files_total: usize,
48 pub bytes_processed: u64,
50 pub bytes_total: u64,
52 pub current_file: Option<PathBuf>,
54 pub errors: Vec<OperationError>,
56 pub started_at: Instant,
58}
59
60impl OperationProgress {
61 pub fn new(operation_type: OperationType, files_total: usize, bytes_total: u64) -> Self {
63 Self {
64 operation_type,
65 files_completed: 0,
66 files_total,
67 bytes_processed: 0,
68 bytes_total,
69 current_file: None,
70 errors: Vec::new(),
71 started_at: Instant::now(),
72 }
73 }
74
75 pub fn bytes_per_second(&self) -> f64 {
77 let elapsed = self.started_at.elapsed().as_secs_f64();
78 if elapsed > 0.0 {
79 self.bytes_processed as f64 / elapsed
80 } else {
81 0.0
82 }
83 }
84
85 pub fn eta(&self) -> Option<Duration> {
87 let rate = self.bytes_per_second();
88 let remaining = self.bytes_total.checked_sub(self.bytes_processed)?;
89 (rate >= 1.0 && self.bytes_total > 0)
90 .then(|| Duration::from_secs_f64(remaining as f64 / rate))
91 }
92
93 pub fn add_bytes(&mut self, bytes: u64) {
95 self.bytes_processed = self.bytes_processed.saturating_add(bytes);
96 }
97
98 pub fn percentage(&self) -> f64 {
101 if self.bytes_total > 0 {
102 ((self.bytes_processed as f64 / self.bytes_total as f64) * 100.0).min(100.0)
103 } else if self.files_total > 0 {
104 ((self.files_completed as f64 / self.files_total as f64) * 100.0).min(100.0)
105 } else {
106 0.0
107 }
108 }
109
110 pub fn has_errors(&self) -> bool {
112 !self.errors.is_empty()
113 }
114
115 pub fn error_count(&self) -> usize {
117 self.errors.iter().fold(0usize, |total, error| {
118 total.saturating_add(error.represented_count())
119 })
120 }
121
122 pub fn add_error(&mut self, error: OperationError) {
127 const MAX_RETAINED_ERRORS: usize = 1000;
128 let payload_bytes = error
129 .path
130 .as_os_str()
131 .as_encoded_bytes()
132 .len()
133 .saturating_add(error.message.len());
134 let error = if payload_bytes > MAX_DIAGNOSTIC_PAYLOAD_BYTES {
135 let mut omitted = OperationError::omitted(error.represented_count());
136 omitted
137 .message
138 .push_str(" (diagnostic payload exceeded 8 KiB)");
139 omitted
140 } else {
141 error
142 };
143 if self.errors.len() < MAX_RETAINED_ERRORS {
144 self.errors.push(error);
145 } else {
146 let omitted = self
147 .errors
148 .drain(MAX_RETAINED_ERRORS - 1..)
149 .fold(error.represented_count(), |total, prior| {
150 total.saturating_add(prior.represented_count())
151 });
152 self.errors.push(OperationError::omitted(omitted));
153 }
154 }
155
156 pub fn set_current_file(&mut self, path: Option<PathBuf>) {
158 self.current_file = path;
159 }
160
161 pub fn complete_file(&mut self, bytes: u64) {
163 self.files_completed = self.files_completed.saturating_add(1);
164 self.bytes_processed = self.bytes_processed.saturating_add(bytes);
165 }
166}
167
168#[derive(Debug, Clone)]
170pub struct OperationComplete {
171 pub cancelled: bool,
174 pub operation_type: OperationType,
176 pub succeeded: usize,
178 pub failed: usize,
180 pub skipped: usize,
182 pub bytes_processed: u64,
184 pub errors: Vec<OperationError>,
186}
187
188impl OperationComplete {
189 pub fn is_success(&self) -> bool {
191 self.failed == 0 && self.errors.is_empty() && !self.cancelled
192 }
193
194 pub fn summary(&self) -> String {
196 let action = match self.operation_type {
197 OperationType::Copy => "Copied",
198 OperationType::Move => "Moved",
199 OperationType::Delete => "Deleted",
200 OperationType::Restore => "Restored",
201 OperationType::Rename => "Renamed",
202 OperationType::CreateFile => "Created",
203 OperationType::CreateDirectory => "Created",
204 };
205
206 let mut summary = format!("{action} {} items", self.succeeded);
207 if self.skipped > 0 {
208 summary.push_str(&format!(", {} skipped", self.skipped));
209 }
210 if self.failed > 0 {
211 summary.push_str(&format!(", {} failed", self.failed));
212 } else if !self.errors.is_empty() {
213 summary.push_str(&format!(
214 ", {} errors",
215 self.errors.iter().fold(0usize, |total, error| total
216 .saturating_add(error.represented_count()))
217 ));
218 }
219 if self.cancelled {
220 summary.push_str("; operation cancelled");
221 }
222 summary
223 }
224}
225
226pub(crate) fn publish_progress<T>(
231 tx: &tokio::sync::mpsc::Sender<T>,
232 make_progress: impl FnOnce() -> T,
233 token: &tokio_util::sync::CancellationToken,
234) {
235 match tx.try_reserve() {
236 Ok(permit) => permit.send(make_progress()),
237 Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => token.cancel(),
238 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {}
239 }
240}
241
242#[cfg(test)]
243mod delivery_tests {
244 use super::*;
245 use tokio_util::sync::CancellationToken;
246
247 #[test]
248 fn diagnostic_payload_limit_preserves_boundaries_and_failure_counts() {
249 let mut progress = OperationProgress::new(OperationType::Copy, 0, 0);
250 let path = PathBuf::from("item");
251 let message = "x".repeat(MAX_DIAGNOSTIC_PAYLOAD_BYTES - 4);
252 progress.add_error(OperationError::new(path.clone(), message.clone()));
253 assert_eq!(progress.errors[0].path, path);
254 assert_eq!(progress.errors[0].message, message);
255 progress.add_error(OperationError::new(path, message + "x"));
256 assert_eq!(progress.errors[1].omitted_count, 1);
257 assert!(progress.errors[1].path.as_os_str().is_empty());
258 assert!(progress.errors[1].message.contains("exceeded 8 KiB"));
259 let mut oversized_omission = OperationError::omitted(37);
260 oversized_omission.message = "界".repeat(MAX_DIAGNOSTIC_PAYLOAD_BYTES);
261 progress.add_error(oversized_omission);
262 assert_eq!(progress.error_count(), 39);
263 assert_eq!(progress.errors[2].omitted_count, 37);
264 for _ in 0..2000 {
265 progress.add_error(OperationError::new(
266 PathBuf::from("p".repeat(MAX_DIAGNOSTIC_PAYLOAD_BYTES + 1)),
267 "failed",
268 ));
269 }
270 assert_eq!(progress.error_count(), 2039);
271 assert_eq!(progress.errors.len(), 1000);
272 assert!(progress.errors.iter().all(|error| {
273 error.path.as_os_str().as_encoded_bytes().len() + error.message.len()
274 <= MAX_DIAGNOSTIC_PAYLOAD_BYTES
275 }));
276 }
277
278 #[cfg(unix)]
279 #[test]
280 fn diagnostic_payload_budget_uses_native_path_bytes() {
281 use std::os::unix::ffi::OsStringExt;
282 let path = PathBuf::from(std::ffi::OsString::from_vec(vec![0xff; 4096]));
283 let mut progress = OperationProgress::new(OperationType::Copy, 0, 0);
284 progress.add_error(OperationError::new(path.clone(), "x".repeat(4096)));
285 assert_eq!(progress.errors[0].path, path);
286 assert_eq!(progress.errors[0].omitted_count, 0);
287 progress.add_error(OperationError::new(path, "x".repeat(4097)));
288 assert_eq!(progress.errors[1].omitted_count, 1);
289 }
290
291 #[test]
292 fn retained_diagnostics_are_bounded_without_losing_total_counts() {
293 let mut progress = OperationProgress::new(OperationType::Copy, 100_000, 0);
294 for index in 0..100_000 {
295 progress.add_error(OperationError::new(
296 PathBuf::from(format!("item-{index}")),
297 "failed",
298 ));
299 assert!(progress.errors.len() <= 1000);
300 if [998, 999, 1000, 99_999].contains(&index) {
301 assert_eq!(progress.error_count(), index + 1);
302 }
303 }
304 assert_eq!(progress.errors.len(), 1000);
305 for (index, error) in progress.errors[..999].iter().enumerate() {
306 assert_eq!(error.path, PathBuf::from(format!("item-{index}")));
307 assert_eq!(error.omitted_count, 0);
308 }
309 assert_eq!(progress.errors[999].omitted_count, 99_001);
310 assert!(progress.errors[999].path.as_os_str().is_empty());
311 assert!(progress.errors[999].message.contains("99001"));
312 assert_eq!(progress.clone().error_count(), 100_000);
313 let completion = OperationComplete {
314 operation_type: OperationType::Copy,
315 cancelled: false,
316 succeeded: 0,
317 failed: 0,
318 skipped: 0,
319 bytes_processed: 0,
320 errors: progress.errors,
321 };
322 assert!(!completion.is_success());
323 assert!(completion.summary().contains("100000 errors"));
324 let legacy: OperationError =
325 serde_json::from_str(r#"{"path":"legacy","message":"failure"}"#).unwrap();
326 assert_eq!(legacy.omitted_count, 0);
327 assert_eq!(legacy.represented_count(), 1);
328 }
329
330 #[test]
331 fn completion_diagnostics_prevent_success_even_without_failure_counts() {
332 for cancelled in [false, true] {
333 for failed in [0, 1] {
334 for errors in [
335 Vec::new(),
336 vec![OperationError::new(PathBuf::from("item"), "failure")],
337 ] {
338 let complete = OperationComplete {
339 operation_type: OperationType::Copy,
340 cancelled,
341 failed,
342 succeeded: 1,
343 skipped: 2,
344 bytes_processed: 0,
345 errors,
346 };
347 assert_eq!(
348 complete.is_success(),
349 !cancelled && failed == 0 && complete.errors.is_empty()
350 );
351 let summary = complete.summary();
352 assert!(summary.contains("2 skipped"));
353 assert_eq!(summary.contains("1 failed"), failed == 1);
354 assert_eq!(
355 summary.contains("1 errors"),
356 failed == 0 && !complete.errors.is_empty()
357 );
358 assert_eq!(summary.contains("cancelled"), cancelled);
359 }
360 }
361 }
362 }
363
364 #[test]
365 fn progress_is_bounded_for_unknown_stale_and_extreme_totals() {
366 let mut progress = OperationProgress::new(OperationType::Copy, 0, 0);
367 assert_eq!(progress.percentage(), 0.0);
368 progress.files_total = 4;
369 progress.complete_file(0);
370 assert_eq!(progress.percentage(), 25.0);
371 progress.bytes_total = 8;
372 progress.bytes_processed = 4;
373 assert_eq!(progress.percentage(), 50.0);
374 for files_total in [0, 1, usize::MAX] {
375 for bytes_total in [0, 1, u64::MAX] {
376 progress.files_total = files_total;
377 progress.bytes_total = bytes_total;
378 progress.files_completed = usize::MAX;
379 progress.bytes_processed = u64::MAX;
380 progress.complete_file(u64::MAX);
381 assert_eq!(progress.files_completed, usize::MAX);
382 assert_eq!(progress.bytes_processed, u64::MAX);
383 let expected = if files_total == 0 && bytes_total == 0 {
384 0.0
385 } else {
386 100.0
387 };
388 assert_eq!(progress.percentage(), expected);
389 }
390 }
391 }
392
393 #[test]
394 fn full_progress_queue_is_nonblocking_and_does_not_cancel() {
395 let (tx, mut rx) = tokio::sync::mpsc::channel(1);
396 tx.try_send(1).unwrap();
397 let token = CancellationToken::new();
398 publish_progress(
399 &tx,
400 || panic!("full queue must not construct a snapshot"),
401 &token,
402 );
403 assert!(!token.is_cancelled());
404 assert_eq!(rx.try_recv().unwrap(), 1);
405 assert!(rx.try_recv().is_err());
406 }
407
408 #[test]
409 fn closed_progress_receiver_cancels_work() {
410 let (tx, rx) = tokio::sync::mpsc::channel::<usize>(1);
411 drop(rx);
412 let token = CancellationToken::new();
413 publish_progress(
414 &tx,
415 || panic!("closed queue must not construct a snapshot"),
416 &token,
417 );
418 assert!(token.is_cancelled());
419 }
420
421 #[test]
422 fn snapshots_resume_after_backpressure_without_building_discarded_updates() {
423 let (tx, mut rx) = tokio::sync::mpsc::channel(1);
424 let token = CancellationToken::new();
425 let constructed = std::cell::Cell::new(0);
426 let snapshot = || {
427 constructed.set(constructed.get() + 1);
428 constructed.get()
429 };
430 publish_progress(&tx, snapshot, &token);
431 for _ in 0..10_000 {
432 publish_progress(&tx, snapshot, &token);
433 }
434 assert_eq!(constructed.get(), 1);
435 assert_eq!(rx.try_recv().unwrap(), 1);
436 publish_progress(&tx, snapshot, &token);
437 assert_eq!(constructed.get(), 2);
438 assert_eq!(rx.try_recv().unwrap(), 2);
439 assert!(!token.is_cancelled());
440 }
441}