Skip to main content

spar/
progress.rs

1//! How far a run has got through the work it picked up.
2//!
3//! One issue can take an hour, and the log in between is every agent call of
4//! every round, so nothing in it said whether the run was on its first item of
5//! six or its last. A line after each item says how many are done, how many
6//! are left, and how long it has been.
7//!
8//! The total grows as the run learns it. Triage decides how many issues of a
9//! wave get worked, and a wave of follow-ups can join after that, so the line
10//! never claims a total the run does not know yet.
11
12use std::time::{Duration, Instant};
13
14use crate::log;
15use crate::model::IssueRun;
16use crate::spend;
17
18const BAR_WIDTH: usize = 20;
19
20pub struct Progress {
21    done: usize,
22    total: usize,
23    started: Instant,
24}
25
26impl Progress {
27    pub fn new(total: usize) -> Self {
28        Self {
29            done: 0,
30            total,
31            started: Instant::now(),
32        }
33    }
34
35    /// More items this run will work, once triage has said which.
36    pub fn add(&mut self, items: usize) {
37        self.total += items;
38    }
39
40    /// One item reached its end, whatever that end was.
41    pub fn finished(&mut self, run: &IssueRun) {
42        self.done += 1;
43        self.total = self.total.max(self.done);
44        log!("{}", self.line(run, self.started.elapsed()));
45    }
46
47    fn line(&self, run: &IssueRun, elapsed: Duration) -> String {
48        let left = self.total - self.done;
49        format!(
50            "{} {} of {} done, #{} {}, {left} left, {} elapsed",
51            bar(self.done, self.total),
52            self.done,
53            self.total,
54            run.issue,
55            run.status,
56            spend::clock(elapsed)
57        )
58    }
59}
60
61fn bar(done: usize, total: usize) -> String {
62    let filled = (done * BAR_WIDTH)
63        .checked_div(total)
64        .unwrap_or(0)
65        .min(BAR_WIDTH);
66    format!("[{}{}]", "#".repeat(filled), ".".repeat(BAR_WIDTH - filled))
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use crate::model::Status;
73
74    fn run(issue: i64, status: Status) -> IssueRun {
75        let mut run = IssueRun::new(issue, "title");
76        run.status = status;
77        run
78    }
79
80    #[test]
81    fn the_bar_fills_in_proportion_and_never_overflows() {
82        assert_eq!(format!("[{}]", ".".repeat(20)), bar(0, 5));
83        assert_eq!(format!("[{}{}]", "#".repeat(8), ".".repeat(12)), bar(2, 5));
84        assert_eq!(format!("[{}]", "#".repeat(20)), bar(5, 5));
85        assert_eq!(format!("[{}]", "#".repeat(20)), bar(7, 5));
86        assert_eq!(format!("[{}]", ".".repeat(20)), bar(0, 0));
87    }
88
89    #[test]
90    fn a_line_names_the_item_just_finished_and_what_is_left() {
91        let mut progress = Progress::new(5);
92        progress.done = 2;
93        let line = progress.line(&run(123, Status::Approved), Duration::from_secs(4000));
94        assert!(
95            line.ends_with("2 of 5 done, #123 approved, 3 left, 66m40s elapsed"),
96            "{line}"
97        );
98    }
99
100    #[test]
101    fn the_total_grows_as_waves_join_and_never_trails_what_is_done() {
102        let mut progress = Progress::new(1);
103        progress.finished(&run(1, Status::Merged));
104        progress.add(2);
105        assert_eq!((1, 3), (progress.done, progress.total));
106        progress.finished(&run(2, Status::Escalated));
107        progress.finished(&run(3, Status::Approved));
108        progress.finished(&run(4, Status::Error));
109        assert_eq!((4, 4), (progress.done, progress.total));
110    }
111}