Skip to main content

bee_tui/
app.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use color_eyre::eyre::eyre;
6use crossterm::event::KeyEvent;
7use ratatui::prelude::Rect;
8use serde::{Deserialize, Serialize};
9use tokio::sync::{mpsc, watch};
10use tokio_util::sync::CancellationToken;
11use tracing::{debug, info};
12
13use crate::{
14    action::Action,
15    api::ApiClient,
16    components::{
17        Component, api_health::ApiHealth, command_log::CommandLog,
18        health::{Gate, GateStatus, Health},
19        lottery::Lottery, network::Network, peers::Peers, stamps::Stamps, swap::Swap,
20        tags::Tags, warmup::Warmup,
21    },
22    config::Config,
23    log_capture,
24    theme,
25    tui::{Event, Tui},
26    watch::{BeeWatch, HealthSnapshot},
27};
28
29pub struct App {
30    config: Config,
31    tick_rate: f64,
32    frame_rate: f64,
33    /// Top-level screens, in display order. Tab cycles among them.
34    /// v0.4 also wires the k9s-style `:command` switcher so users
35    /// can jump directly with `:peers`, `:stamps`, etc.
36    screens: Vec<Box<dyn Component>>,
37    /// Index into [`Self::screens`] for the currently visible screen.
38    current_screen: usize,
39    /// Always-on bottom strip; not part of `screens` because it
40    /// renders alongside whatever screen is active.
41    command_log: Box<dyn Component>,
42    should_quit: bool,
43    should_suspend: bool,
44    mode: Mode,
45    last_tick_key_events: Vec<KeyEvent>,
46    action_tx: mpsc::UnboundedSender<Action>,
47    action_rx: mpsc::UnboundedReceiver<Action>,
48    /// Root cancellation token. Children: BeeWatch hub → per-resource
49    /// pollers. Cancelling this on quit unwinds every spawned task.
50    root_cancel: CancellationToken,
51    /// Active Bee node connection; cheap to clone (`Arc<Inner>` under
52    /// the hood). Read by future header bar + multi-node switcher.
53    #[allow(dead_code)]
54    api: Arc<ApiClient>,
55    /// Watch / informer hub feeding screens.
56    watch: BeeWatch,
57    /// Top-bar reuses the health snapshot for the live ping
58    /// indicator. Cheap clone of the watch receiver.
59    health_rx: watch::Receiver<HealthSnapshot>,
60    /// `Some(buf)` while the user is typing a `:command`. The
61    /// buffer holds the characters typed *after* the leading colon.
62    command_buffer: Option<String>,
63    /// Status / error from the most recent `:command`, persisted on
64    /// the command-bar line until the user enters command mode again.
65    /// Cleared when `command_buffer` transitions to `Some`.
66    command_status: Option<CommandStatus>,
67}
68
69/// Outcome from the most recently executed `:command`. Drives the
70/// colour of the command-bar line in normal mode.
71#[derive(Debug, Clone)]
72pub enum CommandStatus {
73    Info(String),
74    Err(String),
75}
76
77/// Names the top-level screens. Index matches position in
78/// [`App::screens`].
79const SCREEN_NAMES: &[&str] = &[
80    "Health", "Stamps", "Swap", "Lottery", "Peers", "Network", "Warmup", "API", "Tags",
81];
82
83#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
84pub enum Mode {
85    #[default]
86    Home,
87}
88
89impl App {
90    pub fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result<Self> {
91        let (action_tx, action_rx) = mpsc::unbounded_channel();
92        let config = Config::new()?;
93        // Install the theme first so any tracing emitted during the
94        // rest of `new` already reflects the operator's choice.
95        theme::install(&config.ui);
96
97        // Pick the active node profile and build an ApiClient for it.
98        let node = config
99            .active_node()
100            .ok_or_else(|| eyre!("no Bee node configured (config.nodes is empty)"))?;
101        let api = Arc::new(ApiClient::from_node(node)?);
102
103        // Spawn the watch / informer hub. Pollers attach to children
104        // of `root_cancel`, so quitting cancels everything in one go.
105        let root_cancel = CancellationToken::new();
106        let watch = BeeWatch::start(api.clone(), &root_cancel);
107        let health_rx = watch.health();
108
109        let screens = build_screens(&api, &watch);
110        // S10 Command-log subscribes to the bee::http capture set up
111        // by logging::init. If logging hasn't initialised the capture
112        // (e.g. running in a test harness), the pane just shows
113        // "waiting for first request…".
114        let command_log: Box<dyn Component> = Box::new(CommandLog::new(log_capture::handle()));
115
116        Ok(Self {
117            tick_rate,
118            frame_rate,
119            screens,
120            current_screen: 0,
121            command_log,
122            should_quit: false,
123            should_suspend: false,
124            config,
125            mode: Mode::Home,
126            last_tick_key_events: Vec::new(),
127            action_tx,
128            action_rx,
129            root_cancel,
130            api,
131            watch,
132            health_rx,
133            command_buffer: None,
134            command_status: None,
135        })
136    }
137
138    pub async fn run(&mut self) -> color_eyre::Result<()> {
139        let mut tui = Tui::new()?
140            // .mouse(true) // uncomment this line to enable mouse support
141            .tick_rate(self.tick_rate)
142            .frame_rate(self.frame_rate);
143        tui.enter()?;
144
145        let tx = self.action_tx.clone();
146        let cfg = self.config.clone();
147        let size = tui.size()?;
148        for component in self.iter_components_mut() {
149            component.register_action_handler(tx.clone())?;
150            component.register_config_handler(cfg.clone())?;
151            component.init(size)?;
152        }
153
154        let action_tx = self.action_tx.clone();
155        loop {
156            self.handle_events(&mut tui).await?;
157            self.handle_actions(&mut tui)?;
158            if self.should_suspend {
159                tui.suspend()?;
160                action_tx.send(Action::Resume)?;
161                action_tx.send(Action::ClearScreen)?;
162                // tui.mouse(true);
163                tui.enter()?;
164            } else if self.should_quit {
165                tui.stop()?;
166                break;
167            }
168        }
169        // Unwind every spawned task before tearing down the terminal.
170        self.watch.shutdown();
171        self.root_cancel.cancel();
172        tui.exit()?;
173        Ok(())
174    }
175
176    async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
177        let Some(event) = tui.next_event().await else {
178            return Ok(());
179        };
180        let action_tx = self.action_tx.clone();
181        let in_command_mode = self.command_buffer.is_some();
182        match event {
183            Event::Quit => action_tx.send(Action::Quit)?,
184            Event::Tick => action_tx.send(Action::Tick)?,
185            Event::Render => action_tx.send(Action::Render)?,
186            Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
187            Event::Key(key) => self.handle_key_event(key)?,
188            _ => {}
189        }
190        // While the command bar is open we swallow key events at the
191        // App level — components shouldn't react to typed letters.
192        // Non-key events (Tick / Resize / Render) still propagate so
193        // the screens keep refreshing under the prompt.
194        let propagate = !(in_command_mode && matches!(event, Event::Key(_)));
195        if propagate {
196            for component in self.iter_components_mut() {
197                if let Some(action) = component.handle_events(Some(event.clone()))? {
198                    action_tx.send(action)?;
199                }
200            }
201        }
202        Ok(())
203    }
204
205    /// Iterate every component (screens + command-log strip) for
206    /// uniform lifecycle ticks. Doesn't conflict with rendering,
207    /// which only draws the active screen.
208    fn iter_components_mut(&mut self) -> impl Iterator<Item = &mut Box<dyn Component>> {
209        self.screens
210            .iter_mut()
211            .chain(std::iter::once(&mut self.command_log))
212    }
213
214    fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
215        // While a `:command` is being typed every key edits the
216        // buffer or commits / cancels the line. No other keymap
217        // applies.
218        if self.command_buffer.is_some() {
219            self.handle_command_mode_key(key)?;
220            return Ok(());
221        }
222        let action_tx = self.action_tx.clone();
223        // ':' opens the command bar.
224        if matches!(
225            key.code,
226            crossterm::event::KeyCode::Char(':')
227        ) {
228            self.command_buffer = Some(String::new());
229            self.command_status = None;
230            return Ok(());
231        }
232        // Tab keeps working as a quick screen-cycle shortcut even
233        // after the `:command` bar lands.
234        if matches!(key.code, crossterm::event::KeyCode::Tab) {
235            if !self.screens.is_empty() {
236                self.current_screen = (self.current_screen + 1) % self.screens.len();
237                debug!(
238                    "switched to screen {}",
239                    SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
240                );
241            }
242            return Ok(());
243        }
244        let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
245            return Ok(());
246        };
247        match keymap.get(&vec![key]) {
248            Some(action) => {
249                info!("Got action: {action:?}");
250                action_tx.send(action.clone())?;
251            }
252            _ => {
253                // If the key was not handled as a single key action,
254                // then consider it for multi-key combinations.
255                self.last_tick_key_events.push(key);
256
257                // Check for multi-key combinations
258                if let Some(action) = keymap.get(&self.last_tick_key_events) {
259                    info!("Got action: {action:?}");
260                    action_tx.send(action.clone())?;
261                }
262            }
263        }
264        Ok(())
265    }
266
267    fn handle_command_mode_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
268        use crossterm::event::KeyCode;
269        let buf = match self.command_buffer.as_mut() {
270            Some(b) => b,
271            None => return Ok(()),
272        };
273        match key.code {
274            KeyCode::Esc => {
275                // Cancel without dispatching.
276                self.command_buffer = None;
277            }
278            KeyCode::Enter => {
279                let line = std::mem::take(buf);
280                self.command_buffer = None;
281                self.execute_command(&line)?;
282            }
283            KeyCode::Backspace => {
284                buf.pop();
285            }
286            KeyCode::Char(c) => {
287                buf.push(c);
288            }
289            _ => {}
290        }
291        Ok(())
292    }
293
294    /// Resolve a `:command` token to the action it represents.
295    /// Empty input is a silent no-op (operator typed `:` then Enter).
296    fn execute_command(&mut self, line: &str) -> color_eyre::Result<()> {
297        let trimmed = line.trim();
298        if trimmed.is_empty() {
299            return Ok(());
300        }
301        let head = trimmed.split_whitespace().next().unwrap_or("");
302        match head {
303            "q" | "quit" => {
304                self.action_tx.send(Action::Quit)?;
305                self.command_status = Some(CommandStatus::Info("quitting".into()));
306            }
307            "diagnose" | "diag" => {
308                self.command_status = Some(match self.export_diagnostic_bundle() {
309                    Ok(path) => CommandStatus::Info(format!(
310                        "diagnostic bundle exported to {}",
311                        path.display()
312                    )),
313                    Err(e) => CommandStatus::Err(format!("diagnose failed: {e}")),
314                });
315            }
316            "context" | "ctx" => {
317                let target = trimmed.split_whitespace().nth(1).unwrap_or("");
318                if target.is_empty() {
319                    let known: Vec<String> = self
320                        .config
321                        .nodes
322                        .iter()
323                        .map(|n| n.name.clone())
324                        .collect();
325                    self.command_status = Some(CommandStatus::Err(format!(
326                        "usage: :context <name>  (known: {})",
327                        known.join(", ")
328                    )));
329                    return Ok(());
330                }
331                self.command_status = Some(match self.switch_context(target) {
332                    Ok(()) => CommandStatus::Info(format!(
333                        "switched to context {target} ({})",
334                        self.api.url
335                    )),
336                    Err(e) => CommandStatus::Err(format!("context switch failed: {e}")),
337                });
338            }
339            screen if SCREEN_NAMES
340                .iter()
341                .any(|name| name.eq_ignore_ascii_case(screen)) =>
342            {
343                if let Some(idx) = SCREEN_NAMES
344                    .iter()
345                    .position(|name| name.eq_ignore_ascii_case(screen))
346                {
347                    self.current_screen = idx;
348                    self.command_status =
349                        Some(CommandStatus::Info(format!("→ {}", SCREEN_NAMES[idx])));
350                }
351            }
352            other => {
353                self.command_status = Some(CommandStatus::Err(format!(
354                    "unknown command: {other:?} (try :health, :stamps, :swap, :lottery, :peers, :network, :warmup, :api, :diagnose, :quit)"
355                )));
356            }
357        }
358        Ok(())
359    }
360
361    /// Tear down the current watch hub and ApiClient, build a new
362    /// connection against the named NodeConfig, and rebuild the
363    /// screen list against fresh receivers. Component-internal state
364    /// (Lottery's bench history, Network's reachability stability
365    /// timer, etc.) is intentionally lost — a profile switch is a
366    /// fresh slate, the same way it would be on app restart.
367    fn switch_context(&mut self, target: &str) -> color_eyre::Result<()> {
368        let node = self
369            .config
370            .nodes
371            .iter()
372            .find(|n| n.name == target)
373            .ok_or_else(|| eyre!("no node configured with name {target:?}"))?
374            .clone();
375        let new_api = Arc::new(ApiClient::from_node(&node)?);
376        // Cancel the current hub's children and let it drop. The new
377        // hub spawns under the same root_cancel so quit-time teardown
378        // still walks the whole tree in one go.
379        self.watch.shutdown();
380        let new_watch = BeeWatch::start(new_api.clone(), &self.root_cancel);
381        let new_health_rx = new_watch.health();
382        let new_screens = build_screens(&new_api, &new_watch);
383        self.api = new_api;
384        self.watch = new_watch;
385        self.health_rx = new_health_rx;
386        self.screens = new_screens;
387        // Keep the same tab index so the operator stays on the
388        // screen they were looking at — same data shape, new node.
389        Ok(())
390    }
391
392    /// Build and persist a redacted diagnostic bundle to a file in
393    /// the system temp directory. Designed to be paste-ready into a
394    /// support thread (Discord, GitHub issue) without leaking
395    /// auth tokens — URLs are reduced to their path component, since
396    /// Bearer tokens live in headers, not URLs.
397    fn export_diagnostic_bundle(&self) -> std::io::Result<PathBuf> {
398        let bundle = self.render_diagnostic_bundle();
399        let secs = SystemTime::now()
400            .duration_since(UNIX_EPOCH)
401            .map(|d| d.as_secs())
402            .unwrap_or(0);
403        let path = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs}.txt"));
404        std::fs::write(&path, bundle)?;
405        Ok(path)
406    }
407
408    fn render_diagnostic_bundle(&self) -> String {
409        let now = format_utc_now();
410        let health = self.health_rx.borrow().clone();
411        let topology = self.watch.topology().borrow().clone();
412        let gates = Health::gates_for(&health, Some(&topology));
413        let recent: Vec<_> = log_capture::handle()
414            .map(|c| {
415                let mut snap = c.snapshot();
416                let len = snap.len();
417                if len > 50 {
418                    snap.drain(0..len - 50);
419                }
420                snap
421            })
422            .unwrap_or_default();
423
424        let mut out = String::new();
425        out.push_str("# bee-tui diagnostic bundle\n");
426        out.push_str(&format!("# generated UTC {now}\n\n"));
427        out.push_str("## profile\n");
428        out.push_str(&format!("  name      {}\n", self.api.name));
429        out.push_str(&format!("  endpoint  {}\n\n", self.api.url));
430        out.push_str("## health gates\n");
431        for g in &gates {
432            out.push_str(&format_gate_line(g));
433        }
434        out.push_str("\n## last API calls (path only — Bearer tokens, if any, live in headers and aren't captured)\n");
435        for e in &recent {
436            let status = e.status.map(|s| s.to_string()).unwrap_or_else(|| "—".into());
437            let elapsed = e
438                .elapsed_ms
439                .map(|ms| format!("{ms}ms"))
440                .unwrap_or_else(|| "—".into());
441            out.push_str(&format!(
442                "  {ts} {method:<5} {path:<32} {status:>4} {elapsed:>7}\n",
443                ts = e.ts,
444                method = e.method,
445                path = path_only(&e.url),
446                status = status,
447                elapsed = elapsed,
448            ));
449        }
450        out.push_str(&format!(
451            "\n## generated by bee-tui {}\n",
452            env!("CARGO_PKG_VERSION"),
453        ));
454        out
455    }
456
457    fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
458        while let Ok(action) = self.action_rx.try_recv() {
459            if action != Action::Tick && action != Action::Render {
460                debug!("{action:?}");
461            }
462            match action {
463                Action::Tick => {
464                    self.last_tick_key_events.drain(..);
465                }
466                Action::Quit => self.should_quit = true,
467                Action::Suspend => self.should_suspend = true,
468                Action::Resume => self.should_suspend = false,
469                Action::ClearScreen => tui.terminal.clear()?,
470                Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
471                Action::Render => self.render(tui)?,
472                _ => {}
473            }
474            let tx = self.action_tx.clone();
475            for component in self.iter_components_mut() {
476                if let Some(action) = component.update(action.clone())? {
477                    tx.send(action)?
478                };
479            }
480        }
481        Ok(())
482    }
483
484    fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
485        tui.resize(Rect::new(0, 0, w, h))?;
486        self.render(tui)?;
487        Ok(())
488    }
489
490    fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
491        let active = self.current_screen;
492        let tx = self.action_tx.clone();
493        let screens = &mut self.screens;
494        let command_log = &mut self.command_log;
495        let command_buffer = self.command_buffer.clone();
496        let command_status = self.command_status.clone();
497        let profile = self.api.name.clone();
498        let endpoint = self.api.url.clone();
499        let last_ping = self.health_rx.borrow().last_ping;
500        let now_utc = format_utc_now();
501        tui.draw(|frame| {
502            use ratatui::layout::{Constraint, Layout};
503            use ratatui::style::{Color, Modifier, Style};
504            use ratatui::text::{Line, Span};
505            use ratatui::widgets::Paragraph;
506
507            let chunks = Layout::vertical([
508                Constraint::Length(2), // top-bar (metadata + tabs)
509                Constraint::Min(0),    // active screen
510                Constraint::Length(1), // command bar / status line
511                Constraint::Length(8), // command-log strip
512            ])
513            .split(frame.area());
514
515            let top_chunks =
516                Layout::vertical([Constraint::Length(1), Constraint::Length(1)])
517                    .split(chunks[0]);
518
519            // Metadata line: profile · endpoint · ping · clock.
520            let ping_str = match last_ping {
521                Some(d) => format!("{}ms", d.as_millis()),
522                None => "—".into(),
523            };
524            let t = theme::active();
525            let metadata_line = Line::from(vec![
526                Span::styled(
527                    " bee-tui ",
528                    Style::default()
529                        .fg(Color::Black)
530                        .bg(t.info)
531                        .add_modifier(Modifier::BOLD),
532                ),
533                Span::raw("  "),
534                Span::styled(
535                    profile,
536                    Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
537                ),
538                Span::styled(
539                    format!(" @ {endpoint}"),
540                    Style::default().fg(t.dim),
541                ),
542                Span::raw("   "),
543                Span::styled("ping ", Style::default().fg(t.dim)),
544                Span::styled(ping_str, Style::default().fg(t.info)),
545                Span::raw("   "),
546                Span::styled(format!("UTC {now_utc}"), Style::default().fg(t.dim)),
547            ]);
548            frame.render_widget(Paragraph::new(metadata_line), top_chunks[0]);
549
550            // Tab strip with the active screen highlighted.
551            let theme = *theme::active();
552            let mut tabs = Vec::with_capacity(SCREEN_NAMES.len() * 2);
553            for (i, name) in SCREEN_NAMES.iter().enumerate() {
554                let style = if i == active {
555                    Style::default()
556                        .fg(theme.tab_active_fg)
557                        .bg(theme.tab_active_bg)
558                        .add_modifier(Modifier::BOLD)
559                } else {
560                    Style::default().fg(theme.dim)
561                };
562                tabs.push(Span::styled(format!(" {name} "), style));
563                tabs.push(Span::raw(" "));
564            }
565            tabs.push(Span::styled(
566                ":cmd · Tab to cycle",
567                Style::default().fg(theme.dim),
568            ));
569            frame.render_widget(Paragraph::new(Line::from(tabs)), top_chunks[1]);
570
571            // Active screen
572            if let Some(screen) = screens.get_mut(active) {
573                if let Err(err) = screen.draw(frame, chunks[1]) {
574                    let _ = tx.send(Action::Error(format!("Failed to draw screen: {err:?}")));
575                }
576            }
577            // Command bar / status line
578            let prompt = if let Some(buf) = &command_buffer {
579                Line::from(vec![
580                    Span::styled(
581                        ":",
582                        Style::default()
583                            .fg(t.accent)
584                            .add_modifier(Modifier::BOLD),
585                    ),
586                    Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
587                    Span::styled("█", Style::default().fg(t.accent)),
588                ])
589            } else {
590                match &command_status {
591                    Some(CommandStatus::Info(msg)) => {
592                        Line::from(Span::styled(msg.clone(), Style::default().fg(t.pass)))
593                    }
594                    Some(CommandStatus::Err(msg)) => {
595                        Line::from(Span::styled(msg.clone(), Style::default().fg(t.fail)))
596                    }
597                    None => Line::from(""),
598                }
599            };
600            frame.render_widget(Paragraph::new(prompt), chunks[2]);
601
602            // Command-log strip
603            if let Err(err) = command_log.draw(frame, chunks[3]) {
604                let _ = tx.send(Action::Error(format!("Failed to draw log: {err:?}")));
605            }
606        })?;
607        Ok(())
608    }
609}
610
611/// Construct the eight v0.3 screens with receivers from the supplied
612/// hub. Extracted so `App::new` and the `:context` profile-switcher
613/// can share the wiring — the screen list is the same on every
614/// connection, only the underlying watch hub changes.
615///
616/// Order matters — the [`SCREEN_NAMES`] table assumes index 0 is
617/// Health, 1 is Stamps, 2 is Swap, 3 is Lottery, 4 is Peers, 5 is
618/// Network, 6 is Warmup, 7 is API, 8 is Tags.
619fn build_screens(api: &Arc<ApiClient>, watch: &BeeWatch) -> Vec<Box<dyn Component>> {
620    let health = Health::new(api.clone(), watch.health(), watch.topology());
621    let stamps = Stamps::new(watch.stamps());
622    let swap = Swap::new(watch.swap());
623    let lottery = Lottery::new(api.clone(), watch.health(), watch.lottery());
624    let peers = Peers::new(watch.topology());
625    let network = Network::new(watch.network(), watch.topology());
626    let warmup = Warmup::new(watch.health(), watch.stamps(), watch.topology());
627    let api_health = ApiHealth::new(
628        api.clone(),
629        watch.health(),
630        watch.transactions(),
631        log_capture::handle(),
632    );
633    let tags = Tags::new(watch.tags());
634    vec![
635        Box::new(health),
636        Box::new(stamps),
637        Box::new(swap),
638        Box::new(lottery),
639        Box::new(peers),
640        Box::new(network),
641        Box::new(warmup),
642        Box::new(api_health),
643        Box::new(tags),
644    ]
645}
646
647fn format_gate_line(g: &Gate) -> String {
648    let glyph = match g.status {
649        GateStatus::Pass => "✓",
650        GateStatus::Warn => "⚠",
651        GateStatus::Fail => "✗",
652        GateStatus::Unknown => "·",
653    };
654    let mut s = format!("  [{glyph}] {label:<28} {value}\n", label = g.label, value = g.value);
655    if let Some(why) = &g.why {
656        s.push_str(&format!("        └─ {why}\n"));
657    }
658    s
659}
660
661/// Strip scheme + host from a URL, leaving only the path + query.
662/// Mirrors the redaction the S10 command-log pane applies on render.
663fn path_only(url: &str) -> String {
664    if let Some(idx) = url.find("//") {
665        let after_scheme = &url[idx + 2..];
666        if let Some(slash) = after_scheme.find('/') {
667            return after_scheme[slash..].to_string();
668        }
669        return "/".into();
670    }
671    url.to_string()
672}
673
674/// Format the current wall-clock UTC time as `HH:MM:SS`. We compute
675/// from `SystemTime::now()` directly so the binary stays free of a
676/// chrono / time dep just for this one display string.
677fn format_utc_now() -> String {
678    let secs = SystemTime::now()
679        .duration_since(UNIX_EPOCH)
680        .map(|d| d.as_secs())
681        .unwrap_or(0);
682    let secs_in_day = secs % 86_400;
683    let h = secs_in_day / 3_600;
684    let m = (secs_in_day % 3_600) / 60;
685    let s = secs_in_day % 60;
686    format!("{h:02}:{m:02}:{s:02}")
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn format_utc_now_returns_eight_chars() {
695        let s = format_utc_now();
696        assert_eq!(s.len(), 8);
697        assert_eq!(s.chars().nth(2), Some(':'));
698        assert_eq!(s.chars().nth(5), Some(':'));
699    }
700
701    #[test]
702    fn path_only_strips_scheme_and_host() {
703        assert_eq!(
704            path_only("http://10.0.1.5:1633/status"),
705            "/status"
706        );
707        assert_eq!(
708            path_only("https://bee.example.com/stamps?limit=10"),
709            "/stamps?limit=10"
710        );
711    }
712
713    #[test]
714    fn path_only_handles_no_path() {
715        assert_eq!(path_only("http://localhost:1633"), "/");
716    }
717
718    #[test]
719    fn path_only_passes_relative_through() {
720        assert_eq!(path_only("/already/relative"), "/already/relative");
721    }
722}