Skip to main content

rtcom_tui/
run.rs

1//! Event loop driver for the rtcom TUI.
2//!
3//! [`run`] owns the ratatui [`Terminal`], the crossterm key-event
4//! stream, and the bus subscription. It multiplexes them via
5//! `tokio::select!`, re-rendering the app after every iteration and
6//! unwinding cleanly on cancel / user-initiated quit / terminal error.
7//!
8//! The three RAII guards ([`RawModeGuard`], [`AltScreenGuard`], and
9//! the implicit `Terminal` drop) guarantee the terminal is restored to
10//! its original state on every exit path — including panic — so
11//! foreground shells keep working even if rtcom crashes mid-frame.
12//!
13//! T17 wired the live-apply / line-toggle / send-break actions through
14//! the bus into the session. T18 extends this with the save-flavored
15//! (`ApplyAndSave`, `WriteProfile`, `ReadProfile`, ...) actions — these
16//! mutate `Profile` in memory, persist it to TOML via
17//! [`rtcom_config::write`], and publish
18//! [`Event::ProfileSaved`] / [`Event::ProfileLoadFailed`] so T19's toast
19//! layer can surface the outcome to the user.
20
21use std::io;
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24use std::time::Duration;
25
26use anyhow::{Context, Result};
27use crossterm::event::{Event as CtEvent, EventStream, KeyEvent, KeyEventKind};
28use futures::StreamExt;
29use ratatui::{backend::CrosstermBackend, Terminal};
30use rtcom_config::Profile;
31use rtcom_core::{
32    command::Command, Event, EventBus, ModemLineSnapshot, Parity, SerialConfig, StopBits,
33};
34use tokio::sync::broadcast;
35use tokio_util::sync::CancellationToken;
36
37use crate::{
38    app::TuiApp,
39    input::Dispatch,
40    modal::DialogAction,
41    profile_bridge::{
42        line_ending_config_to_section, line_endings_from_profile, serial_config_to_section,
43        serial_section_to_config,
44    },
45    terminal::{AltScreenGuard, MouseCaptureGuard, RawModeGuard},
46    toast::ToastLevel,
47};
48
49/// Drive the TUI main loop until cancelled or the user requests quit.
50///
51/// Owns the ratatui terminal + crossterm event stream; restores cooked
52/// mode + leaves the alternate screen on return (including on `Err`).
53///
54/// # Parameters
55///
56/// - `app`          — the prepared [`TuiApp`]; caller seeds device
57///   summary, initial `SerialConfig`, line endings, modem lines, and
58///   modal style before handing in.
59/// - `bus`          — the shared [`EventBus`], used to publish
60///   [`Event::TxBytes`] for keystrokes and
61///   [`Event::ProfileSaved`] / [`Event::ProfileLoadFailed`] for
62///   profile-IO outcomes.
63/// - `bus_rx`       — a pre-subscribed bus receiver; subscribe before
64///   spawning the session task so no events are missed.
65/// - `cancel`       — the shared [`CancellationToken`]. Tripping it
66///   (via signal, session exit, or [`Dispatch::Quit`]) unwinds the
67///   loop.
68/// - `profile_path` — where to read/write the profile TOML when the
69///   user triggers a save-flavored dialog action. `None` when no path
70///   is discoverable (tests, `$HOME`-less CI); save actions become
71///   no-ops + `tracing::warn` in that case.
72/// - `profile`      — the currently-loaded profile, owned by the run
73///   loop. Mutated in place when `ApplyAndSave` / `ApplyModalStyleAndSave`
74///   / `ReadProfile` rewrite sections; persisted on disk through
75///   [`rtcom_config::write`].
76///
77/// # Errors
78///
79/// Propagates terminal setup, IO, and render errors. Bus `Lagged`
80/// warnings are logged and the loop continues; a bus `Closed` error
81/// is treated as an implicit cancel and the function returns `Ok(())`.
82pub async fn run(
83    mut app: TuiApp,
84    bus: EventBus,
85    mut bus_rx: broadcast::Receiver<Event>,
86    cancel: CancellationToken,
87    profile_path: Option<PathBuf>,
88    mut profile: Profile,
89) -> Result<()> {
90    // RAII: enter raw mode + alt screen + mouse capture. All three
91    // restore on drop, even if the terminal setup below or the loop
92    // body returns Err — the guards sit on the stack and unwind
93    // through every error path. Drop order is `_mouse` → `_alt` →
94    // `_raw`, the reverse of construction, so mouse capture is
95    // released before we leave the alternate screen (matching the
96    // crossterm recipe for setup/teardown symmetry).
97    let _raw = RawModeGuard::enter()?;
98    let _alt = AltScreenGuard::enter()?;
99    let _mouse = MouseCaptureGuard::enable()?;
100
101    let backend = CrosstermBackend::new(io::stdout());
102    let mut terminal = Terminal::new(backend).context("build ratatui terminal")?;
103
104    let mut keys = EventStream::new();
105
106    // Periodic 100ms tick so toast expiration happens even when the
107    // user is idle and no bus events arrive. `tick()` is also called
108    // inside `TuiApp::render`, so the interval's job here is purely
109    // to trigger a redraw; we ignore the returned `Instant`.
110    let mut toast_tick = tokio::time::interval(Duration::from_millis(100));
111    // Consume the immediate first tick so we don't redraw twice on
112    // entry (we already drew the seed frame below).
113    toast_tick.tick().await;
114
115    // Seed frame so the user sees the chrome before any input.
116    terminal
117        .draw(|f| app.render(f))
118        .context("initial terminal draw")?;
119
120    loop {
121        tokio::select! {
122            biased;
123            () = cancel.cancelled() => break,
124
125            ev = keys.next() => {
126                match ev {
127                    Some(Ok(CtEvent::Key(key))) => {
128                        // crossterm 0.28 surfaces both Press and Release
129                        // events on terminals that advertise kitty
130                        // keyboard protocol. Filter to Press-only so a
131                        // single physical keystroke does not produce
132                        // two bytes on the wire.
133                        if key.kind == KeyEventKind::Press
134                            && handle_key_event(
135                                key,
136                                &mut app,
137                                &bus,
138                                &cancel,
139                                profile_path.as_deref(),
140                                &mut profile,
141                            )
142                        {
143                            break;
144                        }
145                    }
146                    Some(Ok(CtEvent::Resize(cols, rows))) => {
147                        // SerialPane::resize takes (rows, cols).
148                        app.serial_pane_mut().resize(rows, cols);
149                    }
150                    Some(Ok(CtEvent::Mouse(m_ev))) => {
151                        // Wheel events scroll the serial pane; click /
152                        // drag / move are ignored in v0.2 (native
153                        // selection lands in v0.2.1).
154                        let _ = app.handle_mouse(m_ev);
155                    }
156                    Some(Ok(_)) => {
157                        // FocusGained / FocusLost / Paste: ignore.
158                    }
159                    Some(Err(err)) => {
160                        tracing::error!(%err, "terminal event stream error");
161                        break;
162                    }
163                    None => {
164                        // Stream closed — treat as an implicit cancel.
165                        break;
166                    }
167                }
168            }
169
170            bus_ev = bus_rx.recv() => {
171                if !handle_bus_event(bus_ev, &mut app) {
172                    break;
173                }
174            }
175
176            _ = toast_tick.tick() => {
177                // Nothing to do here: the expiration call happens
178                // inside TuiApp::render just below. This arm exists
179                // so the loop wakes up to redraw.
180            }
181        }
182
183        terminal.draw(|f| app.render(f)).context("terminal draw")?;
184    }
185
186    // Guards drop in reverse construction order: _alt (leave alt
187    // screen) then _raw (cooked mode). Terminal's own Drop flushes
188    // the final frame to stdout before that.
189    Ok(())
190}
191
192/// Process a single key event. Returns `true` when the caller should
193/// break out of the event loop (user requested quit).
194fn handle_key_event(
195    key: KeyEvent,
196    app: &mut TuiApp,
197    bus: &EventBus,
198    cancel: &CancellationToken,
199    profile_path: Option<&Path>,
200    profile: &mut Profile,
201) -> bool {
202    match app.handle_key(key) {
203        Dispatch::TxBytes(bytes) => {
204            bus.publish(Event::TxBytes(bytes::Bytes::from(bytes)));
205            false
206        }
207        Dispatch::OpenedMenu | Dispatch::ClosedMenu | Dispatch::Noop => false,
208        Dispatch::Quit => {
209            cancel.cancel();
210            true
211        }
212        Dispatch::Action(action) => {
213            apply_dialog_action(&action, app, bus, profile_path, profile);
214            false
215        }
216    }
217}
218
219/// Route a [`DialogAction`] to the right destination:
220///
221/// - For actions that map 1:1 onto a [`Command`] (live-apply, line
222///   toggles, break), publish `Event::Command(cmd)` on the bus and let
223///   the session dispatch it.
224/// - For `ApplyModalStyleLive`, update the TUI's local cached style so
225///   subsequent renders pick it up.
226/// - For save-flavored actions (`ApplyAndSave`, `WriteProfile`,
227///   `ReadProfile`, `ApplyModalStyleAndSave`), mutate the in-memory
228///   [`Profile`], persist via [`rtcom_config::write`], and publish
229///   [`Event::ProfileSaved`] / [`Event::ProfileLoadFailed`] so T19's
230///   toast layer can surface the outcome.
231/// - For line-ending changes (`ApplyLineEndingsLive` /
232///   `ApplyLineEndingsAndSave`), log a warn pointing at v0.2.1 which
233///   introduces the `Arc<Mutex<Mapper>>` refactor needed to swap the
234///   mapper at runtime.
235fn apply_dialog_action(
236    action: &DialogAction,
237    app: &mut TuiApp,
238    bus: &EventBus,
239    profile_path: Option<&Path>,
240    profile: &mut Profile,
241) {
242    // Local-only action: no Command translation, just update the cache.
243    if let DialogAction::ApplyModalStyleLive(style) = action {
244        app.set_modal_style(*style);
245        // T19 / T20 polish makes the renderer honour the style.
246        return;
247    }
248
249    if let Some(cmd) = action_to_command(action) {
250        bus.publish(Event::Command(cmd));
251        return;
252    }
253
254    match action {
255        DialogAction::ApplyLineEndingsLive(_) => {
256            // v0.2.1 ships the Arc<Mutex<Mapper>> refactor that lets
257            // the session swap mappers at runtime. Until then, live
258            // line-ending changes require a restart.
259            tracing::warn!("live line-ending change not yet supported; restart rtcom to apply");
260        }
261        DialogAction::ApplyLineEndingsAndSave(le) => {
262            // The *live* runtime swap still requires the v0.2.1 mapper
263            // refactor, but persisting to the profile is cheap — do it
264            // now so the next rtcom launch picks up the new vocabulary
265            // via the existing profile-load path.
266            tracing::info!(
267                "line endings saved to profile; restart rtcom to apply to the live session"
268            );
269            profile.line_endings = line_ending_config_to_section(le);
270            app.set_line_endings(*le);
271            persist_profile(profile, profile_path, bus);
272        }
273        DialogAction::ApplyAndSave(cfg) => {
274            // Two-step: apply live, then persist the new serial section.
275            bus.publish(Event::Command(Command::ApplyConfig(*cfg)));
276            profile.serial = serial_config_to_section(cfg);
277            persist_profile(profile, profile_path, bus);
278        }
279        DialogAction::ApplyModalStyleAndSave(style) => {
280            // Update the TUI cache *and* the profile, then persist.
281            app.set_modal_style(*style);
282            profile.screen.modal_style = *style;
283            persist_profile(profile, profile_path, bus);
284        }
285        DialogAction::WriteProfile => {
286            persist_profile(profile, profile_path, bus);
287        }
288        DialogAction::ReadProfile => {
289            reload_profile(profile, app, profile_path, bus);
290        }
291        // All other variants are handled by the `action_to_command`
292        // branch above or by the ApplyModalStyleLive early return; this
293        // arm is defensive against future DialogAction additions.
294        _ => {
295            tracing::warn!(?action, "unhandled DialogAction");
296        }
297    }
298}
299
300/// Serialize `profile` to TOML and write it to `path`, publishing the
301/// outcome on the bus.
302///
303/// On success: [`Event::ProfileSaved`] with the destination path.
304/// On failure: [`Event::ProfileLoadFailed`] wrapping the IO / serialize
305/// error as [`rtcom_core::Error::InvalidConfig`] (the core error enum
306/// does not have a dedicated config-IO variant; `InvalidConfig` is the
307/// closest fit and the message carries the full cause).
308///
309/// When `path` is `None` (no discoverable profile location), the call
310/// is a no-op + tracing warn — save-flavored actions reaching this code
311/// path typically have a `Some`, but `None` can happen in tests and
312/// on `$HOME`-less CI.
313fn persist_profile(profile: &Profile, path: Option<&Path>, bus: &EventBus) {
314    let Some(path) = path else {
315        tracing::warn!("profile save requested but no profile path is available");
316        return;
317    };
318    match rtcom_config::write(path, profile) {
319        Ok(()) => {
320            bus.publish(Event::ProfileSaved {
321                path: path.to_path_buf(),
322            });
323        }
324        Err(e) => {
325            let err = rtcom_core::Error::InvalidConfig(format!("profile write: {e}"));
326            bus.publish(Event::ProfileLoadFailed {
327                path: path.to_path_buf(),
328                error: Arc::new(err),
329            });
330        }
331    }
332}
333
334/// Reload the profile from disk, replace the in-memory copy, apply the
335/// `[serial]` section live via [`Command::ApplyConfig`], and update the
336/// TUI snapshots for line endings + modal style.
337///
338/// On success: emits the same [`Event::ProfileSaved`] variant as a
339/// write — "saved" here reads as "profile-level action completed, toast
340/// it", and not having a dedicated `ProfileLoaded` variant is not worth
341/// one for v0.2. T19 can differentiate if users find it confusing.
342///
343/// Intentionally does **not** touch `ModemLineSnapshot`: the profile's
344/// `[modem]` section captures *startup policy* (`initial_dtr =
345/// unchanged|raise|lower`), not live state. Live modem lines are
346/// authoritative from the device and flow through
347/// [`Event::ModemLinesChanged`].
348fn reload_profile(profile: &mut Profile, app: &mut TuiApp, path: Option<&Path>, bus: &EventBus) {
349    let Some(path) = path else {
350        tracing::warn!("profile reload requested but no profile path is available");
351        return;
352    };
353    match rtcom_config::read(path) {
354        Ok(new_profile) => {
355            let serial_cfg = serial_section_to_config(&new_profile.serial);
356            bus.publish(Event::Command(Command::ApplyConfig(serial_cfg)));
357
358            app.set_line_endings(line_endings_from_profile(&new_profile));
359            app.set_modal_style(new_profile.screen.modal_style);
360
361            *profile = new_profile;
362
363            // Reuse ProfileSaved as "successful profile action" until
364            // T19 decides whether loads deserve their own toast label.
365            bus.publish(Event::ProfileSaved {
366                path: path.to_path_buf(),
367            });
368        }
369        Err(e) => {
370            let err = rtcom_core::Error::InvalidConfig(format!("profile read: {e}"));
371            bus.publish(Event::ProfileLoadFailed {
372                path: path.to_path_buf(),
373                error: Arc::new(err),
374            });
375        }
376    }
377}
378
379/// Translate a [`DialogAction`] into a [`Command`] when the action
380/// corresponds to a bus-dispatched command. Returns `None` for actions
381/// that the TUI handles locally (`ApplyModalStyleLive`), that require
382/// profile IO (`ApplyAndSave`, `WriteProfile`, `ReadProfile`,
383/// `ApplyModalStyleAndSave`), or that need the runtime-mapper refactor
384/// shipping in v0.2.1 (`ApplyLineEndings*`).
385///
386/// Split out as a free function so it can be unit-tested without
387/// constructing a full event bus.
388#[must_use]
389const fn action_to_command(action: &DialogAction) -> Option<Command> {
390    match action {
391        DialogAction::ApplyLive(cfg) => Some(Command::ApplyConfig(*cfg)),
392        DialogAction::SetDtr(state) => Some(Command::SetDtrAbs(*state)),
393        DialogAction::SetRts(state) => Some(Command::SetRtsAbs(*state)),
394        DialogAction::SendBreak => Some(Command::SendBreak),
395        // Handled locally by `apply_dialog_action` — not a Command.
396        DialogAction::ApplyModalStyleLive(_)
397        // Save-flavored: drive Profile IO (see `apply_dialog_action`).
398        // `ApplyAndSave` *also* publishes Command::ApplyConfig, but it
399        // does so directly from `apply_dialog_action` after running
400        // the profile-write step, so it stays `None` here to avoid a
401        // double dispatch.
402        | DialogAction::ApplyAndSave(_)
403        | DialogAction::ApplyModalStyleAndSave(_)
404        | DialogAction::WriteProfile
405        | DialogAction::ReadProfile
406        // Deferred to v0.2.1 (needs runtime-mapper refactor).
407        | DialogAction::ApplyLineEndingsLive(_)
408        | DialogAction::ApplyLineEndingsAndSave(_) => None,
409    }
410}
411
412/// Process a single bus event. Returns `false` when the caller should
413/// break out of the event loop (bus closed).
414///
415/// T19 adds toast arms for [`Event::ProfileSaved`] /
416/// [`Event::ProfileLoadFailed`] / [`Event::Error`]; these continue to
417/// log via `tracing` as well so external log subscribers (e.g., the
418/// v0.2.x log-file writer) see the same stream.
419fn handle_bus_event(
420    bus_ev: std::result::Result<Event, broadcast::error::RecvError>,
421    app: &mut TuiApp,
422) -> bool {
423    match bus_ev {
424        Ok(Event::RxBytes(b)) => {
425            app.serial_pane_mut().ingest(&b);
426        }
427        Ok(Event::ConfigChanged(cfg)) => {
428            app.set_serial_config(cfg);
429            app.set_config_summary(summarise(&cfg));
430        }
431        Ok(Event::SystemMessage(msg)) => {
432            app.serial_pane_mut()
433                .ingest(format!("\r\n*** rtcom: {msg}\r\n").as_bytes());
434        }
435        Ok(Event::DeviceDisconnected { reason }) => {
436            app.serial_pane_mut()
437                .ingest(format!("\r\n*** device disconnected: {reason}\r\n").as_bytes());
438        }
439        Ok(Event::Error(e)) => {
440            tracing::error!(%e, "bus error");
441            app.push_toast(format!("error: {e}"), ToastLevel::Error);
442        }
443        Ok(Event::ModemLinesChanged { dtr, rts }) => {
444            app.set_modem_lines(ModemLineSnapshot { dtr, rts });
445        }
446        Ok(Event::ProfileSaved { path }) => {
447            app.push_toast(
448                format!("profile saved: {}", path.display()),
449                ToastLevel::Info,
450            );
451        }
452        Ok(Event::ProfileLoadFailed { path, error }) => {
453            tracing::error!(%error, path = %path.display(), "profile IO failed");
454            app.push_toast(
455                format!("profile IO failed ({}): {error}", path.display()),
456                ToastLevel::Error,
457            );
458        }
459        // `rtcom_core::Event` is `#[non_exhaustive]` so the wildcard
460        // tail here both covers known-but-ignored variants
461        // (MenuOpened/Closed, Command, TxBytes, DeviceConnected) and
462        // any future additions — the TUI doesn't need to change to
463        // stay forward-compatible.
464        Ok(_) => {}
465        Err(broadcast::error::RecvError::Closed) => {
466            // Bus closed; no more events to process.
467            return false;
468        }
469        Err(broadcast::error::RecvError::Lagged(n)) => {
470            tracing::warn!("bus lagged by {n} events");
471        }
472    }
473    true
474}
475
476/// Build the short `"<baud> <DATA><PARITY><STOP> <flow>"` status-bar
477/// string used on the top row (e.g. `"115200 8N1 none"`).
478#[must_use]
479pub fn summarise(cfg: &SerialConfig) -> String {
480    format!(
481        "{} {}{}{} {}",
482        cfg.baud_rate,
483        cfg.data_bits.bits(),
484        parity_letter(cfg.parity),
485        stop_bits_number(cfg.stop_bits),
486        flow_word(cfg.flow_control),
487    )
488}
489
490const fn parity_letter(p: Parity) -> char {
491    match p {
492        Parity::None => 'N',
493        Parity::Even => 'E',
494        Parity::Odd => 'O',
495        Parity::Mark => 'M',
496        Parity::Space => 'S',
497    }
498}
499
500const fn stop_bits_number(s: StopBits) -> u8 {
501    match s {
502        StopBits::One => 1,
503        StopBits::Two => 2,
504    }
505}
506
507const fn flow_word(f: rtcom_core::FlowControl) -> &'static str {
508    match f {
509        rtcom_core::FlowControl::None => "none",
510        rtcom_core::FlowControl::Hardware => "hw",
511        rtcom_core::FlowControl::Software => "sw",
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use rtcom_core::{DataBits, FlowControl, SerialConfig, StopBits};
519
520    #[test]
521    fn summarise_default_is_115200_8n1_none() {
522        let cfg = SerialConfig::default();
523        assert_eq!(summarise(&cfg), "115200 8N1 none");
524    }
525
526    #[test]
527    fn summarise_custom_config() {
528        let cfg = SerialConfig {
529            baud_rate: 9600,
530            data_bits: DataBits::Seven,
531            stop_bits: StopBits::Two,
532            parity: Parity::Even,
533            flow_control: FlowControl::Hardware,
534            ..SerialConfig::default()
535        };
536        assert_eq!(summarise(&cfg), "9600 7E2 hw");
537    }
538
539    #[tokio::test]
540    async fn handle_bus_event_rx_bytes_reaches_pane() {
541        let bus = EventBus::new(8);
542        let mut app = TuiApp::new(bus);
543        assert!(handle_bus_event(
544            Ok(Event::RxBytes(bytes::Bytes::from_static(b"hi"))),
545            &mut app
546        ));
547    }
548
549    #[tokio::test]
550    async fn handle_bus_event_closed_breaks_loop() {
551        let bus = EventBus::new(8);
552        let mut app = TuiApp::new(bus);
553        assert!(!handle_bus_event(
554            Err(broadcast::error::RecvError::Closed),
555            &mut app
556        ));
557    }
558
559    #[tokio::test]
560    async fn handle_bus_event_lagged_is_logged_but_continues() {
561        let bus = EventBus::new(8);
562        let mut app = TuiApp::new(bus);
563        assert!(handle_bus_event(
564            Err(broadcast::error::RecvError::Lagged(7)),
565            &mut app
566        ));
567    }
568
569    #[tokio::test]
570    async fn handle_bus_event_config_changed_updates_summary() {
571        let bus = EventBus::new(8);
572        let mut app = TuiApp::new(bus);
573        app.set_device_summary("/dev/ttyUSB0", "old");
574        let cfg = SerialConfig {
575            baud_rate: 9600,
576            ..SerialConfig::default()
577        };
578        assert!(handle_bus_event(Ok(Event::ConfigChanged(cfg)), &mut app));
579        // Not a public getter on TuiApp, but the app renders the
580        // string; covered here indirectly via set_config_summary.
581    }
582
583    #[tokio::test]
584    async fn handle_bus_event_modem_lines_changed_reaches_app() {
585        let bus = EventBus::new(8);
586        let mut app = TuiApp::new(bus);
587        assert!(handle_bus_event(
588            Ok(Event::ModemLinesChanged {
589                dtr: false,
590                rts: true
591            }),
592            &mut app
593        ));
594        // No public getter for the snapshot either; the call succeeds
595        // and returns `true` (loop continues) — the set_modem_lines
596        // call is covered by `TuiApp`'s own render tests in v0.2+.
597    }
598
599    #[test]
600    fn apply_live_maps_to_apply_config_command() {
601        let cfg = SerialConfig::default();
602        let cmd = action_to_command(&DialogAction::ApplyLive(cfg)).expect("maps to ApplyConfig");
603        match cmd {
604            Command::ApplyConfig(out) => assert_eq!(out, cfg),
605            other => panic!("expected ApplyConfig, got {other:?}"),
606        }
607    }
608
609    #[test]
610    fn set_dtr_maps_to_set_dtr_abs_command() {
611        assert!(matches!(
612            action_to_command(&DialogAction::SetDtr(true)),
613            Some(Command::SetDtrAbs(true))
614        ));
615        assert!(matches!(
616            action_to_command(&DialogAction::SetDtr(false)),
617            Some(Command::SetDtrAbs(false))
618        ));
619    }
620
621    #[test]
622    fn set_rts_maps_to_set_rts_abs_command() {
623        assert!(matches!(
624            action_to_command(&DialogAction::SetRts(true)),
625            Some(Command::SetRtsAbs(true))
626        ));
627        assert!(matches!(
628            action_to_command(&DialogAction::SetRts(false)),
629            Some(Command::SetRtsAbs(false))
630        ));
631    }
632
633    #[test]
634    fn send_break_maps_to_send_break_command() {
635        assert!(matches!(
636            action_to_command(&DialogAction::SendBreak),
637            Some(Command::SendBreak)
638        ));
639    }
640
641    #[test]
642    fn apply_modal_style_live_returns_none_because_handled_locally() {
643        assert!(action_to_command(&DialogAction::ApplyModalStyleLive(
644            rtcom_config::ModalStyle::DimmedOverlay,
645        ))
646        .is_none());
647    }
648
649    #[test]
650    fn apply_line_endings_live_returns_none_pending_v021() {
651        let le = rtcom_core::LineEndingConfig::default();
652        assert!(action_to_command(&DialogAction::ApplyLineEndingsLive(le)).is_none());
653    }
654
655    #[test]
656    fn save_flavored_actions_return_none_pending_t18() {
657        let cfg = SerialConfig::default();
658        assert!(action_to_command(&DialogAction::ApplyAndSave(cfg)).is_none());
659        assert!(action_to_command(&DialogAction::WriteProfile).is_none());
660        assert!(action_to_command(&DialogAction::ReadProfile).is_none());
661        assert!(action_to_command(&DialogAction::ApplyModalStyleAndSave(
662            rtcom_config::ModalStyle::DimmedOverlay,
663        ))
664        .is_none());
665    }
666
667    #[tokio::test]
668    async fn apply_dialog_action_apply_live_publishes_apply_config_command() {
669        let bus = EventBus::new(8);
670        let mut rx = bus.subscribe();
671        let mut app = TuiApp::new(bus.clone());
672        let mut profile = Profile::default();
673
674        let cfg = SerialConfig {
675            baud_rate: 9600,
676            ..SerialConfig::default()
677        };
678        apply_dialog_action(
679            &DialogAction::ApplyLive(cfg),
680            &mut app,
681            &bus,
682            None,
683            &mut profile,
684        );
685
686        match rx.try_recv().expect("Command on the bus") {
687            Event::Command(Command::ApplyConfig(out)) => assert_eq!(out, cfg),
688            other => panic!("expected Event::Command(ApplyConfig), got {other:?}"),
689        }
690    }
691
692    #[tokio::test]
693    async fn apply_dialog_action_modal_style_live_does_not_publish_and_updates_cache() {
694        let bus = EventBus::new(8);
695        let mut rx = bus.subscribe();
696        let mut app = TuiApp::new(bus.clone());
697        let mut profile = Profile::default();
698
699        apply_dialog_action(
700            &DialogAction::ApplyModalStyleLive(rtcom_config::ModalStyle::DimmedOverlay),
701            &mut app,
702            &bus,
703            None,
704            &mut profile,
705        );
706
707        // Nothing should be on the bus: the action is a local-only cache update.
708        match rx.try_recv() {
709            Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
710            other => panic!("expected Empty, got {other:?}"),
711        }
712    }
713
714    #[tokio::test]
715    async fn persist_profile_writes_to_disk_and_publishes_profile_saved() {
716        let dir = tempfile::tempdir().unwrap();
717        let path = dir.path().join("test.toml");
718        let profile = Profile::default();
719        let bus = EventBus::new(64);
720        let mut rx = bus.subscribe();
721
722        persist_profile(&profile, Some(&path), &bus);
723
724        assert!(path.exists(), "profile file should be written");
725        match rx.try_recv().expect("ProfileSaved on the bus") {
726            Event::ProfileSaved { path: p } => assert_eq!(p, path),
727            other => panic!("expected ProfileSaved, got {other:?}"),
728        }
729    }
730
731    #[tokio::test]
732    async fn persist_profile_without_path_is_noop() {
733        let profile = Profile::default();
734        let bus = EventBus::new(64);
735        let mut rx = bus.subscribe();
736
737        persist_profile(&profile, None, &bus);
738
739        // No event published.
740        assert!(matches!(
741            rx.try_recv(),
742            Err(broadcast::error::TryRecvError::Empty)
743        ));
744    }
745
746    #[tokio::test]
747    async fn persist_profile_io_error_publishes_profile_load_failed() {
748        // Stage: write a regular file, then try to persist "under" it
749        // (treating it as a directory). rtcom_config::write tries
750        // create_dir_all on the parent, which fails with NotADirectory
751        // because the "parent" is itself a file.
752        let dir = tempfile::tempdir().unwrap();
753        let blocker = dir.path().join("blocker");
754        std::fs::write(&blocker, b"").unwrap();
755        let path = blocker.join("nested.toml");
756
757        let profile = Profile::default();
758        let bus = EventBus::new(64);
759        let mut rx = bus.subscribe();
760
761        persist_profile(&profile, Some(&path), &bus);
762
763        match rx.try_recv().expect("ProfileLoadFailed on the bus") {
764            Event::ProfileLoadFailed { path: p, error } => {
765                assert_eq!(p, path);
766                assert!(error.to_string().contains("profile write"));
767            }
768            other => panic!("expected ProfileLoadFailed, got {other:?}"),
769        }
770    }
771
772    #[tokio::test]
773    async fn reload_profile_reads_and_dispatches_apply_config() {
774        let dir = tempfile::tempdir().unwrap();
775        let path = dir.path().join("test.toml");
776
777        // Write a non-default profile to disk.
778        let mut disk_profile = Profile::default();
779        disk_profile.serial.baud = 9600;
780        rtcom_config::write(&path, &disk_profile).unwrap();
781
782        // Memory copy is still the default (115200).
783        let mut memory_profile = Profile::default();
784        let bus = EventBus::new(64);
785        let mut rx = bus.subscribe();
786        let mut app = TuiApp::new(bus.clone());
787
788        reload_profile(&mut memory_profile, &mut app, Some(&path), &bus);
789
790        assert_eq!(memory_profile.serial.baud, 9600);
791
792        // First: ApplyConfig command with the newly-read baud.
793        match rx.try_recv().expect("ApplyConfig on the bus") {
794            Event::Command(Command::ApplyConfig(cfg)) => assert_eq!(cfg.baud_rate, 9600),
795            other => panic!("expected Command::ApplyConfig, got {other:?}"),
796        }
797        // Second: ProfileSaved as the user-visible confirmation.
798        match rx.try_recv().expect("ProfileSaved on the bus") {
799            Event::ProfileSaved { path: p } => assert_eq!(p, path),
800            other => panic!("expected ProfileSaved, got {other:?}"),
801        }
802    }
803
804    #[tokio::test]
805    async fn reload_profile_malformed_toml_publishes_profile_load_failed() {
806        let dir = tempfile::tempdir().unwrap();
807        let path = dir.path().join("bad.toml");
808        std::fs::write(&path, b"not valid =~~ toml [\n").unwrap();
809
810        let mut memory_profile = Profile::default();
811        let bus = EventBus::new(64);
812        let mut rx = bus.subscribe();
813        let mut app = TuiApp::new(bus.clone());
814
815        reload_profile(&mut memory_profile, &mut app, Some(&path), &bus);
816
817        match rx.try_recv().expect("ProfileLoadFailed on the bus") {
818            Event::ProfileLoadFailed { path: p, error } => {
819                assert_eq!(p, path);
820                assert!(error.to_string().contains("profile read"));
821            }
822            other => panic!("expected ProfileLoadFailed, got {other:?}"),
823        }
824    }
825
826    #[tokio::test]
827    async fn apply_dialog_action_apply_and_save_updates_profile_and_writes() {
828        let dir = tempfile::tempdir().unwrap();
829        let path = dir.path().join("save.toml");
830        let bus = EventBus::new(64);
831        let mut rx = bus.subscribe();
832        let mut app = TuiApp::new(bus.clone());
833        let mut profile = Profile::default();
834
835        let cfg = SerialConfig {
836            baud_rate: 57_600,
837            ..SerialConfig::default()
838        };
839        apply_dialog_action(
840            &DialogAction::ApplyAndSave(cfg),
841            &mut app,
842            &bus,
843            Some(&path),
844            &mut profile,
845        );
846
847        // In-memory profile updated.
848        assert_eq!(profile.serial.baud, 57_600);
849        // File written.
850        assert!(path.exists());
851
852        // First: live ApplyConfig dispatched.
853        match rx.try_recv().expect("ApplyConfig on the bus") {
854            Event::Command(Command::ApplyConfig(out)) => assert_eq!(out, cfg),
855            other => panic!("expected Command::ApplyConfig, got {other:?}"),
856        }
857        // Second: ProfileSaved confirmation.
858        match rx.try_recv().expect("ProfileSaved on the bus") {
859            Event::ProfileSaved { path: p } => assert_eq!(p, path),
860            other => panic!("expected ProfileSaved, got {other:?}"),
861        }
862    }
863
864    #[tokio::test]
865    async fn handle_bus_event_profile_saved_pushes_info_toast() {
866        let bus = EventBus::new(8);
867        let mut app = TuiApp::new(bus);
868        assert_eq!(app.toasts_mut().visible_count(), 0);
869        assert!(handle_bus_event(
870            Ok(Event::ProfileSaved {
871                path: std::path::PathBuf::from("/tmp/x.toml"),
872            }),
873            &mut app,
874        ));
875        assert_eq!(app.toasts_mut().visible_count(), 1);
876        assert_eq!(
877            app.toasts_mut().visible()[0].level,
878            crate::toast::ToastLevel::Info
879        );
880        assert!(app.toasts_mut().visible()[0]
881            .message
882            .contains("/tmp/x.toml"));
883    }
884
885    #[tokio::test]
886    async fn handle_bus_event_profile_load_failed_pushes_error_toast() {
887        let bus = EventBus::new(8);
888        let mut app = TuiApp::new(bus);
889        let err = rtcom_core::Error::InvalidConfig("bad toml".to_string());
890        assert!(handle_bus_event(
891            Ok(Event::ProfileLoadFailed {
892                path: std::path::PathBuf::from("/tmp/bad.toml"),
893                error: Arc::new(err),
894            }),
895            &mut app,
896        ));
897        assert_eq!(app.toasts_mut().visible_count(), 1);
898        assert_eq!(
899            app.toasts_mut().visible()[0].level,
900            crate::toast::ToastLevel::Error
901        );
902        assert!(app.toasts_mut().visible()[0]
903            .message
904            .contains("/tmp/bad.toml"));
905    }
906
907    #[tokio::test]
908    async fn handle_bus_event_error_pushes_error_toast() {
909        let bus = EventBus::new(8);
910        let mut app = TuiApp::new(bus);
911        let err = rtcom_core::Error::InvalidConfig("boom".to_string());
912        assert!(handle_bus_event(Ok(Event::Error(Arc::new(err))), &mut app));
913        assert_eq!(app.toasts_mut().visible_count(), 1);
914        assert_eq!(
915            app.toasts_mut().visible()[0].level,
916            crate::toast::ToastLevel::Error
917        );
918        assert!(app.toasts_mut().visible()[0].message.contains("boom"));
919    }
920
921    #[tokio::test]
922    async fn apply_line_endings_and_save_persists_profile() {
923        use rtcom_core::{LineEnding, LineEndingConfig};
924        let dir = tempfile::tempdir().unwrap();
925        let path = dir.path().join("le.toml");
926        let bus = EventBus::new(64);
927        let mut rx = bus.subscribe();
928        let mut app = TuiApp::new(bus.clone());
929        let mut profile = Profile::default();
930
931        let le = LineEndingConfig {
932            omap: LineEnding::AddCrToLf,
933            imap: LineEnding::None,
934            emap: LineEnding::None,
935        };
936        apply_dialog_action(
937            &DialogAction::ApplyLineEndingsAndSave(le),
938            &mut app,
939            &bus,
940            Some(&path),
941            &mut profile,
942        );
943
944        // In-memory profile updated with the round-trip vocabulary.
945        assert_eq!(profile.line_endings.omap, "crlf");
946        // File persisted to disk.
947        let on_disk = rtcom_config::read(&path).unwrap();
948        assert_eq!(on_disk.line_endings.omap, "crlf");
949        // ProfileSaved event emitted so the toast layer can surface it.
950        let mut saw_saved = false;
951        while let Ok(ev) = rx.try_recv() {
952            if matches!(ev, Event::ProfileSaved { .. }) {
953                saw_saved = true;
954            }
955        }
956        assert!(saw_saved, "expected ProfileSaved event");
957    }
958
959    #[tokio::test]
960    async fn apply_line_endings_live_still_warns_and_does_not_persist() {
961        use rtcom_core::{LineEnding, LineEndingConfig};
962        let dir = tempfile::tempdir().unwrap();
963        let path = dir.path().join("le_live.toml");
964        let bus = EventBus::new(64);
965        let mut rx = bus.subscribe();
966        let mut app = TuiApp::new(bus.clone());
967        let mut profile = Profile::default();
968
969        let le = LineEndingConfig {
970            omap: LineEnding::AddCrToLf,
971            imap: LineEnding::None,
972            emap: LineEnding::None,
973        };
974        apply_dialog_action(
975            &DialogAction::ApplyLineEndingsLive(le),
976            &mut app,
977            &bus,
978            Some(&path),
979            &mut profile,
980        );
981
982        // Profile in memory untouched (default "none") and no file
983        // written to disk.
984        assert_eq!(profile.line_endings.omap, "none");
985        assert!(!path.exists(), "live-only path must not write profile");
986        // No bus events.
987        assert!(matches!(
988            rx.try_recv(),
989            Err(broadcast::error::TryRecvError::Empty)
990        ));
991    }
992
993    #[tokio::test]
994    async fn apply_dialog_action_apply_modal_style_and_save_persists_and_updates_app() {
995        let dir = tempfile::tempdir().unwrap();
996        let path = dir.path().join("style.toml");
997        let bus = EventBus::new(64);
998        let mut rx = bus.subscribe();
999        let mut app = TuiApp::new(bus.clone());
1000        let mut profile = Profile::default();
1001
1002        apply_dialog_action(
1003            &DialogAction::ApplyModalStyleAndSave(rtcom_config::ModalStyle::Fullscreen),
1004            &mut app,
1005            &bus,
1006            Some(&path),
1007            &mut profile,
1008        );
1009
1010        assert_eq!(
1011            profile.screen.modal_style,
1012            rtcom_config::ModalStyle::Fullscreen
1013        );
1014        assert!(path.exists());
1015
1016        match rx.try_recv().expect("ProfileSaved on the bus") {
1017            Event::ProfileSaved { path: p } => assert_eq!(p, path),
1018            other => panic!("expected ProfileSaved, got {other:?}"),
1019        }
1020    }
1021}