Skip to main content

scanstate/
progress.rs

1//! Live progress indicators for tracking scan rates and ETAs.
2
3use serde::{Deserialize, Serialize};
4use std::time::{Duration, SystemTime};
5
6/// Live scan progress metrics.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ScanProgress {
9    /// Total planned targets.
10    pub total: usize,
11    /// Successfully completed targets.
12    pub completed: usize,
13    /// Skipped targets.
14    pub skipped: usize,
15    /// Total findings discovered so far.
16    pub findings: usize,
17    /// Time when the scan started.
18    pub start_time: SystemTime,
19}
20
21impl ScanProgress {
22    /// Create progress tracking for a scan.
23    #[must_use]
24    pub fn new(total: usize) -> Self {
25        Self {
26            total,
27            completed: 0,
28            skipped: 0,
29            findings: 0,
30            start_time: SystemTime::now(),
31        }
32    }
33
34    /// Record one completed target.
35    pub fn record_completed(&mut self) {
36        self.completed += 1;
37    }
38
39    /// Record one skipped target.
40    pub fn record_skipped(&mut self) {
41        self.skipped += 1;
42    }
43
44    /// Add findings discovered during the scan.
45    pub fn record_findings(&mut self, findings: usize) {
46        self.findings += findings;
47    }
48
49    /// Current processing rate in targets per second.
50    #[must_use]
51    #[allow(clippy::cast_precision_loss)]
52    pub fn rate(&self) -> f64 {
53        let elapsed = self.start_time.elapsed().map(|d| d.as_secs_f64()).unwrap_or(0.0);
54        if elapsed <= f64::EPSILON {
55            return 0.0;
56        }
57
58        (self.completed + self.skipped) as f64 / elapsed
59    }
60
61    /// Estimated time remaining.
62    #[must_use]
63    #[allow(clippy::cast_precision_loss)]
64    pub fn eta(&self) -> Duration {
65        let processed = self.completed + self.skipped;
66        if self.total <= processed {
67            return Duration::ZERO;
68        }
69
70        let rate = self.rate();
71        if rate <= f64::EPSILON {
72            return Duration::ZERO;
73        }
74
75        Duration::from_secs_f64((self.total - processed) as f64 / rate)
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::ScanProgress;
82    use std::time::{Duration, SystemTime};
83
84    #[test]
85    fn new_initializes_progress() {
86        let progress = ScanProgress::new(10);
87        assert_eq!(progress.total, 10);
88        assert_eq!(progress.completed, 0);
89        assert_eq!(progress.skipped, 0);
90        assert_eq!(progress.findings, 0);
91    }
92
93    #[test]
94    fn record_completed_increments_completed_count() {
95        let mut progress = ScanProgress::new(10);
96        progress.record_completed();
97        assert_eq!(progress.completed, 1);
98    }
99
100    #[test]
101    fn record_skipped_increments_skipped_count() {
102        let mut progress = ScanProgress::new(10);
103        progress.record_skipped();
104        assert_eq!(progress.skipped, 1);
105    }
106
107    #[test]
108    fn record_findings_accumulates_findings() {
109        let mut progress = ScanProgress::new(10);
110        progress.record_findings(2);
111        progress.record_findings(3);
112        assert_eq!(progress.findings, 5);
113    }
114
115    #[test]
116    fn rate_returns_targets_per_second() {
117        let mut progress = ScanProgress::new(10);
118        progress.completed = 4;
119        progress.skipped = 2;
120        progress.start_time = SystemTime::now()
121            .checked_sub(Duration::from_secs(2))
122            .expect("subtract fixed start time");
123
124        let rate = progress.rate();
125        assert!(rate >= 2.9 && rate <= 3.1, "unexpected rate: {rate}");
126    }
127
128    #[test]
129    fn rate_returns_zero_when_elapsed_is_too_small() {
130        let progress = ScanProgress::new(10);
131        let rate = progress.rate();
132        assert!(rate >= 0.0);
133    }
134
135    #[test]
136    fn eta_returns_zero_when_scan_is_complete() {
137        let mut progress = ScanProgress::new(5);
138        progress.completed = 3;
139        progress.skipped = 2;
140        progress.start_time = SystemTime::now()
141            .checked_sub(Duration::from_secs(2))
142            .expect("subtract fixed start time");
143
144        assert_eq!(progress.eta(), Duration::ZERO);
145    }
146
147    #[test]
148    fn eta_estimates_remaining_time() {
149        let mut progress = ScanProgress::new(10);
150        progress.completed = 4;
151        progress.skipped = 2;
152        progress.start_time = SystemTime::now()
153            .checked_sub(Duration::from_secs(2))
154            .expect("subtract fixed start time");
155
156        let eta = progress.eta();
157        assert!(eta >= Duration::from_millis(1200));
158        assert!(eta <= Duration::from_millis(1500));
159    }
160
161    #[test]
162    fn progress_is_serializable() {
163        let progress = ScanProgress {
164            total: 10,
165            completed: 3,
166            skipped: 1,
167            findings: 2,
168            start_time: SystemTime::UNIX_EPOCH,
169        };
170        let payload = serde_json::to_string(&progress).unwrap();
171        let decoded: ScanProgress = serde_json::from_str(&payload).unwrap();
172        assert_eq!(decoded.total, 10);
173        assert_eq!(decoded.completed, 3);
174        assert_eq!(decoded.skipped, 1);
175        assert_eq!(decoded.findings, 2);
176        assert_eq!(decoded.start_time, SystemTime::UNIX_EPOCH);
177    }
178}