atomic-progress 0.1.5

A high-performance, cloneable progress tracker with minimal locking overhead.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
//! Core primitives for tracking progress state.
//!
//! This module defines the [`Progress`] struct, which acts as the central handle for
//! updates. It is designed around a "Hot/Cold" split to maximize performance in
//! multi-threaded environments:
//!
//! * **Hot Data:** Position, Total, and Finished state are stored in `Atomic` primitives.
//!   This allows high-frequency updates (e.g., in tight loops) without locking contention.
//! * **Cold Data:** Metadata like names, current items, and error states are guarded by
//!   an [`RwLock`](parking_lot::RwLock). These are accessed less frequently, typically
//!   only by the rendering thread or when significant state changes occur.
//!
//! # Snapshots
//!
//! To render progress safely, use [`Progress::snapshot`] to obtain a [`ProgressSnapshot`].
//! This provides a consistent, immutable view of the progress state at a specific instant,
//! calculating derived metrics like ETA and throughput automatically.

use std::{
    sync::{
        Arc,
        atomic::{AtomicBool, AtomicU64, Ordering},
    },
    time::Duration,
};

use compact_str::CompactString;
use parking_lot::RwLock;
use web_time::Instant;

/// A thread-safe, cloneable handle to a progress indicator.
///
/// `Progress` separates "hot" data (position, total, finished status) which are stored in
/// atomics for high-performance updates, from "cold" data (names, errors, timing) which are
/// guarded by an [`RwLock`].
///
/// Cloning a `Progress` is cheap (Arc bump) and points to the same underlying state.
#[derive(Clone)]
pub struct Progress {
    /// The type of progress indicator (Bar vs Spinner). Immutable after creation.
    pub(crate) kind: ProgressType,

    /// The instant the progress tracker was created/started.
    pub(crate) start: Option<Instant>,

    /// Infrequently accessed metadata (name, error state, stop time).
    pub(crate) cold: Arc<RwLock<Cold>>,

    /// The current "item" being processed (e.g., filename).
    pub(crate) item: Arc<RwLock<CompactString>>,

    // Atomic fields for wait-free updates on the hot path.
    pub(crate) position: Arc<AtomicU64>,
    pub(crate) total: Arc<AtomicU64>,
    pub(crate) finished: Arc<AtomicBool>,
}

/// "Cold" storage for metadata that changes infrequently.
pub struct Cold {
    pub(crate) name: CompactString,
    pub(crate) stopped: Option<Instant>,
    pub(crate) error: Option<CompactString>,
}

/// Defines the behavior/visualization hint for the progress indicator.
#[repr(u8)]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "rkyv", rkyv(derive(Debug, Eq, PartialEq)))]
pub enum ProgressType {
    /// A spinner, used when the total number of items is unknown.
    #[default]
    Spinner,
    /// A progress bar, used when the total is known.
    Bar,
}

/// Slightly dangerous, but convenient to derive inside larger structs.
/// Be sure to only use this for debugging purposes, as it may not reflect the most up-to-date state if other threads are modifying it concurrently.
/// This is not suitable for production code or any logic that relies on consistent state.
impl std::fmt::Debug for Progress {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let cold = self.cold.read();
        f.debug_struct("Progress")
            .field("kind", &self.kind)
            .field("start", &self.start)
            .field("name", &cold.name)
            .field("item", &self.item.read())
            .field("position", &self.position.load(Ordering::Relaxed))
            .field("total", &self.total.load(Ordering::Relaxed))
            .field("finished", &self.finished.load(Ordering::Relaxed))
            .field("error", &cold.error)
            .finish()
    }
}

impl Progress {
    /// Creates a new `Progress` instance.
    ///
    /// # Parameters
    ///
    /// * `kind`: The type of indicator.
    /// * `name`: A label for the task.
    /// * `total`: The total expected count (use 0 for spinners).
    pub fn new(kind: ProgressType, name: impl Into<CompactString>, total: impl Into<u64>) -> Self {
        Self {
            kind,
            start: None,
            cold: Arc::new(RwLock::new(Cold {
                name: name.into(),
                stopped: None,
                error: None,
            })),
            item: Arc::new(RwLock::new(CompactString::default())),
            position: Arc::new(AtomicU64::new(0)),
            total: Arc::new(AtomicU64::new(total.into())),
            finished: Arc::new(AtomicBool::new(false)),
        }
    }

