Skip to main content

amont_runtime/
live.rs

1//! One check, one block — and while it runs, one line: per-check output
2//! capture plus a live progress region for the concurrent stage.
3//!
4//! Twenty checks used to print straight to inherited stdio from their own
5//! threads, so two failing linters shuffled their lines together and the
6//! reader un-shuffled them by hand — the dispatcher's roll-up existed partly
7//! to apologise for it. Now every check writes into its own slot, and a
8//! completed check's output reaches stdout as ONE locked write: contiguous,
9//! whatever the other nineteen were doing.
10//!
11//! Three writers feed a slot:
12//!
13//! 1. The check's own thread, through [`say`] — which is what
14//!    `common::ok/fail/warn` call. A thread with no slot installed (commit-msg,
15//!    `amont install`, the dispatcher itself) prints directly, exactly as
16//!    before; nothing outside a stage changes.
17//! 2. A captured child's reader threads, through [`Stage::append_raw`] —
18//!    they are not the check's thread, so the thread-local cannot carry the
19//!    routing; the `Arc` is captured before the spawn instead.
20//! 3. Nobody else. The dispatcher's own lines (skips, pins, the roll-up)
21//!    happen strictly before or after the fan-out and stay direct.
22//!
23//! Order across checks is COMPLETION order — deterministic per block, not
24//! per stage, which is the same nondeterminism the interleaved version had
25//! without the shuffling. `amont.progress false` switches the whole
26//! mechanism off and restores raw streaming for anyone who wants to watch a
27//! tool write in real time.
28//!
29//! # The region
30//!
31//! When stderr is a real terminal ([`watching`]) the stage also paints a
32//! live region UNDER the finished blocks: one line per running check —
33//! braille spinner, name, elapsed — repainted every 80ms by a ticker
34//! thread, shrinking as checks finish, gone without a trace when the stage
35//! ends. Blocks go to stdout, the region to stderr; both feed one tty, and
36//! every write to either happens under the same [`Stage::out`] lock, so a
37//! block never tears a repaint in half. Piped, redirected, `TERM=dumb`, or
38//! CI: [`watching`] is false, no ticker starts, and the region costs
39//! nothing — which is also why the test suite (piped stdio throughout)
40//! exercises capture but never the paint.
41
42use std::cell::RefCell;
43use std::io::{IsTerminal, Write};
44use std::sync::atomic::{AtomicBool, Ordering};
45use std::sync::{Arc, Mutex, Weak};
46use std::time::Instant;
47
48/// The fleet spinner's frames (progress.rs) — cycled by elapsed time, so a
49/// frame needs no state beyond the clock.
50const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
51
52/// The region never grows past this many check lines; the rest fold into
53/// one `… and N more`. Twelve is the whole default fleet on one screen.
54const MAX_LINES: usize = 12;
55
56/// One check's place in the stage.
57struct Slot {
58    /// Sanitised at [`Stage::begin`]: a manifest-declared name is
59    /// repo-derived text and the region writes it to a live terminal.
60    name: String,
61    /// Restamped by [`Stage::enter`], so a serial stage (pre-push) times
62    /// each check from its own start, not the stage's.
63    started: Instant,
64    /// The last byte or line that landed in `buf` — what the region's
65    /// `quiet` figure and the heartbeat's `last output` read.
66    last_output: Instant,
67    /// Elapsed seconds at which the non-tty heartbeat next speaks.
68    next_beat: u64,
69    buf: Vec<u8>,
70    /// Entered and not yet finished — the region shows exactly these.
71    running: bool,
72    done: bool,
73}
74
75/// A running stage: the slots, and the one lock every terminal write inside
76/// the stage goes through.
77pub struct Stage {
78    slots: Mutex<Vec<Slot>>,
79    /// Serialises block emission and region repaints; the value is how many
80    /// region lines are currently painted (what an erase must remove).
81    out: Mutex<usize>,
82    /// Painting at all? [`enabled`] && [`watching`], decided once at begin.
83    live: bool,
84    stop: AtomicBool,
85}
86
87thread_local! {
88    /// Where [`say`] routes on THIS thread: a stage and a slot index.
89    static SINK: RefCell<Option<(Arc<Stage>, usize)>> = const { RefCell::new(None) };
90}
91
92impl Stage {
93    /// A stage over `names`, in dispatch order. Does nothing visible until
94    /// checks start entering (the region) or finishing (the blocks).
95    pub fn begin(names: &[&str]) -> Arc<Stage> {
96        let now = Instant::now();
97        let stage = Arc::new(Stage {
98            slots: Mutex::new(
99                names
100                    .iter()
101                    .map(|n| Slot {
102                        // Every name in a stage carries the stage's own
103                        // prefix ("pre-commit-clippy"); the region drops it
104                        // — twelve identical prefixes say nothing.
105                        name: crate::ui::sanitize(
106                            n.strip_prefix("pre-commit-")
107                                .or_else(|| n.strip_prefix("pre-push-"))
108                                .unwrap_or(n),
109                        ),
110                        started: now,
111                        last_output: now,
112                        next_beat: HEARTBEAT_SECS,
113                        buf: Vec::new(),
114                        running: false,
115                        done: false,
116                    })
117                    .collect(),
118            ),
119            out: Mutex::new(0),
120            live: enabled() && watching(),
121            stop: AtomicBool::new(false),
122        });
123        if stage.live {
124            // The ticker holds a Weak: the stage dropping is what ends it,
125            // so a paint can never outlive the region's owner.
126            let weak = Arc::downgrade(&stage);
127            let _ = std::thread::Builder::new()
128                .name("amont-live".into())
129                .spawn(move || tick(weak));
130        } else if enabled() {
131            // Nobody is watching a terminal — an agent, CI, a pipe — and a
132            // captured check shows nothing until it finishes. The heartbeat
133            // is the one line a minute that says it is alive, which is the
134            // difference between "wait" and "kill it" for whoever is on the
135            // other end of the pipe.
136            let weak = Arc::downgrade(&stage);
137            let _ = std::thread::Builder::new()
138                .name("amont-heartbeat".into())
139                .spawn(move || heartbeat(weak));
140        }
141        stage
142    }
143
144    /// Route this thread's [`say`] calls into slot `idx` until the guard
145    /// drops. Installed by the dispatcher around each `check.run`. Also
146    /// starts the slot's clock and puts it in the region.
147    pub fn enter(self: &Arc<Stage>, idx: usize) -> SinkGuard {
148        {
149            let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
150            if let Some(slot) = slots.get_mut(idx) {
151                slot.running = true;
152                slot.started = Instant::now();
153                slot.last_output = slot.started;
154                slot.next_beat = HEARTBEAT_SECS;
155            }
156        }
157        SINK.with(|s| *s.borrow_mut() = Some((Arc::clone(self), idx)));
158        SinkGuard
159    }
160
161    /// Append raw bytes (a captured child's output) to slot `idx`.
162    pub fn append_raw(&self, idx: usize, bytes: &[u8]) {
163        let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
164        if let Some(slot) = slots.get_mut(idx) {
165            if !slot.done {
166                slot.buf.extend_from_slice(bytes);
167                slot.last_output = Instant::now();
168            }
169        }
170    }
171
172    fn append_line(&self, idx: usize, line: &str) {
173        let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
174        if let Some(slot) = slots.get_mut(idx) {
175            if !slot.done {
176                slot.buf.extend_from_slice(line.as_bytes());
177                slot.buf.push(b'\n');
178                slot.last_output = Instant::now();
179            }
180        }
181    }
182
183    /// The check is over: emit everything it said as ONE contiguous write,
184    /// with the region lifted out of the way first and repainted after —
185    /// blocks pile up above, spinners stay below.
186    ///
187    /// Called by the dispatcher after `check.run` returns (still on the
188    /// check's thread, so a torn-down thread cannot strand a buffer — the
189    /// same `catch_unwind` that feeds the dead-check outcome runs first).
190    pub fn finish(&self, idx: usize) {
191        let block = {
192            let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
193            let Some(slot) = slots.get_mut(idx) else {
194                return;
195            };
196            slot.done = true;
197            slot.running = false;
198            std::mem::take(&mut slot.buf)
199        };
200        if block.is_empty() && !self.live {
201            return;
202        }
203        let mut drawn = self.out.lock().unwrap_or_else(|p| p.into_inner());
204        if !block.is_empty() {
205            if *drawn > 0 {
206                let mut err = std::io::stderr().lock();
207                let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
208                let _ = err.flush();
209                *drawn = 0;
210            }
211            let stdout = std::io::stdout();
212            let mut handle = stdout.lock();
213            let _ = handle.write_all(&block);
214            let _ = handle.flush();
215        }
216        self.repaint(&mut drawn);
217    }
218
219    /// Erase and redraw the region in one stderr write. Lock order is
220    /// `out` → `slots`, everywhere — never the reverse.
221    fn repaint(&self, drawn: &mut usize) {
222        if !self.live {
223            return;
224        }
225        let entries: Vec<Row> = {
226            let slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
227            let now = Instant::now();
228            slots
229                .iter()
230                .filter(|s| s.running && !s.done)
231                .map(|s| Row {
232                    name: s.name.clone(),
233                    elapsed: now.duration_since(s.started).as_secs_f64(),
234                    quiet: now.duration_since(s.last_output).as_secs_f64(),
235                })
236                .collect()
237        };
238        let text = region(&entries, term_width(), budgets());
239        let mut paint = String::new();
240        if *drawn > 0 {
241            paint.push_str(&format!("\x1b[{}A\x1b[J", *drawn));
242        }
243        paint.push_str(&text);
244        if paint.is_empty() {
245            return;
246        }
247        let mut err = std::io::stderr().lock();
248        let _ = err.write_all(paint.as_bytes());
249        let _ = err.flush();
250        *drawn = text.matches('\n').count();
251    }
252}
253
254impl Drop for Stage {
255    /// The stage's end erases whatever the region still shows — a Block
256    /// verdict, a panic on the dispatcher path, anything: no spinner junk
257    /// above the roll-up. (`get_mut`: dropping proves no other thread holds
258    /// the stage, so the locks are free.)
259    fn drop(&mut self) {
260        self.stop.store(true, Ordering::Relaxed);
261        if !self.live {
262            return;
263        }
264        let drawn = self.out.get_mut().unwrap_or_else(|p| p.into_inner());
265        if *drawn > 0 {
266            let mut err = std::io::stderr().lock();
267            let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
268            let _ = err.flush();
269            *drawn = 0;
270        }
271    }
272}
273
274/// The ticker: repaint every 80ms until the stage drops or tells it to
275/// stop. Holds only a `Weak`, so it can never keep a finished stage alive.
276fn tick(weak: Weak<Stage>) {
277    loop {
278        std::thread::sleep(std::time::Duration::from_millis(80));
279        let Some(stage) = weak.upgrade() else { return };
280        if stage.stop.load(Ordering::Relaxed) {
281            return;
282        }
283        let mut drawn = stage.out.lock().unwrap_or_else(|p| p.into_inner());
284        stage.repaint(&mut drawn);
285    }
286}
287
288/// One running check, as the region and the heartbeat see it.
289#[derive(Debug, Clone)]
290pub struct Row {
291    pub name: String,
292    /// Seconds since the check entered.
293    pub elapsed: f64,
294    /// Seconds since it last wrote anything.
295    pub quiet: f64,
296}
297
298/// The two clocks, as the region annotates them: `(idle, ceiling)` in
299/// seconds, `0` for off.
300#[derive(Debug, Clone, Copy)]
301pub struct Budgets {
302    pub idle: u64,
303    pub ceiling: u64,
304}
305
306fn budgets() -> Budgets {
307    Budgets {
308        idle: crate::hooks::common::idle_timeout(),
309        ceiling: crate::hooks::common::check_timeout(),
310    }
311}
312
313/// How long a check must be quiet before the region says so. A test suite
314/// pauses this long between crates without anything being wrong; past it,
315/// the reader wants to know the silence is being counted.
316const QUIET_NOTE_SECS: f64 = 30.0;
317
318/// The non-tty heartbeat's period: one line a minute per running check.
319const HEARTBEAT_SECS: u64 = 60;
320
321/// Elapsed time in a fixed six-column figure: `  3.2s` under a minute,
322/// `8m12s` and `1h02m` above, so the column stays aligned as the suite
323/// crosses the minute.
324fn elapsed_column(secs: f64) -> String {
325    if secs < 60.0 {
326        format!("{secs:>5.1}s")
327    } else {
328        format!("{:>6}", crate::hooks::common::human_secs(secs as u64))
329    }
330}
331
332/// The region's text: one `⠹ name  12.3s` line per running check, capped at
333/// [`MAX_LINES`] plus a `… and N more` overflow line. Pure — the ticker is
334/// a thin shell around this, and the tests drive it directly.
335///
336/// Two annotations, each only when it carries news: `· quiet 45s/2m` once
337/// a check has been silent past [`QUIET_NOTE_SECS`] (with the silence
338/// budget it is counting toward, when there is one), and `· 48m/60m` once
339/// elapsed passes 80% of the ceiling — the cliff, shown before the fall.
340fn region(entries: &[Row], width: usize, budgets: Budgets) -> String {
341    if entries.is_empty() {
342        return String::new();
343    }
344    let pad = entries
345        .iter()
346        .take(MAX_LINES)
347        .map(|r| r.name.chars().count())
348        .max()
349        .unwrap_or(0);
350    let mut out = String::new();
351    for row in entries.iter().take(MAX_LINES) {
352        let frame = FRAMES[((row.elapsed * 10.0) as usize) % FRAMES.len()];
353        let name = &row.name;
354        let mut line = format!("{frame} {name:<pad$} {}", elapsed_column(row.elapsed));
355        if row.quiet >= QUIET_NOTE_SECS {
356            let quiet = crate::hooks::common::human_secs(row.quiet as u64);
357            if budgets.idle > 0 {
358                line.push_str(&format!(
359                    " · quiet {quiet}/{}",
360                    crate::hooks::common::human_secs(budgets.idle)
361                ));
362            } else {
363                line.push_str(&format!(" · quiet {quiet}"));
364            }
365        }
366        if budgets.ceiling > 0 && row.elapsed >= 0.8 * budgets.ceiling as f64 {
367            line.push_str(&format!(
368                " · {}/{}",
369                crate::hooks::common::human_secs(row.elapsed as u64),
370                crate::hooks::common::human_secs(budgets.ceiling)
371            ));
372        }
373        if line.chars().count() > width {
374            out.extend(line.chars().take(width));
375        } else {
376            out.push_str(&line);
377        }
378        out.push('\n');
379    }
380    if entries.len() > MAX_LINES {
381        out.push_str(&format!("… and {} more\n", entries.len() - MAX_LINES));
382    }
383    out
384}
385
386/// The heartbeat: once a minute, for each check still running, one plain
387/// line on stderr — elapsed, and how long since it last said anything.
388/// Not a region: nothing is erased or repainted, because nobody is looking
389/// at a cursor; whoever reads this reads a log.
390///
391/// The first beat for a check also names the two budgets, once, so the
392/// reader can tell how far it is from being killed without opening the
393/// docs. Written under the same `out` lock as the blocks, so a beat never
394/// lands inside one.
395fn heartbeat(weak: Weak<Stage>) {
396    loop {
397        std::thread::sleep(std::time::Duration::from_secs(1));
398        let Some(stage) = weak.upgrade() else { return };
399        if stage.stop.load(Ordering::Relaxed) {
400            return;
401        }
402        let due: Vec<(Row, bool)> = {
403            let mut slots = stage.slots.lock().unwrap_or_else(|p| p.into_inner());
404            let now = Instant::now();
405            let mut due = Vec::new();
406            for s in slots.iter_mut().filter(|s| s.running && !s.done) {
407                let elapsed = now.duration_since(s.started).as_secs();
408                if elapsed >= s.next_beat {
409                    let first = s.next_beat == HEARTBEAT_SECS;
410                    s.next_beat += HEARTBEAT_SECS;
411                    due.push((
412                        Row {
413                            name: s.name.clone(),
414                            elapsed: elapsed as f64,
415                            quiet: now.duration_since(s.last_output).as_secs_f64(),
416                        },
417                        first,
418                    ));
419                }
420            }
421            due
422        };
423        if due.is_empty() {
424            continue;
425        }
426        let text: String = due
427            .iter()
428            .map(|(row, first)| beat_line(row, *first, budgets()))
429            .collect();
430        let _guard = stage.out.lock().unwrap_or_else(|p| p.into_inner());
431        let mut err = std::io::stderr().lock();
432        let _ = err.write_all(text.as_bytes());
433        let _ = err.flush();
434    }
435}
436
437/// One heartbeat line. Pure, for the tests.
438fn beat_line(row: &Row, first: bool, budgets: Budgets) -> String {
439    use crate::hooks::common::human_secs;
440    let mut line = format!(
441        "  … {} still running: {}, last output {} ago",
442        row.name,
443        human_secs(row.elapsed as u64),
444        human_secs(row.quiet as u64)
445    );
446    if first {
447        let idle = match budgets.idle {
448            0 => "off".to_string(),
449            s => human_secs(s),
450        };
451        let ceiling = match budgets.ceiling {
452            0 => "off".to_string(),
453            s => human_secs(s),
454        };
455        line.push_str(&format!(
456            " (killed after {idle} of silence or {ceiling} in total — amont.idleTimeout / amont.timeout)"
457        ));
458    }
459    line.push('\n');
460    line
461}
462
463/// `$COLUMNS` when it is exported and sane, else a conservative 100 — the
464/// region's lines are short and an ioctl is not worth its portability.
465///
466/// Public because `amont-agent` prints sampled commands and needs the same
467/// answer. A second copy of this would be a second place for the fallback and
468/// the sanity floor to drift.
469pub fn term_width() -> usize {
470    std::env::var("COLUMNS")
471        .ok()
472        .and_then(|c| c.parse::<usize>().ok())
473        .filter(|w| *w >= 20)
474        .unwrap_or(100)
475}
476
477/// Emits slot `idx`'s block when dropped — however the check's closure
478/// exits, a panic included: the partial output of a check that died still
479/// reaches the reader, above the dead-check verdict the runner fills in.
480pub struct FinishOnDrop<'a> {
481    stage: &'a Stage,
482    idx: usize,
483}
484
485impl<'a> FinishOnDrop<'a> {
486    pub fn new(stage: &'a Stage, idx: usize) -> FinishOnDrop<'a> {
487        FinishOnDrop { stage, idx }
488    }
489}
490
491impl Drop for FinishOnDrop<'_> {
492    fn drop(&mut self) {
493        self.stage.finish(self.idx);
494    }
495}
496
497/// Uninstalls the thread's sink on drop, whatever path the check took out.
498pub struct SinkGuard;
499
500impl Drop for SinkGuard {
501    fn drop(&mut self) {
502        SINK.with(|s| *s.borrow_mut() = None);
503    }
504}
505
506/// The sink installed on THIS thread, if any — how a child-capture helper on
507/// the check's own thread learns where the reader threads should append.
508pub fn current_sink() -> Option<(Arc<Stage>, usize)> {
509    SINK.with(|s| s.borrow().clone())
510}
511
512/// One line of check output, wherever it should go.
513///
514/// THE funnel: `common::ok/fail/warn` call this, so a check's helper prints
515/// land in its slot during a stage and on stdout everywhere else. `line` is
516/// taken without a trailing newline, exactly like `println!`.
517pub fn say(line: &str) {
518    let routed = SINK.with(|s| {
519        s.borrow().as_ref().map(|(stage, idx)| {
520            stage.append_line(*idx, line);
521        })
522    });
523    if routed.is_none() {
524        println!("{line}");
525    }
526}
527
528/// `println!`, stage-aware: formats and routes through [`say`]. What every
529/// direct print inside a CHECK BODY becomes — a line printed raw from a
530/// check thread bypasses the slot and interleaves, which is the bug this
531/// module exists to close.
532#[macro_export]
533macro_rules! say {
534    ($($arg:tt)*) => {
535        $crate::live::say(&format!($($arg)*))
536    };
537}
538
539/// Whether the capture mechanism is on at all. `amont.progress false` is the
540/// escape hatch back to raw streaming — one knob, read once.
541pub fn enabled() -> bool {
542    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
543    *ENABLED.get_or_init(|| crate::config::boolean_or("amont.progress", true))
544}
545
546/// Is anyone watching? True only when stderr is a real terminal that speaks
547/// VT: not piped, not redirected, not `TERM=dumb` — and on Windows only
548/// with `TERM` actually set, because bare conhost may not interpret the
549/// cursor codes the region depends on. This is the paint gate; capture
550/// ([`enabled`]) does not consult it.
551pub fn watching() -> bool {
552    static WATCHING: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
553    *WATCHING.get_or_init(|| {
554        if !std::io::stderr().is_terminal() {
555            return false;
556        }
557        match std::env::var("TERM") {
558            Ok(term) => term != "dumb",
559            Err(_) => !cfg!(windows),
560        }
561    })
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    /// The atomicity contract at the unit level: two threads writing
569    /// interleaved lines into their own slots come out as two contiguous
570    /// buffers, whatever the scheduler did.
571    #[test]
572    fn slots_do_not_share_a_buffer() {
573        let stage = Stage::begin(&["a", "b"]);
574        std::thread::scope(|scope| {
575            for idx in 0..2 {
576                let stage = Arc::clone(&stage);
577                scope.spawn(move || {
578                    let _guard = stage.enter(idx);
579                    for i in 0..50 {
580                        say(&format!("check-{idx} line-{i}"));
581                        std::thread::yield_now();
582                    }
583                });
584            }
585        });
586        let slots = stage.slots.lock().unwrap();
587        for idx in 0..2 {
588            let text = String::from_utf8(slots[idx].buf.clone()).unwrap();
589            assert_eq!(text.lines().count(), 50);
590            assert!(
591                text.lines()
592                    .all(|l| l.starts_with(&format!("check-{idx} "))),
593                "a foreign line landed in slot {idx}"
594            );
595        }
596    }
597
598    /// A thread with no sink prints; its lines never land in anyone's slot.
599    #[test]
600    fn no_sink_means_no_capture() {
601        let stage = Stage::begin(&["a"]);
602        say("goes to stdout, not to a slot");
603        let slots = stage.slots.lock().unwrap();
604        assert!(slots[0].buf.is_empty());
605    }
606
607    /// After finish, late writes are dropped rather than stranded — a child
608    /// reader thread that outlives its check must not corrupt a later block.
609    #[test]
610    fn a_finished_slot_takes_no_more_writes() {
611        let stage = Stage::begin(&["a"]);
612        stage.append_raw(0, b"before\n");
613        stage.finish(0);
614        stage.append_raw(0, b"after\n");
615        let slots = stage.slots.lock().unwrap();
616        assert!(slots[0].buf.is_empty(), "a write landed after finish");
617    }
618
619    /// A repo-derived check name cannot smuggle control bytes onto a live
620    /// terminal: sanitised at begin, once, for every later paint.
621    #[test]
622    fn a_slot_name_is_sanitised_at_begin() {
623        let stage = Stage::begin(&["evil\u{1b}[2Jname\rhere"]);
624        let slots = stage.slots.lock().unwrap();
625        assert!(!slots[0].name.contains('\u{1b}'), "{:?}", slots[0].name);
626        assert!(!slots[0].name.contains('\r'), "{:?}", slots[0].name);
627    }
628
629    /// Region names drop the stage's own prefix — it is the same twelve
630    /// characters on every line.
631    #[test]
632    fn a_slot_name_drops_the_stage_prefix() {
633        let stage = Stage::begin(&["pre-commit-clippy", "pre-push-run-tests", "bare"]);
634        let slots = stage.slots.lock().unwrap();
635        assert_eq!(slots[0].name, "clippy");
636        assert_eq!(slots[1].name, "run-tests");
637        assert_eq!(slots[2].name, "bare");
638    }
639
640    fn row(name: &str, elapsed: f64) -> Row {
641        Row {
642            name: name.into(),
643            elapsed,
644            quiet: 0.0,
645        }
646    }
647
648    const B: Budgets = Budgets {
649        idle: 120,
650        ceiling: 3600,
651    };
652
653    /// The spinner frame comes from the clock: different elapsed, different
654    /// frame; same elapsed, same frame.
655    #[test]
656    fn frames_advance_with_time() {
657        let a = region(&[row("clippy", 0.0)], 80, B);
658        let b = region(&[row("clippy", 0.1)], 80, B);
659        let c = region(&[row("clippy", 1.0)], 80, B);
660        assert_ne!(a.chars().next(), b.chars().next());
661        assert_eq!(a.chars().next(), c.chars().next(), "10 frames per second");
662    }
663
664    /// Names pad to a column so the elapsed figures align — across the
665    /// minute mark too, where the figure changes shape.
666    #[test]
667    fn region_lines_align() {
668        let text = region(&[row("a", 0.0), row("longer-name", 0.0)], 80, B);
669        let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
670        assert_eq!(widths[0], widths[1], "{text:?}");
671        let text = region(&[row("a", 3.2), row("b", 492.0)], 80, B);
672        let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
673        assert_eq!(widths[0], widths[1], "{text:?}");
674        assert!(text.contains("8m12s"), "{text:?}");
675    }
676
677    /// Thirteen running checks paint as twelve lines and one overflow.
678    #[test]
679    fn region_caps_and_counts_the_rest() {
680        let entries: Vec<Row> = (0..13).map(|i| row(&format!("check-{i}"), 0.0)).collect();
681        let text = region(&entries, 80, B);
682        assert_eq!(text.lines().count(), MAX_LINES + 1);
683        assert!(text.ends_with("… and 1 more\n"), "{text:?}");
684    }
685
686    /// A narrow terminal truncates rather than wraps — a wrapped region
687    /// line would break the erase arithmetic.
688    #[test]
689    fn region_respects_width() {
690        let text = region(&[row("a-name-much-longer-than-the-terminal", 0.0)], 20, B);
691        assert!(text.lines().all(|l| l.chars().count() <= 20), "{text:?}");
692    }
693
694    /// No running checks, no region — not even a blank line.
695    #[test]
696    fn an_empty_region_is_empty() {
697        assert_eq!(region(&[], 80, B), "");
698    }
699
700    /// Silence is annotated only once it is news, and names the budget it
701    /// counts toward — a check that just paused between crates says
702    /// nothing extra.
703    #[test]
704    fn a_quiet_check_shows_its_silence_against_the_budget() {
705        let mut r = row("cargo-test", 300.0);
706        r.quiet = 5.0;
707        assert!(!region(&[r.clone()], 80, B).contains("quiet"));
708        r.quiet = 45.0;
709        let text = region(&[r.clone()], 80, B);
710        assert!(text.contains("quiet 45s/2m00s"), "{text:?}");
711        let off = Budgets { idle: 0, ..B };
712        let text = region(&[r], 80, off);
713        assert!(
714            text.contains("quiet 45s") && !text.contains('/'),
715            "{text:?}"
716        );
717    }
718
719    /// The ceiling appears once a check is 80% of the way to it — the cliff,
720    /// shown before the fall — and never for a disabled ceiling.
721    #[test]
722    fn the_ceiling_shows_only_when_it_is_near() {
723        assert!(!region(&[row("cargo-test", 1000.0)], 80, B).contains("/1h00m"));
724        let text = region(&[row("cargo-test", 3000.0)], 80, B);
725        assert!(text.contains("50m00s/1h00m"), "{text:?}");
726        let off = Budgets { ceiling: 0, ..B };
727        assert!(!region(&[row("cargo-test", 3000.0)], 80, off).contains("/"));
728    }
729
730    /// The heartbeat says how long, how quiet, and — the first time — the
731    /// budgets, so a reader at the far end of a pipe can tell "wait" from
732    /// "kill it" without the docs.
733    #[test]
734    fn a_heartbeat_names_the_budgets_once() {
735        let mut r = row("cargo-test", 60.0);
736        r.quiet = 2.0;
737        let first = beat_line(&r, true, B);
738        assert!(
739            first.contains("cargo-test still running: 1m00s"),
740            "{first:?}"
741        );
742        assert!(first.contains("last output 2s ago"), "{first:?}");
743        assert!(
744            first.contains("2m00s of silence or 1h00m in total"),
745            "{first:?}"
746        );
747        assert!(first.contains("amont.idleTimeout"), "{first:?}");
748        let later = beat_line(&r, false, B);
749        assert!(!later.contains("amont.idleTimeout"), "{later:?}");
750        let off = beat_line(
751            &r,
752            true,
753            Budgets {
754                idle: 0,
755                ceiling: 0,
756            },
757        );
758        assert!(off.contains("off of silence or off in total"), "{off:?}");
759    }
760}