Skip to main content

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}