    /// Creates a new generic progress bar with a known total.
    #[must_use]
    pub fn new_pb(name: impl Into<CompactString>, total: impl Into<u64>) -> Self {
        Self::new(ProgressType::Bar, name, total)
    }

    /// Creates a new spinner (indeterminate progress).
    #[must_use]
    pub fn new_spinner(name: impl Into<CompactString>) -> Self {
        Self::new(ProgressType::Spinner, name, 0u64)
    }

    // ========================================================================
    // Metadata Accessors
    // ========================================================================

    /// Gets the current name/label of the progress task.
    #[must_use]
    pub fn get_name(&self) -> CompactString {
        self.cold.read().name.clone()
    }

    /// Updates the name/label of the progress task.
    pub fn set_name(&self, name: impl Into<CompactString>) {
        self.cold.write().name = name.into();
    }

    /// Gets the current item description (e.g., currently processing file).
    #[must_use]
    pub fn get_item(&self) -> CompactString {
        self.item.read().clone()
    }

    /// Updates the current item description.
    pub fn set_item(&self, item: impl Into<CompactString>) {
        *self.item.write() = item.into();
    }

    /// Returns the error message, if one occurred.
    #[must_use]
    pub fn get_error(&self) -> Option<CompactString> {
        self.cold.read().error.clone()
    }

    /// Sets (or clears) an error message for this task.
    pub fn set_error(&self, error: Option<impl Into<CompactString>>) {
        let error = error.map(Into::into);
        self.cold.write().error = error;
    }

    // ========================================================================
    // State & Metrics (Hot Path)
    // ========================================================================

    /// Increments the progress position by the specified amount.
    ///
    /// This uses `Ordering::Relaxed` for maximum performance.
    pub fn inc(&self, amount: impl Into<u64>) {
        self.position.fetch_add(amount.into(), Ordering::Relaxed);
    }

    /// Increments the progress position by 1.
    pub fn bump(&self) {
        self.inc(1u64);
    }

    /// Gets the current position.
    #[must_use]
    pub fn get_pos(&self) -> u64 {
        self.position.load(Ordering::Relaxed)
    }

    /// Sets the absolute position.
    pub fn set_pos(&self, pos: u64) {
        self.position.store(pos, Ordering::Relaxed);
    }

    /// Gets the total target count.
    #[must_use]
    pub fn get_total(&self) -> u64 {
        self.total.load(Ordering::Relaxed)
    }

    /// Updates the total target count.
    pub fn set_total(&self, total: u64) {
        self.total.store(total, Ordering::Relaxed);
    }

    /// Checks if the task is marked as finished.
    #[must_use]
    pub fn is_finished(&self) -> bool {
        // Acquire ensures we see any memory writes that happened before the finish flag was set.
        self.finished.load(Ordering::Acquire)
    }

    /// Manually sets the finished state.
    ///
    /// Prefer using [`finish`](Self::finish), [`finish_with_item`](Self::finish_with_item),
    /// or [`finish_with_error`](Self::finish_with_error) to ensure timestamps are recorded.
    pub fn set_finished(&self, finished: bool) {
        self.finished.store(finished, Ordering::Release);
    }

    // ========================================================================
    // Timing & Calculations
    // ========================================================================

    /// Calculates the duration elapsed since creation.
    ///
    /// If the task is finished, this returns the duration between start and finish.
    /// If never started (no start time recorded), returns `None`.
    #[must_use]
    pub fn get_elapsed(&self) -> Option<Duration> {
        let start = self.start?;
        let cold = self.cold.read();

        Some(
            cold.stopped
                .map_or_else(|| start.elapsed(), |stopped| stopped.duration_since(start)),
        )
    }

    /// Returns the current completion percentage (0.0 to 100.0).
    ///
    /// Returns `0.0` if `total` is zero.
    #[allow(clippy::cast_precision_loss)]
    #[must_use]
    pub fn get_percent(&self) -> f64 {
        let pos = self.get_pos() as f64;
        let total = self.get_total() as f64;

        if total == 0.0 {
            0.0
        } else {
            (pos / total) * 100.0
        }
    }

