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