1use 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
48const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
51
52const MAX_LINES: usize = 12;
55
56struct Slot {
58 name: String,
61 started: Instant,
64 last_output: Instant,
67 next_beat: u64,
69 buf: Vec<u8>,
70 running: bool,
72 done: bool,
73}
74
75pub struct Stage {
78 slots: Mutex<Vec<Slot>>,
79 out: Mutex<usize>,
82 live: bool,
84 stop: AtomicBool,
85}
86
87thread_local! {
88 static SINK: RefCell<Option<(Arc<Stage>, usize)>> = const { RefCell::new(None) };
90}
91
92impl Stage {
93 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 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 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 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 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 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 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 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 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
274fn 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#[derive(Debug, Clone)]
290pub struct Row {
291 pub name: String,
292 pub elapsed: f64,
294 pub quiet: f64,
296}
297
298#[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
313const QUIET_NOTE_SECS: f64 = 30.0;
317
318const HEARTBEAT_SECS: u64 = 60;
320
321fn 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
332fn 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
386fn 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
437fn 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
463pub 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
477pub 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
497pub struct SinkGuard;
499
500impl Drop for SinkGuard {
501 fn drop(&mut self) {
502 SINK.with(|s| *s.borrow_mut() = None);
503 }
504}
505
506pub fn current_sink() -> Option<(Arc<Stage>, usize)> {
509 SINK.with(|s| s.borrow().clone())
510}
511
512pub 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#[macro_export]
533macro_rules! say {
534 ($($arg:tt)*) => {
535 $crate::live::say(&format!($($arg)*))
536 };
537}
538
539pub 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
546pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
696 fn an_empty_region_is_empty() {
697 assert_eq!(region(&[], 80, B), "");
698 }
699
700 #[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 #[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 #[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}