    // ========================================================================
    // Lifecycle Management
    // ========================================================================

    /// Marks the task as finished and records the stop time.
    pub fn finish(&self) {
        if self.start.is_some() {
            self.cold.write().stopped.replace(Instant::now());
        }
        self.set_finished(true);
    }

    /// Sets the current item and marks the task as finished.
    pub fn finish_with_item(&self, item: impl Into<CompactString>) {
        self.set_item(item);
        self.finish(); // Calls set_finished(true) internally
    }

    /// Sets an error message and marks the task as finished.
    pub fn finish_with_error(&self, error: impl Into<CompactString>) {
        self.set_error(Some(error));
        self.finish();
    }

    // ========================================================================
    // Advanced / Internal
    // ========================================================================

    /// Returns a shared reference to the atomic position counter.
    ///
    /// Useful for sharing this specific counter with other systems.
    #[must_use]
    pub fn atomic_pos(&self) -> Arc<AtomicU64> {
        self.position.clone()
    }

    /// Returns a shared reference to the atomic total counter.
    #[must_use]
    pub fn atomic_total(&self) -> Arc<AtomicU64> {
        self.total.clone()
    }

    /// Creates a consistent snapshot of the current state.
    ///
    /// This involves acquiring a read lock on the "cold" data.
    #[must_use]
    pub fn snapshot(&self) -> ProgressSnapshot {
        self.into()
    }
}

/// A plain-data snapshot of a [`Progress`] state at a specific point in time.
///
/// This is typically used for rendering, as it holds owned data and requires no locking
/// to access.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "rkyv", rkyv(derive(Debug, Eq, PartialEq)))]
pub struct ProgressSnapshot {
    /// The type of progress indicator.
    pub kind: ProgressType,

    /// The name/label of the progress task.
    pub name: CompactString,
    /// The current item description.
    pub item: CompactString,

    /// The elapsed duration.
    pub elapsed: Option<Duration>,

    /// The current position.
    pub position: u64,
    /// The total target count.
    pub total: u64,

    /// Whether the task is finished.
    pub finished: bool,

    /// The associated error message, if any.
    pub error: Option<CompactString>,
}

impl From<&Progress> for ProgressSnapshot {
    fn from(progress: &Progress) -> Self {
        // Lock cold data once
        let cold = progress.cold.read();
        let name = cold.name.clone();
        let error = cold.error.clone();
        drop(cold);

        Self {
            kind: progress.kind,
            name,
            item: progress.item.read().clone(),
            elapsed: progress.get_elapsed(),
            position: progress.position.load(Ordering::Relaxed),
            total: progress.total.load(Ordering::Relaxed),
            finished: progress.finished.load(Ordering::Relaxed),
            error,
        }
    }
}

impl ProgressSnapshot {
    /// Returns the type of progress indicator.
    #[must_use]
    pub const fn kind(&self) -> ProgressType {
        self.kind
    }

    /// Returns the name/label of the progress task.
    #[must_use]
    pub fn name(&self) -> &str {
        &self.name
    }
    /// Returns the current item description.
    #[must_use]
    pub fn item(&self) -> &str {
        &self.item
    }

    /// Returns the elapsed duration.
    #[must_use]
    pub const fn elapsed(&self) -> Option<Duration> {
        self.elapsed
    }

    /// Returns the current position.
    #[must_use]
    pub const fn position(&self) -> u64 {
        self.position
    }
    /// Returns the total target count.
    #[must_use]
    pub const fn total(&self) -> u64 {
        self.total
    }

    /// Returns whether the task is finished.
    #[must_use]
    pub const fn finished(&self) -> bool {
        self.finished
    }

    /// Returns the error message, if any.
    #[must_use]
    pub fn error(&self) -> Option<&str> {
        self.error.as_deref()
    }

