rusmes_cli/commands/watch.rs
1//! `--watch` mode: continuously redraw terminal output at a fixed interval.
2//!
3//! Callers pass in an async closure that renders one "frame" of output as a
4//! `String`, a refresh interval in milliseconds, and an optional cancellation
5//! receiver. The runner clears the screen, prints the rendered frame, waits
6//! for the interval, then repeats. It exits cleanly on SIGINT (Ctrl-C) or
7//! when the cancellation receiver fires.
8
9use anyhow::Result;
10use crossterm::{
11 cursor,
12 terminal::{self, ClearType},
13 ExecutableCommand,
14};
15use std::io::Write;
16use std::time::Duration;
17use tokio::signal::ctrl_c;
18use tokio::sync::oneshot;
19use tokio::time::sleep;
20
21/// Run `render_fn` in a loop, refreshing the terminal every `interval_ms`
22/// milliseconds. Returns as soon as Ctrl-C is received or the optional
23/// `cancel` channel fires.
24///
25/// # Arguments
26/// * `interval_ms` — refresh interval in milliseconds (clamped to ≥1 ms)
27/// * `render_fn` — async closure that returns a frame string or an error
28/// * `cancel` — optional oneshot receiver; when it fires the loop exits
29pub async fn run_watch<F, Fut>(
30 interval_ms: u64,
31 mut render_fn: F,
32 cancel: Option<oneshot::Receiver<()>>,
33) -> Result<()>
34where
35 F: FnMut() -> Fut,
36 Fut: std::future::Future<Output = Result<String>>,
37{
38 let interval = Duration::from_millis(interval_ms.max(1));
39 let mut stdout = std::io::stdout();
40
41 // Convert the optional cancel receiver into a fused future so we can
42 // poll it repeatedly across iterations. When `cancel` is `None` we use
43 // a channel whose sender is immediately dropped, which makes the receiver
44 // return `Err(RecvError)` immediately — we special-case that path below.
45 enum CancelFut {
46 Active(oneshot::Receiver<()>),
47 /// No cancel channel was supplied; never resolves.
48 Absent,
49 /// The cancel signal has already been received.
50 Fired,
51 }
52
53 let mut cancel_fut = match cancel {
54 Some(rx) => CancelFut::Active(rx),
55 None => CancelFut::Absent,
56 };
57
58 loop {
59 // If cancellation already fired on a previous iteration, exit.
60 if matches!(cancel_fut, CancelFut::Fired) {
61 writeln!(stdout)?;
62 break;
63 }
64
65 // Render the frame.
66 let frame = render_fn().await?;
67
68 // Clear terminal and move cursor to top-left.
69 stdout.execute(terminal::Clear(ClearType::All))?;
70 stdout.execute(cursor::MoveTo(0, 0))?;
71 write!(stdout, "{frame}")?;
72 stdout.flush()?;
73
74 // Wait for: interval expiry, SIGINT, or cancel channel — whichever
75 // comes first.
76 match cancel_fut {
77 CancelFut::Active(rx) => {
78 // We need to be able to re-use `rx` if the sleep wins, so we
79 // use a mutable reference inside the select.
80 let mut rx = rx;
81 tokio::select! {
82 _ = sleep(interval) => {
83 // Sleep won: put the receiver back and keep looping.
84 cancel_fut = CancelFut::Active(rx);
85 }
86 _ = ctrl_c() => {
87 writeln!(stdout)?;
88 // `rx` is dropped here — that's fine.
89 break;
90 }
91 result = &mut rx => {
92 // Cancel channel fired (or sender was dropped).
93 let _ = result;
94 cancel_fut = CancelFut::Fired;
95 continue;
96 }
97 }
98 }
99 CancelFut::Absent => {
100 tokio::select! {
101 _ = sleep(interval) => {
102 cancel_fut = CancelFut::Absent;
103 }
104 _ = ctrl_c() => {
105 writeln!(stdout)?;
106 break;
107 }
108 }
109 }
110 CancelFut::Fired => {
111 // Handled at the top of the loop; shouldn't reach here.
112 writeln!(stdout)?;
113 break;
114 }
115 }
116 }
117
118 Ok(())
119}
120
121/// Convenience wrapper for production use (no cancel channel, interval in
122/// seconds).
123///
124/// # Arguments
125/// * `interval_secs` — refresh interval in seconds (clamped to ≥1 s)
126/// * `render_fn` — async closure that returns a frame string or an error
127pub async fn run_watch_secs<F, Fut>(interval_secs: u64, render_fn: F) -> Result<()>
128where
129 F: FnMut() -> Fut,
130 Fut: std::future::Future<Output = Result<String>>,
131{
132 let ms = interval_secs.max(1).saturating_mul(1_000);
133 run_watch(ms, render_fn, None).await
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 /// Verify that passing a pre-fired cancel oneshot to `run_watch` causes
141 /// the loop to exit cleanly without panic or error.
142 #[tokio::test]
143 async fn test_watch_exits_on_signal() {
144 let (tx, rx) = oneshot::channel::<()>();
145
146 // Fire the cancel signal *before* starting the watch loop so the
147 // receiver is already resolved when `tokio::select!` polls it.
148 tx.send(()).expect("send should not fail");
149
150 let result = run_watch(
151 0, // 0 ms → clamped to 1 ms
152 || async { Ok("frame".to_string()) },
153 Some(rx),
154 )
155 .await;
156
157 assert!(
158 result.is_ok(),
159 "watch loop should exit without error on cancel"
160 );
161 }
162}