Skip to main content

xianyun_executor/
logfilter.rs

1//! A terminal filter UI for scoping streamed process output to one
2//! process name.
3//!
4//! Ported from `internal/logfilter/tui.go`. Go builds this on
5//! `charm.land/bubbletea` (the Elm-architecture TUI framework) and
6//! `charm.land/lipgloss` for styling; this port uses `ratatui` with an
7//! inline viewport (`ratatui::Viewport::Inline(1)` plus
8//! `Terminal::insert_before`), which is the direct equivalent of Go's
9//! `tea.Println`-above-a-persistent-view pattern: a one-line status bar
10//! stays pinned at the bottom while log lines scroll permanently above
11//! it. See `PORT_NOTES.md`.
12
13use std::io::Write;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::mpsc;
16use std::sync::{Arc, Mutex};
17use std::time::Duration;
18
19use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
20use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
21use ratatui::Terminal;
22use ratatui::backend::CrosstermBackend;
23use ratatui::style::{Color, Style};
24use ratatui::text::{Line, Span};
25use ratatui::widgets::{Paragraph, Widget};
26use ratatui::{TerminalOptions, Viewport};
27
28const PROCESS_COLORS: [&str; 8] = [
29    "#8be9fd", // cyan
30    "#ff79c6", // pink
31    "#ffb86c", // orange
32    "#bd93f9", // purple
33    "#a4e9ff", // light cyan
34    "#ffaadd", // light pink
35    "#ffcc88", // light orange
36    "#ccb3ff", // light purple
37];
38
39/// Events sent into the event loop running inside [`Tui::start`] by
40/// [`Tui::println`]/[`Tui::quit`], which may be called concurrently from
41/// other threads/tasks sharing the same `Arc<Tui>`.
42enum TuiEvent {
43    Println(String),
44    Quit,
45}
46
47/// Ported from `TUI`. Go's `program`/`ready`/`done` fields (the Bubble
48/// Tea program handle and its lifecycle channels) become, respectively:
49/// a channel `Sender<TuiEvent>` the running event loop listens on
50/// (`tx`), and two `AtomicBool`s (`ready`/`done`) standing in for Go's
51/// closed-channel-as-signal idiom. `filter` is unchanged from the
52/// pre-existing port.
53#[derive(Default)]
54pub struct Tui {
55    filter: Mutex<String>,
56    tx: Mutex<Option<mpsc::Sender<TuiEvent>>>,
57    ready: AtomicBool,
58    done: AtomicBool,
59    output: Mutex<Option<Box<dyn Write + Send>>>,
60}
61
62impl Tui {
63    /// Ported from `NewTUI`.
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Ported from `TUI.Start`. Takes `&self` rather than Go's method
69    /// receiver's implicit `&mut`: `Tui` is shared via `Arc<Tui>` (see
70    /// `Context`), so `quit`/`println` must be callable from other
71    /// threads while this runs its event loop, exactly the situation
72    /// Go's version handles with its own `program`/`ready`/`done`
73    /// fields -- interior mutability (the `Mutex`/`AtomicBool` fields
74    /// above) plays the same role here.
75    ///
76    /// Sets up a ratatui terminal with a one-line inline viewport
77    /// (`Viewport::Inline(1)`), matching Go's `tea.Println` pattern:
78    /// log lines print permanently above the viewport via
79    /// `Terminal::insert_before`, and the viewport itself always shows
80    /// the current filter status line. Blocks until a quit is
81    /// requested (either `Ctrl-C` typed into the terminal or
82    /// [`Tui::quit`] called from elsewhere), matching Go's
83    /// `program.Run()` blocking semantics.
84    pub fn start(&self, out: Box<dyn Write + Send>) -> Result<(), TuiError> {
85        let (tx, rx) = mpsc::channel();
86        *self.tx.lock().unwrap() = Some(tx);
87
88        let result = self.run_event_loop(out, &rx);
89
90        self.done.store(true, Ordering::SeqCst);
91        self.ready.store(false, Ordering::SeqCst);
92        *self.tx.lock().unwrap() = None;
93
94        result
95    }
96
97    fn run_event_loop(
98        &self,
99        out: Box<dyn Write + Send>,
100        rx: &mpsc::Receiver<TuiEvent>,
101    ) -> Result<(), TuiError> {
102        let backend = CrosstermBackend::new(out);
103        let mut terminal = Terminal::with_options(
104            backend,
105            TerminalOptions {
106                viewport: Viewport::Inline(1),
107            },
108        )
109        .map_err(TuiError)?;
110
111        enable_raw_mode().map_err(TuiError)?;
112        self.ready.store(true, Ordering::SeqCst);
113
114        let outcome = self.drive(&mut terminal, rx);
115
116        let _ = disable_raw_mode();
117
118        outcome
119    }
120
121    fn drive(
122        &self,
123        terminal: &mut Terminal<CrosstermBackend<Box<dyn Write + Send>>>,
124        rx: &mpsc::Receiver<TuiEvent>,
125    ) -> Result<(), TuiError> {
126        loop {
127            self.draw_status(terminal)?;
128
129            if event::poll(Duration::from_millis(50)).map_err(TuiError)?
130                && let Event::Key(key) = event::read().map_err(TuiError)?
131            {
132                let current = self.filter();
133                match filter_action(&current, key) {
134                    FilterAction::SetFilter(new_filter) => self.set_filter(&new_filter),
135                    FilterAction::Quit => return Ok(()),
136                    FilterAction::Unchanged => {}
137                }
138            }
139
140            while let Ok(event) = rx.try_recv() {
141                match event {
142                    TuiEvent::Println(line) => {
143                        terminal
144                            .insert_before(1, |buf| {
145                                Paragraph::new(colorized_line(&line)).render(buf.area, buf);
146                            })
147                            .map_err(TuiError)?;
148                    }
149                    TuiEvent::Quit => return Ok(()),
150                }
151            }
152        }
153    }
154
155    fn draw_status(
156        &self,
157        terminal: &mut Terminal<CrosstermBackend<Box<dyn Write + Send>>>,
158    ) -> Result<(), TuiError> {
159        let filter = self.filter();
160        let status = status_line(&filter);
161        terminal
162            .draw(|frame| {
163                frame.render_widget(Paragraph::new(status), frame.area());
164            })
165            .map_err(TuiError)?;
166        Ok(())
167    }
168
169    /// Ported from `TUI.Quit`. Go's `waitReady` blocks the caller up to
170    /// 5 seconds waiting for `start` to have run at least once before
171    /// quitting; this port instead checks the `ready` flag immediately
172    /// and no-ops if `start` hasn't set it yet (or has already cleared
173    /// it on exit), which preserves the *safety* guarantee ("calling
174    /// quit before/after start never panics or blocks") without
175    /// actually blocking a test for 5 wall-clock seconds.
176    pub fn quit(&self) {
177        if !self.ready.load(Ordering::SeqCst) {
178            return;
179        }
180        if let Some(tx) = self.tx.lock().unwrap().as_ref() {
181            let _ = tx.send(TuiEvent::Quit);
182        }
183    }
184
185    /// Ported from `TUI.Println`. Writes directly to the stored output
186    /// if the TUI has already stopped (mirrors Go's `done` channel
187    /// check); otherwise forwards the colorized line into the running
188    /// event loop's channel. No-ops if `start` was never called,
189    /// mirroring Go's `ready` select-default-return.
190    pub fn println(&self, line: &str) {
191        if self.done.load(Ordering::SeqCst) {
192            if let Some(out) = self.output.lock().unwrap().as_mut() {
193                let _ = writeln!(out, "{line}");
194            }
195            return;
196        }
197
198        if !self.ready.load(Ordering::SeqCst) {
199            return;
200        }
201
202        if let Some(tx) = self.tx.lock().unwrap().as_ref() {
203            let _ = tx.send(TuiEvent::Println(colorize_process(line)));
204        }
205    }
206
207    /// Ported from `TUI.Filter`.
208    pub fn filter(&self) -> String {
209        self.filter.lock().unwrap().clone()
210    }
211
212    /// Ported from `TUI.setFilter`.
213    fn set_filter(&self, filter: &str) {
214        *self.filter.lock().unwrap() = filter.to_string();
215    }
216
217    /// Ported from `TUI.Matches`.
218    pub fn matches(&self, line: &str) -> bool {
219        let filter = self.filter();
220        if filter.is_empty() {
221            return true;
222        }
223
224        extract_process(line).contains(&filter)
225    }
226
227    /// Ported from `NewWriter`/`tuiWriter`. Returns a [`Write`]
228    /// implementation that forwards each line written to it (minus a
229    /// single trailing newline, matching Go's `strings.TrimSuffix`) to
230    /// [`Tui::println`].
231    pub fn new_writer(self: &Arc<Self>) -> TuiWriter {
232        TuiWriter { tui: self.clone() }
233    }
234}
235
236/// Ported from `tuiWriter`.
237pub struct TuiWriter {
238    tui: Arc<Tui>,
239}
240
241impl Write for TuiWriter {
242    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
243        let text = String::from_utf8_lossy(buf);
244        self.tui.println(text.strip_suffix('\n').unwrap_or(&text));
245        Ok(buf.len())
246    }
247
248    fn flush(&mut self) -> std::io::Result<()> {
249        Ok(())
250    }
251}
252
253/// The outcome of feeding one key press through the filter-editing state
254/// machine. Factored out of the event loop so it's directly unit
255/// testable without a real terminal, mirroring `tuiModel.Update`'s
256/// `switch key.Code` exactly.
257#[derive(Debug, PartialEq, Eq)]
258enum FilterAction {
259    SetFilter(String),
260    Quit,
261    Unchanged,
262}
263
264/// Ported from `tuiModel.Update`'s `tea.KeyPressMsg` branch.
265fn filter_action(current: &str, key: KeyEvent) -> FilterAction {
266    if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
267        return FilterAction::Quit;
268    }
269
270    match key.code {
271        KeyCode::Esc => FilterAction::SetFilter(String::new()),
272        KeyCode::Backspace | KeyCode::Delete => {
273            if current.is_empty() {
274                FilterAction::Unchanged
275            } else {
276                let mut chars: Vec<char> = current.chars().collect();
277                chars.pop();
278                FilterAction::SetFilter(chars.into_iter().collect())
279            }
280        }
281        KeyCode::Char(c) => FilterAction::SetFilter(format!("{current}{c}")),
282        _ => FilterAction::Unchanged,
283    }
284}
285
286/// Ported from `tuiModel.View`.
287fn status_line(filter: &str) -> String {
288    if filter.is_empty() {
289        "filter: (none)  —  type a process name to filter  |  ESC to clear".to_string()
290    } else {
291        format!("filter: {filter}▌")
292    }
293}
294
295/// Ported from the error returned by `TUI.Start`.
296#[derive(Debug, thiserror::Error)]
297#[error("TUI exited with error: {0}")]
298pub struct TuiError(#[from] std::io::Error);
299
300/// Converts a `#rrggbb` hex color (as used by `PROCESS_COLORS`) into its
301/// RGB components. Panics on malformed input, since `PROCESS_COLORS` is
302/// a fixed, hand-verified constant, not user input.
303fn hex_to_rgb(hex: &str) -> (u8, u8, u8) {
304    let hex = hex.strip_prefix('#').unwrap_or(hex);
305    let r = u8::from_str_radix(&hex[0..2], 16).unwrap();
306    let g = u8::from_str_radix(&hex[2..4], 16).unwrap();
307    let b = u8::from_str_radix(&hex[4..6], 16).unwrap();
308    (r, g, b)
309}
310
311/// Picks a color for `name` the same way Go's `ColorizeProcess` does:
312/// indexing `PROCESS_COLORS` by the first *byte* of `name` modulo the
313/// palette size (not the first Unicode scalar value), to stay
314/// byte-for-byte compatible with Go's `int(name[0]) % len(processColors)`
315/// even for multi-byte UTF-8 process names.
316fn color_for_name(name: &str) -> (u8, u8, u8) {
317    let first_byte = name.as_bytes()[0];
318    let hex = PROCESS_COLORS[first_byte as usize % PROCESS_COLORS.len()];
319    hex_to_rgb(hex)
320}
321
322/// Splits a `[name]rest` line into its bracketed name and the remaining
323/// text, matching `ColorizeProcess`'s/`extractProcess`'s shared
324/// bracket-parsing rules (must start with `[`, must contain a closing
325/// `]`, name must be non-empty).
326fn split_bracket(line: &str) -> Option<(&str, &str)> {
327    if !line.starts_with('[') {
328        return None;
329    }
330    let end = line.find(']')?;
331    let name = &line[1..end];
332    if name.is_empty() {
333        return None;
334    }
335    Some((name, &line[end + 1..]))
336}
337
338/// Ported from `ColorizeProcess`. Returns `line` unchanged if it doesn't
339/// start with a `[name]` prefix; otherwise wraps the `[name]` prefix in
340/// a truecolor ANSI SGR escape, picking the color the same way Go's
341/// `lipgloss`-rendered version does. Built from plain ANSI escapes
342/// (rather than threading `anstream`/`anstyle` through here) because
343/// this function has no access to the eventual output stream to detect
344/// `NO_COLOR`/non-TTY -- that detection already happens one layer up,
345/// at the ratatui `CrosstermBackend` the colorized text is rendered
346/// into (ratatui only emits color escapes when the backend supports
347/// it), and at `TuiWriter`'s direct-write-after-`done` fallback,
348/// which inherits whatever styling policy its destination writer
349/// already applies (matching Go, which never strips color from
350/// `ColorizeProcess`'s output either).
351pub fn colorize_process(line: &str) -> String {
352    let Some((name, rest)) = split_bracket(line) else {
353        return line.to_string();
354    };
355    let (r, g, b) = color_for_name(name);
356    format!("\x1b[38;2;{r};{g};{b}m[{name}]\x1b[0m{rest}")
357}
358
359/// Builds a ratatui [`Line`] with the `[name]` prefix styled using the
360/// same palette as [`colorize_process`], for rendering inside the
361/// inline-viewport event loop (ratatui's `Buffer` cells carry styling
362/// as [`Style`] values, not embedded ANSI escapes, so this is a
363/// separate code path from `colorize_process`'s plain-string output,
364/// sharing only the color-selection logic via [`color_for_name`]).
365fn colorized_line(line: &str) -> Line<'static> {
366    let Some((name, rest)) = split_bracket(line) else {
367        return Line::from(line.to_string());
368    };
369    let (r, g, b) = color_for_name(name);
370    Line::from(vec![
371        Span::styled(
372            format!("[{name}]"),
373            Style::default().fg(Color::Rgb(r, g, b)),
374        ),
375        Span::raw(rest.to_string()),
376    ])
377}
378
379/// Ported from `extractProcess`.
380fn extract_process(line: &str) -> String {
381    split_bracket(line).map_or_else(String::new, |(name, _)| name.to_string())
382}
383
384/// A narrow stand-in for Go's `context.Context`-carried TUI handle.
385/// Ported from `NewContext`/`FromContext`. See `crate::exec::context` for
386/// the broader discussion of why Go's ambient context doesn't translate
387/// 1:1 to Rust. `Context::default()` carries no `Tui`, matching Go's zero
388/// `context.Context` having nothing stored under `contextKey{}`.
389#[derive(Clone, Default)]
390pub struct Context {
391    tui: Option<Arc<Tui>>,
392}
393
394/// Ported from `NewContext`.
395pub fn new_context(_ctx: Context, tui: Arc<Tui>) -> Context {
396    Context { tui: Some(tui) }
397}
398
399/// Ported from `FromContext`.
400pub fn from_context(ctx: &Context) -> Option<Arc<Tui>> {
401    ctx.tui.clone()
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crossterm::event::KeyEventKind;
408
409    fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
410        KeyEvent::new(code, modifiers)
411    }
412
413    fn plain_key(code: KeyCode) -> KeyEvent {
414        let mut k = key(code, KeyModifiers::NONE);
415        k.kind = KeyEventKind::Press;
416        k
417    }
418
419    #[test]
420    fn matches_no_filter() {
421        let tui = Tui::new();
422
423        assert!(tui.matches("[puma] some log"));
424        assert!(tui.matches("[sidekiq] some log"));
425    }
426
427    #[test]
428    fn matches_with_filter() {
429        let tui = Tui::new();
430        tui.set_filter("puma");
431
432        assert!(tui.matches("[puma] some log"));
433        assert!(!tui.matches("[sidekiq] some log"));
434    }
435
436    #[test]
437    fn matches_no_prefix() {
438        let tui = Tui::new();
439        tui.set_filter("puma");
440
441        assert!(!tui.matches("some log without prefix"));
442    }
443
444    #[test]
445    fn extract_process_cases() {
446        assert_eq!(extract_process("[puma] some log"), "puma");
447        assert_eq!(extract_process("no brackets here"), "");
448        assert_eq!(extract_process(""), "");
449        assert_eq!(extract_process("[unclosed bracket"), "");
450    }
451
452    #[test]
453    fn new_context_from_context() {
454        let tui = Arc::new(Tui::new());
455        let ctx = new_context(Context::default(), tui);
456
457        assert!(from_context(&ctx).is_some());
458    }
459
460    #[test]
461    fn from_context_nil_when_not_set() {
462        assert!(from_context(&Context::default()).is_none());
463    }
464
465    #[test]
466    fn quit_before_start_is_a_safe_noop() {
467        let tui = Tui::new();
468        tui.quit();
469    }
470
471    #[test]
472    fn println_before_start_is_a_safe_noop() {
473        let tui = Tui::new();
474        tui.println("[puma] hello");
475    }
476
477    #[test]
478    fn colorize_process_no_bracket_passthrough() {
479        assert_eq!(colorize_process("no brackets here"), "no brackets here");
480    }
481
482    #[test]
483    fn colorize_process_empty_name_passthrough() {
484        assert_eq!(colorize_process("[] rest"), "[] rest");
485    }
486
487    #[test]
488    fn colorize_process_unclosed_bracket_passthrough() {
489        assert_eq!(colorize_process("[unclosed"), "[unclosed");
490    }
491
492    #[test]
493    fn colorize_process_empty_line_passthrough() {
494        assert_eq!(colorize_process(""), "");
495    }
496
497    #[test]
498    fn hex_to_rgb_cyan() {
499        assert_eq!(hex_to_rgb("#8be9fd"), (0x8b, 0xe9, 0xfd));
500    }
501
502    #[test]
503    fn hex_to_rgb_pink() {
504        assert_eq!(hex_to_rgb("#ff79c6"), (0xff, 0x79, 0xc6));
505    }
506
507    #[test]
508    fn colorize_process_contains_expected_escape_for_puma() {
509        // 'p' == 112, 112 % 8 == 0 -> processColors[0] == "#8be9fd".
510        assert_eq!(b'p' % 8, 0);
511        let out = colorize_process("[puma] starting server");
512        assert_eq!(out, "\x1b[38;2;139;233;253m[puma]\x1b[0m starting server");
513    }
514
515    #[test]
516    fn colorize_process_different_names_can_pick_different_colors() {
517        // 'a' == 97, 97 % 8 == 1 -> processColors[1] == "#ff79c6".
518        assert_eq!(b'a' % 8, 1);
519        let out = colorize_process("[app] booting");
520        assert_eq!(out, "\x1b[38;2;255;121;198m[app]\x1b[0m booting");
521    }
522
523    #[test]
524    fn colorize_process_byte_indexing_matches_go_modulo() {
525        // Hand-computed first-byte % 8 values for a spread of process
526        // names, matching Go's `int(name[0]) % len(processColors)`.
527        for (name, expected_index) in [
528            ("sidekiq", usize::from(b's' % 8)),
529            ("worker", usize::from(b'w' % 8)),
530            ("db", usize::from(b'd' % 8)),
531        ] {
532            let line = format!("[{name}] x");
533            let out = colorize_process(&line);
534            let (r, g, b) = hex_to_rgb(PROCESS_COLORS[expected_index]);
535            assert!(
536                out.starts_with(&format!("\x1b[38;2;{r};{g};{b}m[{name}]\x1b[0m")),
537                "name={name} expected color index {expected_index}, got {out}"
538            );
539        }
540    }
541
542    #[test]
543    fn status_line_empty_filter() {
544        assert_eq!(
545            status_line(""),
546            "filter: (none)  —  type a process name to filter  |  ESC to clear"
547        );
548    }
549
550    #[test]
551    fn status_line_with_filter_has_cursor_block() {
552        assert_eq!(status_line("puma"), "filter: puma▌");
553    }
554
555    #[test]
556    fn filter_action_esc_clears() {
557        assert_eq!(
558            filter_action("puma", plain_key(KeyCode::Esc)),
559            FilterAction::SetFilter(String::new())
560        );
561    }
562
563    #[test]
564    fn filter_action_backspace_on_nonempty_removes_last_char() {
565        assert_eq!(
566            filter_action("puma", plain_key(KeyCode::Backspace)),
567            FilterAction::SetFilter("pum".to_string())
568        );
569    }
570
571    #[test]
572    fn filter_action_delete_on_nonempty_removes_last_char() {
573        assert_eq!(
574            filter_action("puma", plain_key(KeyCode::Delete)),
575            FilterAction::SetFilter("pum".to_string())
576        );
577    }
578
579    #[test]
580    fn filter_action_backspace_on_empty_is_unchanged() {
581        assert_eq!(
582            filter_action("", plain_key(KeyCode::Backspace)),
583            FilterAction::Unchanged
584        );
585    }
586
587    #[test]
588    fn filter_action_ctrl_c_quits() {
589        assert_eq!(
590            filter_action("puma", key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
591            FilterAction::Quit
592        );
593    }
594
595    #[test]
596    fn filter_action_printable_char_appends() {
597        assert_eq!(
598            filter_action("pu", plain_key(KeyCode::Char('m'))),
599            FilterAction::SetFilter("pum".to_string())
600        );
601    }
602
603    #[test]
604    fn filter_action_unhandled_key_is_unchanged() {
605        assert_eq!(
606            filter_action("puma", plain_key(KeyCode::F(5))),
607            FilterAction::Unchanged
608        );
609    }
610}