    /// Estimates the time remaining (ETA) based on average speed since start.
    ///
    /// Returns `None` if:
    /// * No progress has been made.
    /// * Total is zero.
    /// * Process is finished.
    /// * Elapsed time is effectively zero.
    #[allow(clippy::cast_precision_loss)]
    #[must_use]
    pub fn eta(&self) -> Option<Duration> {
        if self.position == 0 || self.total == 0 || self.finished {
            return None;
        }

        let elapsed = self.elapsed?;
        let secs = elapsed.as_secs_f64();

        // Avoid division by zero or extremely small intervals
        if secs <= 1e-6 {
            return None;
        }

        let rate = self.position as f64 / secs;
        if rate <= 0.0 {
            return None;
        }

        let remaining_items = self.total.saturating_sub(self.position);
        let remaining_secs = remaining_items as f64 / rate;

        Some(Duration::from_secs_f64(remaining_secs))
    }

    /// Calculates the average throughput (items per second) over the entire lifetime.
    #[allow(clippy::cast_precision_loss)]
    #[must_use]
    pub fn throughput(&self) -> f64 {
        if let Some(elapsed) = self.elapsed {
            let secs = elapsed.as_secs_f64();
            if secs > 0.0 {
                return self.position as f64 / secs;
            }
        }
        0.0
    }

    /// Calculates the instantaneous throughput relative to a previous snapshot.
    ///
    /// This is useful for calculating "current speed" (e.g., in the last second).
    #[allow(clippy::cast_precision_loss)]
    #[must_use]
    pub fn throughput_since(&self, prev: &Self) -> f64 {
        let pos_diff = self.position.saturating_sub(prev.position) as f64;

        let time_diff = match (self.elapsed, prev.elapsed) {
            (Some(curr), Some(old)) => curr.as_secs_f64() - old.as_secs_f64(),
            _ => 0.0,
        };

        if time_diff > 0.0 {
            pos_diff / time_diff
        } else {
            0.0
        }
    }
}

#[cfg(test)]
mod tests {
    use std::thread;

    use super::Progress;

    /// Basic Lifecycle
    /// Verifies the fundamental state machine: New -> Inc -> Finish.
    #[test]
    #[allow(clippy::float_cmp)]
    fn test_basic_lifecycle() {
        let p = Progress::new_pb("test_job", 100u64);

        assert_eq!(p.get_pos(), 0);
        assert!(!p.is_finished());
        assert_eq!(p.get_percent(), 0.0);

        p.inc(50u64);
        assert_eq!(p.get_pos(), 50);
        assert_eq!(p.get_percent(), 50.0);

        p.finish();
        assert!(p.is_finished());

        // Default constructor does not start the timer; elapsed should be None.
        assert!(p.get_elapsed().is_none());
    }

    /// Concurrency & Atomics
    /// Ensures that high-contention updates from multiple threads are lossless.
    #[test]
    fn test_concurrency_atomics() {
        let p = Progress::new_spinner("concurrent_job");
        let mut handles = vec![];

        // Spawn 10 threads, each incrementing 100 times
        for _ in 0..10 {
            let p_ref = p.clone();
            handles.push(thread::spawn(move || {
                for _ in 0..100 {
                    p_ref.inc(1u64);
                }
            }));
        }

        for h in handles {
            h.join().unwrap();
        }

        assert_eq!(p.get_pos(), 1000, "Atomic updates should be lossless");
    }

    /// Snapshot Metadata
    /// Verifies that "Cold" data (names, errors) propagates to snapshots correctly.
    #[test]
    fn test_snapshot_metadata() {
        let p = Progress::new_pb("initial_name", 100u64);

        // Mutate cold state
        p.set_name("updated_name");
        p.set_item("file_a.txt");
        p.set_error(Some("disk_full"));

        let snap = p.snapshot();

        assert_eq!(snap.name, "updated_name");
        assert_eq!(snap.item, "file_a.txt");
        assert_eq!(snap.error, Some("disk_full".into()));
    }

    /// Throughput & ETA Safety
    /// Verifies mathematical correctness and edge-case safety (NaN/Inf checks).
    #[allow(clippy::float_cmp)]
    #[test]
    fn test_math_safety() {
        let p = Progress::new_pb("math_test", 100u64);
        let snap = p.snapshot();

        // Edge case: No time elapsed, no progress
        assert_eq!(snap.throughput(), 0.0);
        assert!(snap.eta().is_none());

        // We can't easily mock time without dependency injection or sleeping.
        // We settle for verifying that 0 total handles percentage gracefully.
        let p_zero = Progress::new_pb("zero_total", 0u64);
        assert_eq!(p_zero.get_percent(), 0.0);
    }
}