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    /// Is this the PUSH stage? Read by the heartbeat, which has something to
85    /// say about a long gate there and nothing to say about one at commit
86    /// time — see [`beat_line`]. Derived from the names, which already
87    /// carry the trigger.
88    on_push: bool,
89    stop: AtomicBool,
90}
91
92thread_local! {
93    /// Where [`say`] routes on THIS thread: a stage and a slot index.
94    static SINK: RefCell<Option<(Arc<Stage>, usize)>> = const { RefCell::new(None) };
95}
96
97impl Stage {
98    /// A stage over `names`, in dispatch order. Does nothing visible until
99    /// checks start entering (the region) or finishing (the blocks).
100    pub fn begin(names: &[&str]) -> Arc<Stage> {
101        let now = Instant::now();
102        let stage = Arc::new(Stage {
103            slots: Mutex::new(
104                names
105                    .iter()
106                    .map(|n| Slot {
107                        // Every name in a stage carries the stage's own
108                        // prefix ("pre-commit-clippy"); the region drops it
109                        // — twelve identical prefixes say nothing.
110                        name: crate::ui::sanitize(
111                            n.strip_prefix("pre-commit-")
112                                .or_else(|| n.strip_prefix("pre-push-"))
113                                .unwrap_or(n),
114                        ),
115                        started: now,
116                        last_output: now,
117                        next_beat: HEARTBEAT_SECS,
118                        buf: Vec::new(),
119                        running: false,
120                        done: false,
121                    })
122                    .collect(),
123            ),
124            out: Mutex::new(0),
125            live: enabled() && watching(),
126            // The names arrive fully qualified and the loop above has
127            // already had to strip the trigger to display them, so the
128            // stage can answer this without dispatch passing anything in.
129            on_push: names.iter().any(|n| n.starts_with("pre-push-")),
130            stop: AtomicBool::new(false),
131        });
132        if stage.live {
133            // The ticker holds a Weak: the stage dropping is what ends it,
134            // so a paint can never outlive the region's owner.
135            let weak = Arc::downgrade(&stage);
136            let _ = std::thread::Builder::new()
137                .name("amont-live".into())
138                .spawn(move || tick(weak));
139        } else if enabled() {
140            // Nobody is watching a terminal — an agent, CI, a pipe — and a
141            // captured check shows nothing until it finishes. The heartbeat
142            // is the one line a minute that says it is alive, which is the
143            // difference between "wait" and "kill it" for whoever is on the
144            // other end of the pipe.
145            let weak = Arc::downgrade(&stage);
146            let _ = std::thread::Builder::new()
147                .name("amont-heartbeat".into())
148                .spawn(move || heartbeat(weak));
149        }
150        stage
151    }
152
153    /// Route this thread's [`say`] calls into slot `idx` until the guard
154    /// drops. Installed by the dispatcher around each `check.run`. Also
155    /// starts the slot's clock and puts it in the region.
156    pub fn enter(self: &Arc<Stage>, idx: usize) -> SinkGuard {
157        {
158            let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
159            if let Some(slot) = slots.get_mut(idx) {
160                slot.running = true;
161                slot.started = Instant::now();
162                slot.last_output = slot.started;
163                slot.next_beat = HEARTBEAT_SECS;
164            }
165        }
166        SINK.with(|s| *s.borrow_mut() = Some((Arc::clone(self), idx)));
167        SinkGuard
168    }
169
170    /// Append raw bytes (a captured child's output) to slot `idx`.
171    pub fn append_raw(&self, idx: usize, bytes: &[u8]) {
172        let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
173        if let Some(slot) = slots.get_mut(idx) {
174            if !slot.done {
175                slot.buf.extend_from_slice(bytes);
176                slot.last_output = Instant::now();
177            }
178        }
179    }
180
181    fn append_line(&self, idx: usize, line: &str) {
182        let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
183        if let Some(slot) = slots.get_mut(idx) {
184            if !slot.done {
185                slot.buf.extend_from_slice(line.as_bytes());
186                slot.buf.push(b'\n');
187                slot.last_output = Instant::now();
188            }
189        }
190    }
191
192    /// The check is over: emit everything it said as ONE contiguous write,
193    /// with the region lifted out of the way first and repainted after —
194    /// blocks pile up above, spinners stay below.
195    ///
196    /// Called by the dispatcher after `check.run` returns (still on the
197    /// check's thread, so a torn-down thread cannot strand a buffer — the
198    /// same `catch_unwind` that feeds the dead-check outcome runs first).
199    pub fn finish(&self, idx: usize) {
200        let block = {
201            let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
202            let Some(slot) = slots.get_mut(idx) else {
203                return;
204            };
205            slot.done = true;
206            slot.running = false;
207            std::mem::take(&mut slot.buf)
208        };
209        if block.is_empty() && !self.live {
210            return;
211        }
212        let mut drawn = self.out.lock().unwrap_or_else(|p| p.into_inner());
213        if !block.is_empty() {
214            if *drawn > 0 {
215                let mut err = std::io::stderr().lock();
216                let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
217                let _ = err.flush();
218                *drawn = 0;
219            }
220            let stdout = std::io::stdout();
221            let mut handle = stdout.lock();
222            let _ = handle.write_all(&block);
223            let _ = handle.flush();
224        }
225        self.repaint(&mut drawn);
226    }
227
228    /// Erase and redraw the region in one stderr write. Lock order is
229    /// `out` → `slots`, everywhere — never the reverse.
230    fn repaint(&self, drawn: &mut usize) {
231        if !self.live {
232            return;
233        }
234        let entries: Vec<Row> = {
235            let slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
236            let now = Instant::now();
237            slots
238                .iter()
239                .filter(|s| s.running && !s.done)
240                .map(|s| Row {
241                    name: s.name.clone(),
242                    elapsed: now.duration_since(s.started).as_secs_f64(),
243                    quiet: now.duration_since(s.last_output).as_secs_f64(),
244                })
245                .collect()
246        };
247        let text = region(&entries, term_width(), budgets());
248        let mut paint = String::new();
249        if *drawn > 0 {
250            paint.push_str(&format!("\x1b[{}A\x1b[J", *drawn));
251        }
252        paint.push_str(&text);
253        if paint.is_empty() {
254            return;
255        }
256        let mut err = std::io::stderr().lock();
257        let _ = err.write_all(paint.as_bytes());
258        let _ = err.flush();
259        *drawn = text.matches('\n').count();
260    }
261}
262
263impl Drop for Stage {
264    /// The stage's end erases whatever the region still shows — a Block
265    /// verdict, a panic on the dispatcher path, anything: no spinner junk
266    /// above the roll-up. (`get_mut`: dropping proves no other thread holds
267    /// the stage, so the locks are free.)
268    fn drop(&mut self) {
269        self.stop.store(true, Ordering::Relaxed);
270        if !self.live {
271            return;
272        }
273        let drawn = self.out.get_mut().unwrap_or_else(|p| p.into_inner());
274        if *drawn > 0 {
275            let mut err = std::io::stderr().lock();
276            let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
277            let _ = err.flush();
278            *drawn = 0;
279        }
280    }
281}
282
283/// The ticker: repaint every 80ms until the stage drops or tells it to
284/// stop. Holds only a `Weak`, so it can never keep a finished stage alive.
285fn tick(weak: Weak<Stage>) {
286    loop {
287        std::thread::sleep(std::time::Duration::from_millis(80));
288        let Some(stage) = weak.upgrade() else { return };
289        if stage.stop.load(Ordering::Relaxed) {
290            return;
291        }
292        let mut drawn = stage.out.lock().unwrap_or_else(|p| p.into_inner());
293        stage.repaint(&mut drawn);
294    }
295}
296
297/// One running check, as the region and the heartbeat see it.
298#[derive(Debug, Clone)]
299pub struct Row {
300    pub name: String,
301    /// Seconds since the check entered.
302    pub elapsed: f64,
303    /// Seconds since it last wrote anything.
304    pub quiet: f64,
305}
306
307/// The two clocks, as the region annotates them: `(idle, ceiling)` in
308/// seconds, `0` for off.
309#[derive(Debug, Clone, Copy)]
310pub struct Budgets {
311    pub idle: u64,
312    pub ceiling: u64,
313}
314
315fn budgets() -> Budgets {
316    Budgets {
317        idle: crate::hooks::common::idle_timeout(),
318        ceiling: crate::hooks::common::check_timeout(),
319    }
320}
321
322/// How long a check must be quiet before the region says so. A test suite
323/// pauses this long between crates without anything being wrong; past it,
324/// the reader wants to know the silence is being counted.
325const QUIET_NOTE_SECS: f64 = 30.0;
326
327/// The non-tty heartbeat's period: one line a minute per running check.
328const HEARTBEAT_SECS: u64 = 60;
329
330/// Elapsed time in a fixed six-column figure: `  3.2s` under a minute,
331/// `8m12s` and `1h02m` above, so the column stays aligned as the suite
332/// crosses the minute.
333fn elapsed_column(secs: f64) -> String {
334    if secs < 60.0 {
335        format!("{secs:>5.1}s")
336    } else {
337        format!("{:>6}", crate::hooks::common::human_secs(secs as u64))
338    }
339}
340
341/// The region's text: one `⠹ name  12.3s` line per running check, capped at
342/// [`MAX_LINES`] plus a `… and N more` overflow line. Pure — the ticker is
343/// a thin shell around this, and the tests drive it directly.
344///
345/// Two annotations, each only when it carries news: `· quiet 45s/2m` once
346/// a check has been silent past [`QUIET_NOTE_SECS`] (with the silence
347/// budget it is counting toward, when there is one), and `· 48m/60m` once
348/// elapsed passes 80% of the ceiling — the cliff, shown before the fall.
349fn region(entries: &[Row], width: usize, budgets: Budgets) -> String {
350    if entries.is_empty() {
351        return String::new();
352    }
353    let pad = entries
354        .iter()
355        .take(MAX_LINES)
356        .map(|r| r.name.chars().count())
357        .max()
358        .unwrap_or(0);
359    let mut out = String::new();
360    for row in entries.iter().take(MAX_LINES) {
361        let frame = FRAMES[((row.elapsed * 10.0) as usize) % FRAMES.len()];
362        let name = &row.name;
363        let mut line = format!("{frame} {name:<pad$} {}", elapsed_column(row.elapsed));
364        if row.quiet >= QUIET_NOTE_SECS {
365            let quiet = crate::hooks::common::human_secs(row.quiet as u64);
366            if budgets.idle > 0 {
367                line.push_str(&format!(
368                    " · quiet {quiet}/{}",
369                    crate::hooks::common::human_secs(budgets.idle)
370                ));
371            } else {
372                line.push_str(&format!(" · quiet {quiet}"));
373            }
374        }
375        if budgets.ceiling > 0 && row.elapsed >= 0.8 * budgets.ceiling as f64 {
376            line.push_str(&format!(
377                " · {}/{}",
378                crate::hooks::common::human_secs(row.elapsed as u64),
379                crate::hooks::common::human_secs(budgets.ceiling)
380            ));
381        }
382        if line.chars().count() > width {
383            out.extend(line.chars().take(width));
384        } else {
385            out.push_str(&line);
386        }
387        out.push('\n');
388    }
389    if entries.len() > MAX_LINES {
390        out.push_str(&format!("… and {} more\n", entries.len() - MAX_LINES));
391    }
392    out
393}
394
395/// The heartbeat: once a minute, for each check still running, one plain
396/// line on stderr — elapsed, and how long since it last said anything.
397/// Not a region: nothing is erased or repainted, because nobody is looking
398/// at a cursor; whoever reads this reads a log.
399///
400/// The first beat for a check also names the two budgets, once, so the
401/// reader can tell how far it is from being killed without opening the
402/// docs. Written under the same `out` lock as the blocks, so a beat never
403/// lands inside one.
404fn heartbeat(weak: Weak<Stage>) {
405    loop {
406        std::thread::sleep(std::time::Duration::from_secs(1));
407        let Some(stage) = weak.upgrade() else { return };
408        if stage.stop.load(Ordering::Relaxed) {
409            return;
410        }
411        let due: Vec<(Row, bool)> = {
412            let mut slots = stage.slots.lock().unwrap_or_else(|p| p.into_inner());
413            let now = Instant::now();
414            let mut due = Vec::new();
415            for s in slots.iter_mut().filter(|s| s.running && !s.done) {
416                let elapsed = now.duration_since(s.started).as_secs();
417                if elapsed >= s.next_beat {
418                    let first = s.next_beat == HEARTBEAT_SECS;
419                    s.next_beat += HEARTBEAT_SECS;
420                    due.push((
421                        Row {
422                            name: s.name.clone(),
423                            elapsed: elapsed as f64,
424                            quiet: now.duration_since(s.last_output).as_secs_f64(),
425                        },
426                        first,
427                    ));
428                }
429            }
430            due
431        };
432        if due.is_empty() {
433            continue;
434        }
435        let text: String = due
436            .iter()
437            .map(|(row, first)| beat_line(row, *first, budgets(), stage.on_push))
438            .collect();
439        let _guard = stage.out.lock().unwrap_or_else(|p| p.into_inner());
440        let mut err = std::io::stderr().lock();
441        let _ = err.write_all(text.as_bytes());
442        let _ = err.flush();
443    }
444}
445
446/// One heartbeat line. Pure, for the tests.
447///
448/// On the FIRST beat of a PUSH gate it also names something no other part of
449/// the system is placed to explain. `git push` opens its connection to the
450/// remote, reads the remote refs — which is where the `pre-push` hook's own
451/// stdin comes from — and only then calls the hook. The connection is
452/// therefore already open and goes idle for exactly as long as the gate
453/// runs, and a remote may close it before the gate finishes. git then
454/// reports `Connection reset by peer`, which reads as a network fault and
455/// says nothing about the seven minutes that caused it.
456///
457/// The note does NOT recommend ssh keepalive, and that omission is
458/// deliberate: `ServerAliveInterval 60` was already in force on the machine
459/// where this was diagnosed, and GitHub reset the connection anyway.
460/// Whatever the remote is measuring, it is not packets. Recommending it
461/// would be a confident instruction to change a setting that is probably
462/// already on and cannot help, so the note says so and points at the thing
463/// that does work.
464///
465/// Only on a first beat, so it is said once; only on a push, so a commit
466/// gate never hears it. A first beat is a check that has already run a full
467/// minute, which is the population at risk — no threshold to invent.
468fn beat_line(row: &Row, first: bool, budgets: Budgets, on_push: bool) -> String {
469    use crate::hooks::common::human_secs;
470    let mut line = format!(
471        "  … {} still running: {}, last output {} ago",
472        row.name,
473        human_secs(row.elapsed as u64),
474        human_secs(row.quiet as u64)
475    );
476    if first {
477        let idle = match budgets.idle {
478            0 => "off".to_string(),
479            s => human_secs(s),
480        };
481        let ceiling = match budgets.ceiling {
482            0 => "off".to_string(),
483            s => human_secs(s),
484        };
485        line.push_str(&format!(
486            " (killed after {idle} of silence or {ceiling} in total — amont.idleTimeout / amont.timeout)"
487        ));
488        if on_push {
489            // `concat!`, not a `\`-continued literal: a continuation keeps
490            // the next line's indentation, which turns the message into runs
491            // of spaces. Each line is its own literal and the newlines are
492            // written down, so what is here is what a reader sees.
493            line.push_str(concat!(
494                "\n    git opened its connection to the remote before calling this",
495                "\n    gate, and it stays idle until the gate finishes. A remote may",
496                "\n    close it first — GitHub does — and the push then fails with",
497                "\n    \"Connection reset by peer\", naming the network rather than the",
498                "\n    wait. ssh keepalive does not prevent this.",
499                "\n    Declaring this check at pre-commit moves it off the push path —",
500                "\n    see \"Moving a gate entry earlier\" in the docs.",
501            ));
502        }
503    }
504    line.push('\n');
505    line
506}
507
508/// `$COLUMNS` when it is exported and sane, else a conservative 100 — the
509/// region's lines are short and an ioctl is not worth its portability.
510///
511/// `pub` is now wider than it needs to be — the out-of-crate caller that
512/// justified it, `amont-agent`, is its own project and carries its own copy.
513/// Left public rather than narrowed in the same change that removed it.
514pub fn term_width() -> usize {
515    std::env::var("COLUMNS")
516        .ok()
517        .and_then(|c| c.parse::<usize>().ok())
518        .filter(|w| *w >= 20)
519        .unwrap_or(100)
520}
521
522/// Emits slot `idx`'s block when dropped — however the check's closure
523/// exits, a panic included: the partial output of a check that died still
524/// reaches the reader, above the dead-check verdict the runner fills in.
525pub struct FinishOnDrop<'a> {
526    stage: &'a Stage,
527    idx: usize,
528}
529
530impl<'a> FinishOnDrop<'a> {
531    pub fn new(stage: &'a Stage, idx: usize) -> FinishOnDrop<'a> {
532        FinishOnDrop { stage, idx }
533    }
534}
535
536impl Drop for FinishOnDrop<'_> {
537    fn drop(&mut self) {
538        self.stage.finish(self.idx);
539    }
540}
541
542/// Uninstalls the thread's sink on drop, whatever path the check took out.
543pub struct SinkGuard;
544
545impl Drop for SinkGuard {
546    fn drop(&mut self) {
547        SINK.with(|s| *s.borrow_mut() = None);
548    }
549}
550
551/// The sink installed on THIS thread, if any — how a child-capture helper on
552/// the check's own thread learns where the reader threads should append.
553pub fn current_sink() -> Option<(Arc<Stage>, usize)> {
554    SINK.with(|s| s.borrow().clone())
555}
556
557/// One line of check output, wherever it should go.
558///
559/// THE funnel: `common::ok/fail/warn` call this, so a check's helper prints
560/// land in its slot during a stage and on stdout everywhere else. `line` is
561/// taken without a trailing newline, exactly like `println!`.
562pub fn say(line: &str) {
563    let routed = SINK.with(|s| {
564        s.borrow().as_ref().map(|(stage, idx)| {
565            stage.append_line(*idx, line);
566        })
567    });
568    if routed.is_none() {
569        println!("{line}");
570    }
571}
572
573/// `println!`, stage-aware: formats and routes through [`say`]. What every
574/// direct print inside a CHECK BODY becomes — a line printed raw from a
575/// check thread bypasses the slot and interleaves, which is the bug this
576/// module exists to close.
577#[macro_export]
578macro_rules! say {
579    ($($arg:tt)*) => {
580        $crate::live::say(&format!($($arg)*))
581    };
582}
583
584/// Should a check's SUCCESS line be swallowed?
585///
586/// A hook that passes says one line per check, and on a clean run that is the
587/// entire output: fourteen lines to say nothing happened. At a terminal those
588/// lines are the reassurance that the gate ran. Captured — an agent's tool
589/// result, a CI log — they are re-read on every later turn of the session and
590/// say no more the tenth time than the first.
591///
592/// So the setting names WHO is reading, not how loud to be:
593///
594/// - `never` (default) — today's output, every check says it passed.
595/// - `auto` — quiet when nobody is watching, verbose at a terminal.
596/// - `always` — quiet everywhere.
597///
598/// Only the success lines go. A failure, a warning, a check that could not
599/// run, a repaired file, and the blocked summary are printed under every
600/// setting: quiet is about the uneventful path, and nothing else.
601pub fn quiet() -> bool {
602    static QUIET: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
603    *QUIET.get_or_init(|| {
604        decide(
605            crate::config::enumerated_or("amont.quiet", QUIET_VALUES, "never"),
606            watching(),
607        )
608    })
609}
610
611pub const QUIET_VALUES: &[&str] = &["never", "auto", "always"];
612
613/// Pure, so the three-way decision is testable without a terminal or a config.
614fn decide(setting: &str, watching: bool) -> bool {
615    match setting {
616        "always" => true,
617        "auto" => !watching,
618        // `never`, and anything `enumerated_or` rejected back to the default.
619        _ => false,
620    }
621}
622
623/// Whether the capture mechanism is on at all. `amont.progress false` is the
624/// escape hatch back to raw streaming — one knob, read once.
625pub fn enabled() -> bool {
626    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
627    *ENABLED.get_or_init(|| crate::config::boolean_or("amont.progress", true))
628}
629
630/// Is anyone watching? True only when stderr is a real terminal that speaks
631/// VT: not piped, not redirected, not `TERM=dumb` — and on Windows only
632/// with `TERM` actually set, because bare conhost may not interpret the
633/// cursor codes the region depends on. This is the paint gate; capture
634/// ([`enabled`]) does not consult it.
635pub fn watching() -> bool {
636    static WATCHING: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
637    *WATCHING.get_or_init(|| {
638        if !std::io::stderr().is_terminal() {
639            return false;
640        }
641        match std::env::var("TERM") {
642            Ok(term) => term != "dumb",
643            Err(_) => !cfg!(windows),
644        }
645    })
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    #[test]
653    fn quiet_asks_who_is_reading() {
654        assert!(!decide("never", true));
655        assert!(!decide("never", false));
656        assert!(always_and_auto_agree_at_a_terminal());
657        assert!(decide("auto", false), "captured: nobody is watching");
658        assert!(decide("always", true));
659        assert!(decide("always", false));
660        // An unreadable value has already been reported by `enumerated_or`,
661        // which hands back the default; silence is never assumed.
662        assert!(!decide("shhh", false));
663    }
664
665    fn always_and_auto_agree_at_a_terminal() -> bool {
666        !decide("auto", true) && decide("always", true)
667    }
668
669    /// The atomicity contract at the unit level: two threads writing
670    /// interleaved lines into their own slots come out as two contiguous
671    /// buffers, whatever the scheduler did.
672    #[test]
673    fn slots_do_not_share_a_buffer() {
674        let stage = Stage::begin(&["a", "b"]);
675        std::thread::scope(|scope| {
676            for idx in 0..2 {
677                let stage = Arc::clone(&stage);
678                scope.spawn(move || {
679                    let _guard = stage.enter(idx);
680                    for i in 0..50 {
681                        say(&format!("check-{idx} line-{i}"));
682                        std::thread::yield_now();
683                    }
684                });
685            }
686        });
687        let slots = stage.slots.lock().unwrap();
688        for idx in 0..2 {
689            let text = String::from_utf8(slots[idx].buf.clone()).unwrap();
690            assert_eq!(text.lines().count(), 50);
691            assert!(
692                text.lines()
693                    .all(|l| l.starts_with(&format!("check-{idx} "))),
694                "a foreign line landed in slot {idx}"
695            );
696        }
697    }
698
699    /// A thread with no sink prints; its lines never land in anyone's slot.
700    #[test]
701    fn no_sink_means_no_capture() {
702        let stage = Stage::begin(&["a"]);
703        say("goes to stdout, not to a slot");
704        let slots = stage.slots.lock().unwrap();
705        assert!(slots[0].buf.is_empty());
706    }
707
708    /// After finish, late writes are dropped rather than stranded — a child
709    /// reader thread that outlives its check must not corrupt a later block.
710    #[test]
711    fn a_finished_slot_takes_no_more_writes() {
712        let stage = Stage::begin(&["a"]);
713        stage.append_raw(0, b"before\n");
714        stage.finish(0);
715        stage.append_raw(0, b"after\n");
716        let slots = stage.slots.lock().unwrap();
717        assert!(slots[0].buf.is_empty(), "a write landed after finish");
718    }
719
720    /// A repo-derived check name cannot smuggle control bytes onto a live
721    /// terminal: sanitised at begin, once, for every later paint.
722    #[test]
723    fn a_slot_name_is_sanitised_at_begin() {
724        let stage = Stage::begin(&["evil\u{1b}[2Jname\rhere"]);
725        let slots = stage.slots.lock().unwrap();
726        assert!(!slots[0].name.contains('\u{1b}'), "{:?}", slots[0].name);
727        assert!(!slots[0].name.contains('\r'), "{:?}", slots[0].name);
728    }
729
730    /// Region names drop the stage's own prefix — it is the same twelve
731    /// characters on every line.
732    #[test]
733    fn a_slot_name_drops_the_stage_prefix() {
734        let stage = Stage::begin(&["pre-commit-clippy", "pre-push-run-tests", "bare"]);
735        let slots = stage.slots.lock().unwrap();
736        assert_eq!(slots[0].name, "clippy");
737        assert_eq!(slots[1].name, "run-tests");
738        assert_eq!(slots[2].name, "bare");
739    }
740
741    fn row(name: &str, elapsed: f64) -> Row {
742        Row {
743            name: name.into(),
744            elapsed,
745            quiet: 0.0,
746        }
747    }
748
749    const B: Budgets = Budgets {
750        idle: 120,
751        ceiling: 3600,
752    };
753
754    /// The spinner frame comes from the clock: different elapsed, different
755    /// frame; same elapsed, same frame.
756    #[test]
757    fn frames_advance_with_time() {
758        let a = region(&[row("clippy", 0.0)], 80, B);
759        let b = region(&[row("clippy", 0.1)], 80, B);
760        let c = region(&[row("clippy", 1.0)], 80, B);
761        assert_ne!(a.chars().next(), b.chars().next());
762        assert_eq!(a.chars().next(), c.chars().next(), "10 frames per second");
763    }
764
765    /// Names pad to a column so the elapsed figures align — across the
766    /// minute mark too, where the figure changes shape.
767    #[test]
768    fn region_lines_align() {
769        let text = region(&[row("a", 0.0), row("longer-name", 0.0)], 80, B);
770        let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
771        assert_eq!(widths[0], widths[1], "{text:?}");
772        let text = region(&[row("a", 3.2), row("b", 492.0)], 80, B);
773        let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
774        assert_eq!(widths[0], widths[1], "{text:?}");
775        assert!(text.contains("8m12s"), "{text:?}");
776    }
777
778    /// Thirteen running checks paint as twelve lines and one overflow.
779    #[test]
780    fn region_caps_and_counts_the_rest() {
781        let entries: Vec<Row> = (0..13).map(|i| row(&format!("check-{i}"), 0.0)).collect();
782        let text = region(&entries, 80, B);
783        assert_eq!(text.lines().count(), MAX_LINES + 1);
784        assert!(text.ends_with("… and 1 more\n"), "{text:?}");
785    }
786
787    /// A narrow terminal truncates rather than wraps — a wrapped region
788    /// line would break the erase arithmetic.
789    #[test]
790    fn region_respects_width() {
791        let text = region(&[row("a-name-much-longer-than-the-terminal", 0.0)], 20, B);
792        assert!(text.lines().all(|l| l.chars().count() <= 20), "{text:?}");
793    }
794
795    /// No running checks, no region — not even a blank line.
796    #[test]
797    fn an_empty_region_is_empty() {
798        assert_eq!(region(&[], 80, B), "");
799    }
800
801    /// Silence is annotated only once it is news, and names the budget it
802    /// counts toward — a check that just paused between crates says
803    /// nothing extra.
804    #[test]
805    fn a_quiet_check_shows_its_silence_against_the_budget() {
806        let mut r = row("cargo-test", 300.0);
807        r.quiet = 5.0;
808        assert!(!region(&[r.clone()], 80, B).contains("quiet"));
809        r.quiet = 45.0;
810        let text = region(&[r.clone()], 80, B);
811        assert!(text.contains("quiet 45s/2m00s"), "{text:?}");
812        let off = Budgets { idle: 0, ..B };
813        let text = region(&[r], 80, off);
814        assert!(
815            text.contains("quiet 45s") && !text.contains('/'),
816            "{text:?}"
817        );
818    }
819
820    /// The ceiling appears once a check is 80% of the way to it — the cliff,
821    /// shown before the fall — and never for a disabled ceiling.
822    #[test]
823    fn the_ceiling_shows_only_when_it_is_near() {
824        assert!(!region(&[row("cargo-test", 1000.0)], 80, B).contains("/1h00m"));
825        let text = region(&[row("cargo-test", 3000.0)], 80, B);
826        assert!(text.contains("50m00s/1h00m"), "{text:?}");
827        let off = Budgets { ceiling: 0, ..B };
828        assert!(!region(&[row("cargo-test", 3000.0)], 80, off).contains("/"));
829    }
830
831    /// The heartbeat says how long, how quiet, and — the first time — the
832    /// budgets, so a reader at the far end of a pipe can tell "wait" from
833    /// "kill it" without the docs.
834    #[test]
835    fn a_heartbeat_names_the_budgets_once() {
836        let mut r = row("cargo-test", 60.0);
837        r.quiet = 2.0;
838        let first = beat_line(&r, true, B, false);
839        assert!(
840            first.contains("cargo-test still running: 1m00s"),
841            "{first:?}"
842        );
843        assert!(first.contains("last output 2s ago"), "{first:?}");
844        assert!(
845            first.contains("2m00s of silence or 1h00m in total"),
846            "{first:?}"
847        );
848        assert!(first.contains("amont.idleTimeout"), "{first:?}");
849        let later = beat_line(&r, false, B, false);
850        assert!(!later.contains("amont.idleTimeout"), "{later:?}");
851        let off = beat_line(
852            &r,
853            true,
854            Budgets {
855                idle: 0,
856                ceiling: 0,
857            },
858            false,
859        );
860        assert!(off.contains("off of silence or off in total"), "{off:?}");
861    }
862
863    /// The message, with newlines and indentation flattened.
864    ///
865    /// The note is wrapped for a terminal, so a literal substring can fall
866    /// across a line break — asserting on `"may close it first"` failed for
867    /// no better reason than that `may` ended a line. These tests are about
868    /// what the message SAYS; re-wrapping it should not break them.
869    fn flat(line: &str) -> String {
870        line.split_whitespace().collect::<Vec<_>>().join(" ")
871    }
872
873    /// A long PUSH gate is told what it is sitting on; a commit gate is not.
874    ///
875    /// The three negatives matter as much as the positive. Said on every
876    /// beat it would be nagging; said at commit time it would be false —
877    /// there is no connection open — and a future refactor that wires
878    /// `on_push` to a constant would show up here and nowhere else.
879    #[test]
880    fn a_long_push_gate_is_told_what_it_is_sitting_on() {
881        let r = row("cargo-test", 60.0);
882
883        let pushing = flat(&beat_line(&r, true, B, true));
884        assert!(
885            pushing.contains("A remote may close it first"),
886            "{pushing:?}"
887        );
888        assert!(pushing.contains("Connection reset by peer"), "{pushing:?}");
889        assert!(
890            pushing.contains("Moving a gate entry earlier"),
891            "{pushing:?}"
892        );
893
894        // Once, not every minute.
895        let later = flat(&beat_line(&r, false, B, true));
896        assert!(!later.contains("close it first"), "{later:?}");
897
898        // Never at commit time: nothing is waiting on a socket there.
899        let committing = flat(&beat_line(&r, true, B, false));
900        assert!(!committing.contains("close it first"), "{committing:?}");
901    }
902
903    /// The advice that does NOT appear, and must not come back.
904    ///
905    /// `ServerAliveInterval 60` is the obvious suggestion and it is wrong:
906    /// it was already in force on the machine where this failure was
907    /// diagnosed, and the remote reset the connection regardless. Telling
908    /// every amont user to set it would be confident, actionable and
909    /// useless. This test exists so that a future reader who has the same
910    /// obvious idea meets an argument instead of a blank.
911    #[test]
912    fn the_push_note_does_not_recommend_ssh_keepalive() {
913        let r = row("cargo-test", 60.0);
914        let pushing = flat(&beat_line(&r, true, B, true));
915        assert!(
916            !pushing.contains("ServerAlive"),
917            "keepalive was already on when this failed; recommending it \
918             would be useless advice: {pushing:?}"
919        );
920        assert!(
921            pushing.contains("ssh keepalive does not prevent this"),
922            "say so, rather than leaving the reader to try it: {pushing:?}"
923        );
924    }
925}