Skip to main content

common/
progress.rs

1use tracing::instrument;
2
3/// Number of shards for the counter. More shards reduce contention but increase memory.
4/// 64 shards × 128 bytes = 8KB per counter, which virtually eliminates contention.
5const NUM_SHARDS: usize = 64;
6
7/// Atomic counter padded to cache line size to prevent false sharing.
8/// Each shard lives on its own cache line so concurrent updates from different
9/// threads don't cause cache invalidation.
10/// Uses 128B alignment to support both x86-64 (64B) and ARM (128B) cache lines.
11#[repr(align(128))]
12struct PaddedAtomicU64(std::sync::atomic::AtomicU64);
13
14/// Global counter for assigning shard indices to threads.
15/// Each thread gets a unique index (mod NUM_SHARDS) on first access.
16static NEXT_SHARD_INDEX: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
17
18thread_local! {
19    /// Per-thread shard index, assigned once on first access.
20    /// Uses modulo to wrap around when more threads than shards.
21    static MY_SHARD: usize =
22        NEXT_SHARD_INDEX.fetch_add(1, std::sync::atomic::Ordering::Relaxed) % NUM_SHARDS;
23}
24
25/// Sharded atomic counter optimized for concurrent access from multiple threads.
26///
27/// Uses cache-line-padded shards to prevent false sharing. Each thread is assigned
28/// a shard index, so updates from different threads typically hit different cache lines.
29///
30/// This design handles interleaved access to multiple counters efficiently - unlike
31/// a single-slot cache approach, there's no "cache thrashing" when alternating between
32/// counters.
33///
34/// # Memory
35///
36/// Each counter uses NUM_SHARDS × 128 bytes = 8KB (with 64 shards).
37/// This is larger than a simple AtomicU64 but virtually eliminates contention.
38pub struct TlsCounter {
39    shards: [PaddedAtomicU64; NUM_SHARDS],
40}
41
42impl TlsCounter {
43    #[must_use]
44    pub fn new() -> Self {
45        Self {
46            shards: std::array::from_fn(|_| PaddedAtomicU64(std::sync::atomic::AtomicU64::new(0))),
47        }
48    }
49
50    pub fn add(&self, value: u64) {
51        let shard = MY_SHARD.with(|&s| s);
52        self.shards[shard]
53            .0
54            .fetch_add(value, std::sync::atomic::Ordering::Relaxed);
55    }
56
57    pub fn inc(&self) {
58        self.add(1);
59    }
60
61    pub fn get(&self) -> u64 {
62        self.shards
63            .iter()
64            .map(|s| s.0.load(std::sync::atomic::Ordering::Relaxed))
65            .sum()
66    }
67}
68
69impl std::fmt::Debug for TlsCounter {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.debug_struct("TlsCounter")
72            .field("value", &self.get())
73            .finish()
74    }
75}
76
77impl Default for TlsCounter {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83#[derive(Debug)]
84pub struct ProgressCounter {
85    started: TlsCounter,
86    finished: TlsCounter,
87}
88
89impl Default for ProgressCounter {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95pub struct ProgressGuard<'a> {
96    progress: &'a ProgressCounter,
97}
98
99impl<'a> ProgressGuard<'a> {
100    pub fn new(progress: &'a ProgressCounter) -> Self {
101        progress.started.inc();
102        Self { progress }
103    }
104}
105
106impl Drop for ProgressGuard<'_> {
107    fn drop(&mut self) {
108        self.progress.finished.inc();
109    }
110}
111
112pub struct Status {
113    pub started: u64,
114    pub finished: u64,
115}
116
117impl ProgressCounter {
118    #[must_use]
119    pub fn new() -> Self {
120        Self {
121            started: TlsCounter::new(),
122            finished: TlsCounter::new(),
123        }
124    }
125
126    pub fn guard(&self) -> ProgressGuard<'_> {
127        ProgressGuard::new(self)
128    }
129
130    #[instrument]
131    pub fn get(&self) -> Status {
132        let mut status = Status {
133            started: self.started.get(),
134            finished: self.finished.get(),
135        };
136        if status.finished > status.started {
137            tracing::debug!(
138                "Progress inversion - started: {}, finished {}",
139                status.started,
140                status.finished
141            );
142            status.started = status.finished;
143        }
144        status
145    }
146}
147
148pub struct Progress {
149    pub ops: ProgressCounter,
150    pub bytes_copied: TlsCounter,
151    pub hard_links_created: TlsCounter,
152    pub files_copied: TlsCounter,
153    pub symlinks_created: TlsCounter,
154    pub directories_created: TlsCounter,
155    pub files_changed: TlsCounter,
156    pub symlinks_changed: TlsCounter,
157    pub directories_changed: TlsCounter,
158    pub files_unchanged: TlsCounter,
159    pub symlinks_unchanged: TlsCounter,
160    pub directories_unchanged: TlsCounter,
161    pub hard_links_unchanged: TlsCounter,
162    pub files_removed: TlsCounter,
163    pub symlinks_removed: TlsCounter,
164    pub directories_removed: TlsCounter,
165    pub bytes_removed: TlsCounter,
166    pub files_skipped: TlsCounter,
167    pub symlinks_skipped: TlsCounter,
168    pub directories_skipped: TlsCounter,
169    pub specials_skipped: TlsCounter,
170    start_time: std::time::Instant,
171}
172
173impl Progress {
174    #[must_use]
175    pub fn new() -> Self {
176        Self {
177            ops: Default::default(),
178            bytes_copied: Default::default(),
179            hard_links_created: Default::default(),
180            files_copied: Default::default(),
181            symlinks_created: Default::default(),
182            directories_created: Default::default(),
183            files_changed: Default::default(),
184            symlinks_changed: Default::default(),
185            directories_changed: Default::default(),
186            files_unchanged: Default::default(),
187            symlinks_unchanged: Default::default(),
188            directories_unchanged: Default::default(),
189            hard_links_unchanged: Default::default(),
190            files_removed: Default::default(),
191            symlinks_removed: Default::default(),
192            directories_removed: Default::default(),
193            bytes_removed: Default::default(),
194            files_skipped: Default::default(),
195            symlinks_skipped: Default::default(),
196            directories_skipped: Default::default(),
197            specials_skipped: Default::default(),
198            start_time: std::time::Instant::now(),
199        }
200    }
201
202    pub fn get_duration(&self) -> std::time::Duration {
203        self.start_time.elapsed()
204    }
205}
206
207impl Default for Progress {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
214pub struct SerializableProgress {
215    pub ops_started: u64,
216    pub ops_finished: u64,
217    pub bytes_copied: u64,
218    pub hard_links_created: u64,
219    pub files_copied: u64,
220    pub symlinks_created: u64,
221    pub directories_created: u64,
222    pub files_unchanged: u64,
223    pub symlinks_unchanged: u64,
224    pub directories_unchanged: u64,
225    pub hard_links_unchanged: u64,
226    pub files_removed: u64,
227    pub symlinks_removed: u64,
228    pub directories_removed: u64,
229    pub bytes_removed: u64,
230    pub files_skipped: u64,
231    pub symlinks_skipped: u64,
232    pub directories_skipped: u64,
233    pub specials_skipped: u64,
234    pub current_time: std::time::SystemTime,
235}
236
237impl Default for SerializableProgress {
238    fn default() -> Self {
239        Self {
240            ops_started: 0,
241            ops_finished: 0,
242            bytes_copied: 0,
243            hard_links_created: 0,
244            files_copied: 0,
245            symlinks_created: 0,
246            directories_created: 0,
247            files_unchanged: 0,
248            symlinks_unchanged: 0,
249            directories_unchanged: 0,
250            hard_links_unchanged: 0,
251            files_removed: 0,
252            symlinks_removed: 0,
253            directories_removed: 0,
254            bytes_removed: 0,
255            files_skipped: 0,
256            symlinks_skipped: 0,
257            directories_skipped: 0,
258            specials_skipped: 0,
259            current_time: std::time::SystemTime::now(),
260        }
261    }
262}
263
264impl From<&Progress> for SerializableProgress {
265    /// Creates a `SerializableProgress` from a Progress, capturing the current time at the moment of conversion
266    fn from(progress: &Progress) -> Self {
267        let ops = progress.ops.get();
268        Self {
269            ops_started: ops.started,
270            ops_finished: ops.finished,
271            bytes_copied: progress.bytes_copied.get(),
272            hard_links_created: progress.hard_links_created.get(),
273            files_copied: progress.files_copied.get(),
274            symlinks_created: progress.symlinks_created.get(),
275            directories_created: progress.directories_created.get(),
276            files_unchanged: progress.files_unchanged.get(),
277            symlinks_unchanged: progress.symlinks_unchanged.get(),
278            directories_unchanged: progress.directories_unchanged.get(),
279            hard_links_unchanged: progress.hard_links_unchanged.get(),
280            files_removed: progress.files_removed.get(),
281            symlinks_removed: progress.symlinks_removed.get(),
282            directories_removed: progress.directories_removed.get(),
283            bytes_removed: progress.bytes_removed.get(),
284            files_skipped: progress.files_skipped.get(),
285            symlinks_skipped: progress.symlinks_skipped.get(),
286            directories_skipped: progress.directories_skipped.get(),
287            specials_skipped: progress.specials_skipped.get(),
288            current_time: std::time::SystemTime::now(),
289        }
290    }
291}
292
293/// Trait implemented by per-tool progress printers.
294///
295/// Each tool has its own printer that only renders sections relevant to what
296/// the tool actually does (e.g. `rrm` has no COPIED section; `rcmp` only shows
297/// OPS). Prefer adding a new specialized printer over extending a shared one.
298pub trait LocalProgressReport: Send {
299    fn print(&mut self) -> anyhow::Result<String>;
300}
301
302/// Selects which tool-specific printer to instantiate for a local progress run.
303#[derive(Copy, Clone, Debug)]
304pub enum LocalProgressKind {
305    Copy,
306    Remove,
307    Link,
308    Compare,
309    Filegen,
310    Modify,
311}
312
313fn ops_rates(
314    progress: &Progress,
315    last_ops: u64,
316    last_update: std::time::Instant,
317    now: std::time::Instant,
318) -> (Status, f64, f64) {
319    let ops = progress.ops.get();
320    let total_secs = progress.get_duration().as_secs_f64();
321    let curr_secs = (now - last_update).as_secs_f64();
322    let average = if total_secs > 0.0 {
323        ops.finished as f64 / total_secs
324    } else {
325        0.0
326    };
327    let current = if curr_secs > 0.0 {
328        (ops.finished - last_ops) as f64 / curr_secs
329    } else {
330        0.0
331    };
332    (ops, average, current)
333}
334
335fn bytes_rates(
336    total: u64,
337    last: u64,
338    total_secs: f64,
339    curr_secs: f64,
340) -> (bytesize::ByteSize, bytesize::ByteSize) {
341    let average = if total_secs > 0.0 {
342        total as f64 / total_secs
343    } else {
344        0.0
345    };
346    let current = if curr_secs > 0.0 {
347        (total - last) as f64 / curr_secs
348    } else {
349        0.0
350    };
351    (
352        bytesize::ByteSize(average as u64),
353        bytesize::ByteSize(current as u64),
354    )
355}
356
357/// Progress printer for `rcp` (local copy). Shows COPIED/UNCHANGED/REMOVED/SKIPPED
358/// for files, symlinks, and directories. Hard-link counters are omitted because
359/// `rcp` never creates hard links.
360pub struct CopyProgressPrinter<'a> {
361    progress: &'a Progress,
362    last_ops: u64,
363    last_bytes: u64,
364    last_update: std::time::Instant,
365}
366
367impl<'a> CopyProgressPrinter<'a> {
368    pub fn new(progress: &'a Progress) -> Self {
369        Self {
370            progress,
371            last_ops: progress.ops.get().finished,
372            last_bytes: progress.bytes_copied.get(),
373            last_update: std::time::Instant::now(),
374        }
375    }
376}
377
378impl<'a> LocalProgressReport for CopyProgressPrinter<'a> {
379    fn print(&mut self) -> anyhow::Result<String> {
380        let now = std::time::Instant::now();
381        let (ops, avg_ops, cur_ops) =
382            ops_rates(self.progress, self.last_ops, self.last_update, now);
383        let bytes = self.progress.bytes_copied.get();
384        let total_secs = self.progress.get_duration().as_secs_f64();
385        let curr_secs = (now - self.last_update).as_secs_f64();
386        let (avg_bytes, cur_bytes) = bytes_rates(bytes, self.last_bytes, total_secs, curr_secs);
387        self.last_ops = ops.finished;
388        self.last_bytes = bytes;
389        self.last_update = now;
390        Ok(format!(
391            "---------------------\n\
392            OPS:\n\
393            pending: {:>10}\n\
394            average: {:>10.2} items/s\n\
395            current: {:>10.2} items/s\n\
396            -----------------------\n\
397            COPIED:\n\
398            average: {:>10}/s\n\
399            current: {:>10}/s\n\
400            bytes:   {:>10}\n\
401            files:       {:>10}\n\
402            symlinks:    {:>10}\n\
403            directories: {:>10}\n\
404            -----------------------\n\
405            UNCHANGED:\n\
406            files:       {:>10}\n\
407            symlinks:    {:>10}\n\
408            directories: {:>10}\n\
409            -----------------------\n\
410            REMOVED:\n\
411            bytes:       {:>10}\n\
412            files:       {:>10}\n\
413            symlinks:    {:>10}\n\
414            directories: {:>10}\n\
415            -----------------------\n\
416            SKIPPED:\n\
417            files:       {:>10}\n\
418            symlinks:    {:>10}\n\
419            directories: {:>10}\n\
420            specials:    {:>10}",
421            ops.started - ops.finished,
422            avg_ops,
423            cur_ops,
424            avg_bytes,
425            cur_bytes,
426            bytesize::ByteSize(bytes),
427            self.progress.files_copied.get(),
428            self.progress.symlinks_created.get(),
429            self.progress.directories_created.get(),
430            self.progress.files_unchanged.get(),
431            self.progress.symlinks_unchanged.get(),
432            self.progress.directories_unchanged.get(),
433            bytesize::ByteSize(self.progress.bytes_removed.get()),
434            self.progress.files_removed.get(),
435            self.progress.symlinks_removed.get(),
436            self.progress.directories_removed.get(),
437            self.progress.files_skipped.get(),
438            self.progress.symlinks_skipped.get(),
439            self.progress.directories_skipped.get(),
440            self.progress.specials_skipped.get(),
441        ))
442    }
443}
444
445/// Progress printer for `rrm`. Only shows REMOVED and SKIPPED — `rrm` never
446/// creates, copies, or leaves entries unchanged.
447pub struct RemoveProgressPrinter<'a> {
448    progress: &'a Progress,
449    last_ops: u64,
450    last_bytes_removed: u64,
451    last_update: std::time::Instant,
452}
453
454impl<'a> RemoveProgressPrinter<'a> {
455    pub fn new(progress: &'a Progress) -> Self {
456        Self {
457            progress,
458            last_ops: progress.ops.get().finished,
459            last_bytes_removed: progress.bytes_removed.get(),
460            last_update: std::time::Instant::now(),
461        }
462    }
463}
464
465impl<'a> LocalProgressReport for RemoveProgressPrinter<'a> {
466    fn print(&mut self) -> anyhow::Result<String> {
467        let now = std::time::Instant::now();
468        let (ops, avg_ops, cur_ops) =
469            ops_rates(self.progress, self.last_ops, self.last_update, now);
470        let bytes_removed = self.progress.bytes_removed.get();
471        let total_secs = self.progress.get_duration().as_secs_f64();
472        let curr_secs = (now - self.last_update).as_secs_f64();
473        let (avg_bytes, cur_bytes) = bytes_rates(
474            bytes_removed,
475            self.last_bytes_removed,
476            total_secs,
477            curr_secs,
478        );
479        self.last_ops = ops.finished;
480        self.last_bytes_removed = bytes_removed;
481        self.last_update = now;
482        Ok(format!(
483            "---------------------\n\
484            OPS:\n\
485            pending: {:>10}\n\
486            average: {:>10.2} items/s\n\
487            current: {:>10.2} items/s\n\
488            -----------------------\n\
489            REMOVED:\n\
490            average: {:>10}/s\n\
491            current: {:>10}/s\n\
492            bytes:       {:>10}\n\
493            files:       {:>10}\n\
494            symlinks:    {:>10}\n\
495            directories: {:>10}\n\
496            -----------------------\n\
497            SKIPPED:\n\
498            files:       {:>10}\n\
499            symlinks:    {:>10}\n\
500            directories: {:>10}",
501            ops.started - ops.finished,
502            avg_ops,
503            cur_ops,
504            avg_bytes,
505            cur_bytes,
506            bytesize::ByteSize(bytes_removed),
507            self.progress.files_removed.get(),
508            self.progress.symlinks_removed.get(),
509            self.progress.directories_removed.get(),
510            self.progress.files_skipped.get(),
511            self.progress.symlinks_skipped.get(),
512            self.progress.directories_skipped.get(),
513        ))
514    }
515}
516
517/// Progress printer for `rchm`. Reports CHANGED / UNCHANGED / SKIPPED counts per
518/// entry type; rchm performs only metadata ops, so there are no byte counters.
519pub struct ModifyProgressPrinter<'a> {
520    progress: &'a Progress,
521    last_ops: u64,
522    last_update: std::time::Instant,
523}
524
525impl<'a> ModifyProgressPrinter<'a> {
526    pub fn new(progress: &'a Progress) -> Self {
527        Self {
528            progress,
529            last_ops: progress.ops.get().finished,
530            last_update: std::time::Instant::now(),
531        }
532    }
533}
534
535impl<'a> LocalProgressReport for ModifyProgressPrinter<'a> {
536    fn print(&mut self) -> anyhow::Result<String> {
537        let now = std::time::Instant::now();
538        let (ops, avg_ops, cur_ops) =
539            ops_rates(self.progress, self.last_ops, self.last_update, now);
540        self.last_ops = ops.finished;
541        self.last_update = now;
542        Ok(format!(
543            "---------------------\n\
544            OPS:\n\
545            pending: {:>10}\n\
546            average: {:>10.2} items/s\n\
547            current: {:>10.2} items/s\n\
548            -----------------------\n\
549            CHANGED:\n\
550            files:       {:>10}\n\
551            symlinks:    {:>10}\n\
552            directories: {:>10}\n\
553            -----------------------\n\
554            UNCHANGED:\n\
555            files:       {:>10}\n\
556            symlinks:    {:>10}\n\
557            directories: {:>10}\n\
558            -----------------------\n\
559            SKIPPED:\n\
560            files:       {:>10}\n\
561            symlinks:    {:>10}\n\
562            directories: {:>10}",
563            ops.started - ops.finished,
564            avg_ops,
565            cur_ops,
566            self.progress.files_changed.get(),
567            self.progress.symlinks_changed.get(),
568            self.progress.directories_changed.get(),
569            self.progress.files_unchanged.get(),
570            self.progress.symlinks_unchanged.get(),
571            self.progress.directories_unchanged.get(),
572            self.progress.files_skipped.get(),
573            self.progress.symlinks_skipped.get(),
574            self.progress.directories_skipped.get(),
575        ))
576    }
577}
578
579/// Progress printer for `rlink`. Like [`CopyProgressPrinter`] but also reports
580/// hard-link counters, since `rlink`'s primary output is hard links (with copies
581/// as a fallback for `--update` changes).
582pub struct LinkProgressPrinter<'a> {
583    progress: &'a Progress,
584    last_ops: u64,
585    last_bytes: u64,
586    last_update: std::time::Instant,
587}
588
589impl<'a> LinkProgressPrinter<'a> {
590    pub fn new(progress: &'a Progress) -> Self {
591        Self {
592            progress,
593            last_ops: progress.ops.get().finished,
594            last_bytes: progress.bytes_copied.get(),
595            last_update: std::time::Instant::now(),
596        }
597    }
598}
599
600impl<'a> LocalProgressReport for LinkProgressPrinter<'a> {
601    fn print(&mut self) -> anyhow::Result<String> {
602        let now = std::time::Instant::now();
603        let (ops, avg_ops, cur_ops) =
604            ops_rates(self.progress, self.last_ops, self.last_update, now);
605        let bytes = self.progress.bytes_copied.get();
606        let total_secs = self.progress.get_duration().as_secs_f64();
607        let curr_secs = (now - self.last_update).as_secs_f64();
608        let (avg_bytes, cur_bytes) = bytes_rates(bytes, self.last_bytes, total_secs, curr_secs);
609        self.last_ops = ops.finished;
610        self.last_bytes = bytes;
611        self.last_update = now;
612        Ok(format!(
613            "---------------------\n\
614            OPS:\n\
615            pending: {:>10}\n\
616            average: {:>10.2} items/s\n\
617            current: {:>10.2} items/s\n\
618            -----------------------\n\
619            LINKED / COPIED:\n\
620            average: {:>10}/s\n\
621            current: {:>10}/s\n\
622            bytes:   {:>10}\n\
623            hard-links:  {:>10}\n\
624            files:       {:>10}\n\
625            symlinks:    {:>10}\n\
626            directories: {:>10}\n\
627            -----------------------\n\
628            UNCHANGED:\n\
629            hard-links:  {:>10}\n\
630            files:       {:>10}\n\
631            symlinks:    {:>10}\n\
632            directories: {:>10}\n\
633            -----------------------\n\
634            REMOVED:\n\
635            bytes:       {:>10}\n\
636            files:       {:>10}\n\
637            symlinks:    {:>10}\n\
638            directories: {:>10}\n\
639            -----------------------\n\
640            SKIPPED:\n\
641            files:       {:>10}\n\
642            symlinks:    {:>10}\n\
643            directories: {:>10}\n\
644            specials:    {:>10}",
645            ops.started - ops.finished,
646            avg_ops,
647            cur_ops,
648            avg_bytes,
649            cur_bytes,
650            bytesize::ByteSize(bytes),
651            self.progress.hard_links_created.get(),
652            self.progress.files_copied.get(),
653            self.progress.symlinks_created.get(),
654            self.progress.directories_created.get(),
655            self.progress.hard_links_unchanged.get(),
656            self.progress.files_unchanged.get(),
657            self.progress.symlinks_unchanged.get(),
658            self.progress.directories_unchanged.get(),
659            bytesize::ByteSize(self.progress.bytes_removed.get()),
660            self.progress.files_removed.get(),
661            self.progress.symlinks_removed.get(),
662            self.progress.directories_removed.get(),
663            self.progress.files_skipped.get(),
664            self.progress.symlinks_skipped.get(),
665            self.progress.directories_skipped.get(),
666            self.progress.specials_skipped.get(),
667        ))
668    }
669}
670
671/// Progress printer for `rcmp`. Compare operations only drive the ops counter —
672/// they don't copy, remove, or modify anything — so only OPS is shown.
673pub struct CompareProgressPrinter<'a> {
674    progress: &'a Progress,
675    last_ops: u64,
676    last_update: std::time::Instant,
677}
678
679impl<'a> CompareProgressPrinter<'a> {
680    pub fn new(progress: &'a Progress) -> Self {
681        Self {
682            progress,
683            last_ops: progress.ops.get().finished,
684            last_update: std::time::Instant::now(),
685        }
686    }
687}
688
689impl<'a> LocalProgressReport for CompareProgressPrinter<'a> {
690    fn print(&mut self) -> anyhow::Result<String> {
691        let now = std::time::Instant::now();
692        let (ops, avg_ops, cur_ops) =
693            ops_rates(self.progress, self.last_ops, self.last_update, now);
694        self.last_ops = ops.finished;
695        self.last_update = now;
696        Ok(format!(
697            "---------------------\n\
698            OPS:\n\
699            pending: {:>10}\n\
700            compared: {:>9}\n\
701            average: {:>10.2} items/s\n\
702            current: {:>10.2} items/s",
703            ops.started - ops.finished,
704            ops.finished,
705            avg_ops,
706            cur_ops,
707        ))
708    }
709}
710
711/// Progress printer for `filegen`. Shows a GENERATED section instead of COPIED,
712/// because `filegen` creates files and directories rather than copying them.
713pub struct FilegenProgressPrinter<'a> {
714    progress: &'a Progress,
715    last_ops: u64,
716    last_bytes: u64,
717    last_update: std::time::Instant,
718}
719
720impl<'a> FilegenProgressPrinter<'a> {
721    pub fn new(progress: &'a Progress) -> Self {
722        Self {
723            progress,
724            last_ops: progress.ops.get().finished,
725            last_bytes: progress.bytes_copied.get(),
726            last_update: std::time::Instant::now(),
727        }
728    }
729}
730
731impl<'a> LocalProgressReport for FilegenProgressPrinter<'a> {
732    fn print(&mut self) -> anyhow::Result<String> {
733        let now = std::time::Instant::now();
734        let (ops, avg_ops, cur_ops) =
735            ops_rates(self.progress, self.last_ops, self.last_update, now);
736        let bytes = self.progress.bytes_copied.get();
737        let total_secs = self.progress.get_duration().as_secs_f64();
738        let curr_secs = (now - self.last_update).as_secs_f64();
739        let (avg_bytes, cur_bytes) = bytes_rates(bytes, self.last_bytes, total_secs, curr_secs);
740        self.last_ops = ops.finished;
741        self.last_bytes = bytes;
742        self.last_update = now;
743        Ok(format!(
744            "---------------------\n\
745            OPS:\n\
746            pending: {:>10}\n\
747            average: {:>10.2} items/s\n\
748            current: {:>10.2} items/s\n\
749            -----------------------\n\
750            GENERATED:\n\
751            average: {:>10}/s\n\
752            current: {:>10}/s\n\
753            bytes:       {:>10}\n\
754            files:       {:>10}\n\
755            directories: {:>10}",
756            ops.started - ops.finished,
757            avg_ops,
758            cur_ops,
759            avg_bytes,
760            cur_bytes,
761            bytesize::ByteSize(bytes),
762            self.progress.files_copied.get(),
763            self.progress.directories_created.get(),
764        ))
765    }
766}
767
768/// Construct the tool-specific printer for the given [`LocalProgressKind`],
769/// borrowing the global [`Progress`] for its lifetime.
770#[must_use]
771pub fn make_local_printer<'a>(
772    kind: LocalProgressKind,
773    progress: &'a Progress,
774) -> Box<dyn LocalProgressReport + 'a> {
775    match kind {
776        LocalProgressKind::Copy => Box::new(CopyProgressPrinter::new(progress)),
777        LocalProgressKind::Remove => Box::new(RemoveProgressPrinter::new(progress)),
778        LocalProgressKind::Link => Box::new(LinkProgressPrinter::new(progress)),
779        LocalProgressKind::Compare => Box::new(CompareProgressPrinter::new(progress)),
780        LocalProgressKind::Filegen => Box::new(FilegenProgressPrinter::new(progress)),
781        LocalProgressKind::Modify => Box::new(ModifyProgressPrinter::new(progress)),
782    }
783}
784
785pub struct RcpdProgressPrinter {
786    start_time: std::time::Instant,
787    last_source_ops: u64,
788    last_source_bytes: u64,
789    last_source_files: u64,
790    last_dest_ops: u64,
791    last_dest_bytes: u64,
792    last_update: std::time::Instant,
793}
794
795impl RcpdProgressPrinter {
796    #[must_use]
797    pub fn new() -> Self {
798        let now = std::time::Instant::now();
799        Self {
800            start_time: now,
801            last_source_ops: 0,
802            last_source_bytes: 0,
803            last_source_files: 0,
804            last_dest_ops: 0,
805            last_dest_bytes: 0,
806            last_update: now,
807        }
808    }
809
810    fn calculate_current_rate(&self, current: u64, last: u64, duration_secs: f64) -> f64 {
811        if duration_secs > 0.0 {
812            (current - last) as f64 / duration_secs
813        } else {
814            0.0
815        }
816    }
817
818    fn calculate_average_rate(&self, total: u64, total_duration_secs: f64) -> f64 {
819        if total_duration_secs > 0.0 {
820            total as f64 / total_duration_secs
821        } else {
822            0.0
823        }
824    }
825
826    pub fn print(
827        &mut self,
828        source_progress: &SerializableProgress,
829        dest_progress: &SerializableProgress,
830    ) -> anyhow::Result<String> {
831        let time_now = std::time::Instant::now();
832        let total_duration_secs = (time_now - self.start_time).as_secs_f64();
833        let curr_duration_secs = (time_now - self.last_update).as_secs_f64();
834        // source current rates
835        let source_ops_rate_curr = self.calculate_current_rate(
836            source_progress.ops_finished,
837            self.last_source_ops,
838            curr_duration_secs,
839        );
840        let source_bytes_rate_curr = self.calculate_current_rate(
841            source_progress.bytes_copied,
842            self.last_source_bytes,
843            curr_duration_secs,
844        );
845        let source_files_rate_curr = self.calculate_current_rate(
846            source_progress.files_copied,
847            self.last_source_files,
848            curr_duration_secs,
849        );
850        // source average rates
851        let source_ops_rate_avg =
852            self.calculate_average_rate(source_progress.ops_finished, total_duration_secs);
853        let source_bytes_rate_avg =
854            self.calculate_average_rate(source_progress.bytes_copied, total_duration_secs);
855        let source_files_rate_avg =
856            self.calculate_average_rate(source_progress.files_copied, total_duration_secs);
857        // destination current rates
858        let dest_ops_rate_curr = self.calculate_current_rate(
859            dest_progress.ops_finished,
860            self.last_dest_ops,
861            curr_duration_secs,
862        );
863        let dest_bytes_rate_curr = self.calculate_current_rate(
864            dest_progress.bytes_copied,
865            self.last_dest_bytes,
866            curr_duration_secs,
867        );
868        // destination average rates
869        let dest_ops_rate_avg =
870            self.calculate_average_rate(dest_progress.ops_finished, total_duration_secs);
871        let dest_bytes_rate_avg =
872            self.calculate_average_rate(dest_progress.bytes_copied, total_duration_secs);
873        // update last values
874        self.last_source_ops = source_progress.ops_finished;
875        self.last_source_bytes = source_progress.bytes_copied;
876        self.last_source_files = source_progress.files_copied;
877        self.last_dest_ops = dest_progress.ops_finished;
878        self.last_dest_bytes = dest_progress.bytes_copied;
879        self.last_update = time_now;
880        Ok(format!(
881            "==== SOURCE =======\n\
882            OPS:\n\
883            pending: {:>10}\n\
884            average: {:>10.2} items/s\n\
885            current: {:>10.2} items/s\n\
886            ---------------------\n\
887            COPIED:\n\
888            average: {:>10}/s\n\
889            current: {:>10}/s\n\
890            bytes:   {:>10}\n\
891            files:       {:>10}\n\
892            ---------------------\n\
893            FILES:\n\
894            average: {:>10.2} files/s\n\
895            current: {:>10.2} files/s\n\
896            ---------------------\n\
897            SKIPPED:\n\
898            files:       {:>10}\n\
899            symlinks:    {:>10}\n\
900            directories: {:>10}\n\
901            specials:    {:>10}\n\
902            ==== DESTINATION ====\n\
903            OPS:\n\
904            pending: {:>10}\n\
905            average: {:>10.2} items/s\n\
906            current: {:>10.2} items/s\n\
907            ---------------------\n\
908            COPIED:\n\
909            average: {:>10}/s\n\
910            current: {:>10}/s\n\
911            bytes:   {:>10}\n\
912            files:       {:>10}\n\
913            symlinks:    {:>10}\n\
914            directories: {:>10}\n\
915            hard-links:  {:>10}\n\
916            ---------------------\n\
917            UNCHANGED:\n\
918            files:       {:>10}\n\
919            symlinks:    {:>10}\n\
920            directories: {:>10}\n\
921            hard-links:  {:>10}\n\
922            ---------------------\n\
923            REMOVED:\n\
924            bytes:       {:>10}\n\
925            files:       {:>10}\n\
926            symlinks:    {:>10}\n\
927            directories: {:>10}",
928            // source section
929            source_progress
930                .ops_started
931                .saturating_sub(source_progress.ops_finished), // pending
932            source_ops_rate_avg,
933            source_ops_rate_curr,
934            bytesize::ByteSize(source_bytes_rate_avg as u64),
935            bytesize::ByteSize(source_bytes_rate_curr as u64),
936            bytesize::ByteSize(source_progress.bytes_copied),
937            source_progress.files_copied,
938            source_files_rate_avg,
939            source_files_rate_curr,
940            // source skipped
941            source_progress.files_skipped,
942            source_progress.symlinks_skipped,
943            source_progress.directories_skipped,
944            source_progress.specials_skipped,
945            // destination section
946            dest_progress
947                .ops_started
948                .saturating_sub(dest_progress.ops_finished), // pending
949            dest_ops_rate_avg,
950            dest_ops_rate_curr,
951            bytesize::ByteSize(dest_bytes_rate_avg as u64),
952            bytesize::ByteSize(dest_bytes_rate_curr as u64),
953            bytesize::ByteSize(dest_progress.bytes_copied),
954            // destination detailed stats
955            dest_progress.files_copied,
956            dest_progress.symlinks_created,
957            dest_progress.directories_created,
958            dest_progress.hard_links_created,
959            // unchanged
960            dest_progress.files_unchanged,
961            dest_progress.symlinks_unchanged,
962            dest_progress.directories_unchanged,
963            dest_progress.hard_links_unchanged,
964            // removed
965            bytesize::ByteSize(dest_progress.bytes_removed),
966            dest_progress.files_removed,
967            dest_progress.symlinks_removed,
968            dest_progress.directories_removed,
969        ))
970    }
971}
972
973impl Default for RcpdProgressPrinter {
974    fn default() -> Self {
975        Self::new()
976    }
977}
978
979#[cfg(test)]
980mod tests {
981    use super::*;
982    use crate::remote_tracing::TracingMessage;
983    use anyhow::Result;
984
985    #[test]
986    fn basic_counting() -> Result<()> {
987        let tls_counter = TlsCounter::new();
988        for _ in 0..10 {
989            tls_counter.inc();
990        }
991        assert!(tls_counter.get() == 10);
992        Ok(())
993    }
994
995    #[test]
996    fn threaded_counting() -> Result<()> {
997        let tls_counter = TlsCounter::new();
998        std::thread::scope(|scope| {
999            let mut handles = Vec::new();
1000            for _ in 0..10 {
1001                handles.push(scope.spawn(|| {
1002                    for _ in 0..100 {
1003                        tls_counter.inc();
1004                    }
1005                }));
1006            }
1007        });
1008        assert!(tls_counter.get() == 1000);
1009        Ok(())
1010    }
1011
1012    #[test]
1013    fn basic_guard() -> Result<()> {
1014        let tls_progress = ProgressCounter::new();
1015        let _guard = tls_progress.guard();
1016        Ok(())
1017    }
1018
1019    #[test]
1020    fn test_serializable_progress() -> Result<()> {
1021        let progress = Progress::new();
1022
1023        // Add some test data
1024        progress.files_copied.inc();
1025        progress.bytes_copied.add(1024);
1026        progress.directories_created.add(2);
1027
1028        // Test conversion to serializable format
1029        let serializable = SerializableProgress::from(&progress);
1030        assert_eq!(serializable.files_copied, 1);
1031        assert_eq!(serializable.bytes_copied, 1024);
1032        assert_eq!(serializable.directories_created, 2);
1033
1034        // Test that we can create a TracingMessage with progress
1035        let _tracing_msg = TracingMessage::Progress(serializable);
1036
1037        Ok(())
1038    }
1039
1040    #[test]
1041    fn serializable_progress_never_reports_more_finished_than_started() {
1042        let progress = Progress::new();
1043        progress.ops.finished.inc();
1044
1045        let serializable = SerializableProgress::from(&progress);
1046
1047        assert_eq!(serializable.ops_started, 1);
1048        assert_eq!(serializable.ops_finished, 1);
1049    }
1050
1051    #[test]
1052    fn test_rcpd_progress_printer() -> Result<()> {
1053        let mut printer = RcpdProgressPrinter::new();
1054
1055        // Create test progress data
1056        let source_progress = SerializableProgress {
1057            ops_started: 100,
1058            ops_finished: 80,
1059            bytes_copied: 1024,
1060            files_copied: 5,
1061            files_skipped: 3,
1062            symlinks_skipped: 1,
1063            directories_skipped: 2,
1064            ..Default::default()
1065        };
1066
1067        let dest_progress = SerializableProgress {
1068            ops_started: 80,
1069            ops_finished: 70,
1070            bytes_copied: 1024,
1071            files_copied: 8,
1072            symlinks_created: 2,
1073            directories_created: 1,
1074            ..Default::default()
1075        };
1076
1077        // Test that print returns a formatted string
1078        let output = printer.print(&source_progress, &dest_progress)?;
1079        assert!(output.contains("SOURCE"));
1080        assert!(output.contains("DESTINATION"));
1081        assert!(output.contains("OPS:"));
1082        assert!(output.contains("pending:"));
1083        assert!(output.contains("20")); // source pending ops (100-80)
1084        assert!(output.contains("10")); // dest pending ops (80-70)
1085        let mut sections = output.split("==== DESTINATION ====");
1086        let source_section = sections.next().unwrap();
1087        let dest_section = sections.next().unwrap_or("");
1088        let source_files_line = source_section
1089            .lines()
1090            .find(|line| line.trim_start().starts_with("files:"))
1091            .expect("source files line missing");
1092        assert!(source_files_line.trim_start().ends_with("5"));
1093        assert!(!source_files_line.contains('.'));
1094        let dest_files_line = dest_section
1095            .lines()
1096            .find(|line| line.trim_start().starts_with("files:"))
1097            .expect("dest files line missing");
1098        assert!(dest_files_line.trim_start().ends_with("8"));
1099        assert!(!dest_files_line.contains('.'));
1100        // verify SKIPPED section appears in source
1101        assert!(source_section.contains("SKIPPED:"));
1102        let skipped_section = source_section
1103            .split("SKIPPED:")
1104            .nth(1)
1105            .expect("SKIPPED section missing in source");
1106        let skipped_lines: Vec<&str> = skipped_section.lines().collect();
1107        let skipped_files_line = skipped_lines
1108            .iter()
1109            .find(|line| line.trim_start().starts_with("files:"))
1110            .expect("skipped files line missing");
1111        assert!(skipped_files_line.trim_start().ends_with("3"));
1112        let skipped_symlinks_line = skipped_lines
1113            .iter()
1114            .find(|line| line.trim_start().starts_with("symlinks:"))
1115            .expect("skipped symlinks line missing");
1116        assert!(skipped_symlinks_line.trim_start().ends_with("1"));
1117        let skipped_dirs_line = skipped_lines
1118            .iter()
1119            .find(|line| line.trim_start().starts_with("directories:"))
1120            .expect("skipped directories line missing");
1121        assert!(skipped_dirs_line.trim_start().ends_with("2"));
1122
1123        Ok(())
1124    }
1125
1126    #[test]
1127    fn rcpd_progress_printer_treats_inverted_counts_as_no_pending_work() -> Result<()> {
1128        let mut printer = RcpdProgressPrinter::new();
1129        let source_progress = SerializableProgress {
1130            ops_started: 2,
1131            ops_finished: 3,
1132            ..Default::default()
1133        };
1134        let dest_progress = SerializableProgress {
1135            ops_started: 4,
1136            ops_finished: 5,
1137            ..Default::default()
1138        };
1139
1140        let output = printer.print(&source_progress, &dest_progress)?;
1141        let pending: Vec<_> = output
1142            .lines()
1143            .filter_map(|line| line.trim().strip_prefix("pending:"))
1144            .map(str::trim)
1145            .collect();
1146
1147        assert_eq!(pending, vec!["0", "0"]);
1148        Ok(())
1149    }
1150
1151    #[test]
1152    fn interleaved_counter_access() -> Result<()> {
1153        // test that interleaved access to multiple counters works correctly
1154        // (this was problematic with the old single-slot cache design)
1155        let counter_a = TlsCounter::new();
1156        let counter_b = TlsCounter::new();
1157        let counter_c = TlsCounter::new();
1158        for i in 0..100 {
1159            counter_a.add(1);
1160            counter_b.add(2);
1161            counter_c.add(3);
1162            // verify intermediate values are correct
1163            if i % 10 == 0 {
1164                assert_eq!(counter_a.get(), i + 1);
1165                assert_eq!(counter_b.get(), (i + 1) * 2);
1166                assert_eq!(counter_c.get(), (i + 1) * 3);
1167            }
1168        }
1169        // verify final counts
1170        assert_eq!(counter_a.get(), 100);
1171        assert_eq!(counter_b.get(), 200);
1172        assert_eq!(counter_c.get(), 300);
1173        Ok(())
1174    }
1175
1176    #[test]
1177    fn concurrent_multi_counter_access() -> Result<()> {
1178        // test concurrent access with multiple threads each using multiple counters
1179        let counter_a = std::sync::Arc::new(TlsCounter::new());
1180        let counter_b = std::sync::Arc::new(TlsCounter::new());
1181        const THREADS: usize = 4;
1182        const ITERATIONS: u64 = 1000;
1183        let handles: Vec<_> = (0..THREADS)
1184            .map(|_| {
1185                let ca = counter_a.clone();
1186                let cb = counter_b.clone();
1187                std::thread::spawn(move || {
1188                    for _ in 0..ITERATIONS {
1189                        ca.add(1);
1190                        cb.add(2);
1191                    }
1192                })
1193            })
1194            .collect();
1195        for h in handles {
1196            h.join().unwrap();
1197        }
1198        // verify totals are correct (no lost increments)
1199        assert_eq!(counter_a.get(), THREADS as u64 * ITERATIONS);
1200        assert_eq!(counter_b.get(), THREADS as u64 * ITERATIONS * 2);
1201        Ok(())
1202    }
1203
1204    #[test]
1205    fn repeated_counter_access() -> Result<()> {
1206        // test that repeated access to the same counter works correctly
1207        let counter = TlsCounter::new();
1208        for i in 1..=1000 {
1209            counter.add(1);
1210            assert_eq!(counter.get(), i);
1211        }
1212        Ok(())
1213    }
1214
1215    #[test]
1216    fn sharding_distributes_across_threads() -> Result<()> {
1217        // test that different threads get assigned to different shards
1218        // and that all increments are correctly counted
1219        let counter = std::sync::Arc::new(TlsCounter::new());
1220        const THREADS: usize = 16;
1221        const ITERATIONS: u64 = 100;
1222        let handles: Vec<_> = (0..THREADS)
1223            .map(|_| {
1224                let c = counter.clone();
1225                std::thread::spawn(move || {
1226                    for _ in 0..ITERATIONS {
1227                        c.inc();
1228                    }
1229                })
1230            })
1231            .collect();
1232        for h in handles {
1233            h.join().unwrap();
1234        }
1235        assert_eq!(counter.get(), THREADS as u64 * ITERATIONS);
1236        Ok(())
1237    }
1238
1239    #[test]
1240    fn sharding_handles_more_threads_than_shards() -> Result<()> {
1241        // test that shard assignment wraps correctly when threads > NUM_SHARDS
1242        let counter = std::sync::Arc::new(TlsCounter::new());
1243        const THREADS: usize = 128; // 2x NUM_SHARDS to force wrap-around
1244        const ITERATIONS: u64 = 100;
1245        let handles: Vec<_> = (0..THREADS)
1246            .map(|_| {
1247                let c = counter.clone();
1248                std::thread::spawn(move || {
1249                    for _ in 0..ITERATIONS {
1250                        c.inc();
1251                    }
1252                })
1253            })
1254            .collect();
1255        for h in handles {
1256            h.join().unwrap();
1257        }
1258        assert_eq!(counter.get(), THREADS as u64 * ITERATIONS);
1259        Ok(())
1260    }
1261
1262    #[test]
1263    fn counter_independence() -> Result<()> {
1264        // test that multiple counters are completely independent
1265        let counters: Vec<_> = (0..10).map(|_| TlsCounter::new()).collect();
1266        for (i, counter) in counters.iter().enumerate() {
1267            counter.add((i + 1) as u64 * 100);
1268        }
1269        for (i, counter) in counters.iter().enumerate() {
1270            assert_eq!(counter.get(), (i + 1) as u64 * 100);
1271        }
1272        Ok(())
1273    }
1274
1275    #[test]
1276    fn copy_printer_omits_hard_links() -> Result<()> {
1277        let progress = Progress::new();
1278        let mut printer = CopyProgressPrinter::new(&progress);
1279        let output = printer.print()?;
1280        assert!(output.contains("COPIED:"));
1281        assert!(output.contains("UNCHANGED:"));
1282        assert!(output.contains("REMOVED:"));
1283        assert!(output.contains("SKIPPED:"));
1284        assert!(!output.contains("hard-links"));
1285        Ok(())
1286    }
1287
1288    #[test]
1289    fn remove_printer_hides_copy_and_unchanged() -> Result<()> {
1290        let progress = Progress::new();
1291        let mut printer = RemoveProgressPrinter::new(&progress);
1292        let output = printer.print()?;
1293        assert!(output.contains("REMOVED:"));
1294        assert!(output.contains("SKIPPED:"));
1295        assert!(!output.contains("COPIED:"));
1296        assert!(!output.contains("UNCHANGED:"));
1297        assert!(!output.contains("hard-links"));
1298        assert!(!output.contains("specials:"));
1299        Ok(())
1300    }
1301
1302    #[test]
1303    fn link_printer_shows_hard_links() -> Result<()> {
1304        let progress = Progress::new();
1305        let mut printer = LinkProgressPrinter::new(&progress);
1306        let output = printer.print()?;
1307        assert!(output.contains("LINKED / COPIED:"));
1308        assert!(output.contains("UNCHANGED:"));
1309        assert!(output.contains("REMOVED:"));
1310        assert!(output.contains("SKIPPED:"));
1311        assert!(output.contains("hard-links"));
1312        Ok(())
1313    }
1314
1315    #[test]
1316    fn compare_printer_only_shows_ops() -> Result<()> {
1317        let progress = Progress::new();
1318        let mut printer = CompareProgressPrinter::new(&progress);
1319        let output = printer.print()?;
1320        assert!(output.contains("OPS:"));
1321        assert!(!output.contains("COPIED:"));
1322        assert!(!output.contains("UNCHANGED:"));
1323        assert!(!output.contains("REMOVED:"));
1324        assert!(!output.contains("SKIPPED:"));
1325        assert!(!output.contains("GENERATED:"));
1326        Ok(())
1327    }
1328
1329    #[test]
1330    fn filegen_printer_shows_generated() -> Result<()> {
1331        let progress = Progress::new();
1332        let mut printer = FilegenProgressPrinter::new(&progress);
1333        let output = printer.print()?;
1334        assert!(output.contains("OPS:"));
1335        assert!(output.contains("GENERATED:"));
1336        assert!(!output.contains("COPIED:"));
1337        assert!(!output.contains("UNCHANGED:"));
1338        assert!(!output.contains("REMOVED:"));
1339        assert!(!output.contains("SKIPPED:"));
1340        assert!(!output.contains("symlinks:"));
1341        Ok(())
1342    }
1343
1344    #[test]
1345    fn modify_printer_renders_changed_section() {
1346        let progress = Progress::new();
1347        progress.files_changed.inc();
1348        progress.directories_unchanged.inc();
1349        let mut printer = ModifyProgressPrinter::new(&progress);
1350        let out = printer.print().unwrap();
1351        assert!(out.contains("CHANGED:"));
1352        assert!(out.contains("UNCHANGED:"));
1353        assert!(out.contains("SKIPPED:"));
1354    }
1355}