Skip to main content

max / makenotwork

25.6 KB · 702 lines History Blame Raw
1 //! bento-tui — operator front-end for bentod.
2 //!
3 //! Layout (top to bottom):
4 //!
5 //! ┌ daemon ─────────────────────────────────────────────────────┐
6 //! │ bento -> http://...:7800 (ws ok) build: goingson 0.4.1 │
7 //! ├ matrix ── target x step (↑/↓ select target) ────────────────│
8 //! │ target chk pre bld sgn ntz stp vfy pkg pub col │
9 //! │>macos/aarch64 O O > . . . . . . . │
10 //! │ linux/x86_64 O O O - - O - - O O │
11 //! ├ events (WS /events) ────────────────────────────────────────│
12 //! │ 09:21:03 target_start linux/x86_64 │
13 //! ├ tail [42] linux/x86_64 build — in-flight ───────────────────│
14 //! │ Compiling goingson v0.4.1 │
15 //! ├ status / keys ──────────────────────────────────────────────│
16 //! │ [b] build [R] retry target [↑↓] select [[/]] tail [q] quit│
17 //! └─────────────────────────────────────────────────────────────┘
18
19 use anyhow::{Context, Result};
20 use crossterm::event::{self, Event as XEvent, KeyCode, KeyModifiers};
21 use crossterm::terminal::{
22 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
23 };
24 use futures_util::StreamExt;
25 use ratatui::prelude::*;
26 use ratatui::widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table};
27 use bento_daemon::domain::{Step, StepRunId};
28 use bento_daemon::events::{Event, EventEnvelope};
29 use serde::Deserialize;
30 use std::collections::{BTreeMap, VecDeque};
31 use std::io;
32 use std::sync::{Arc, Mutex};
33 use std::time::Duration;
34 use tokio::sync::mpsc;
35
36 // ---------- daemon types (subset of GET /state) ----------
37
38 #[derive(Clone, Debug, Deserialize)]
39 struct StateView {
40 build: Option<BuildView>,
41 }
42
43 #[derive(Clone, Debug, Deserialize)]
44 struct BuildView {
45 #[allow(dead_code)]
46 id: i64,
47 app: String,
48 version: String,
49 status: String,
50 targets: Vec<TargetView>,
51 }
52
53 #[derive(Clone, Debug, Deserialize)]
54 struct TargetView {
55 target: String,
56 status: String,
57 #[allow(dead_code)]
58 current_step: Option<String>,
59 error: Option<String>,
60 steps: Vec<StepView>,
61 }
62
63 #[derive(Clone, Debug, Deserialize)]
64 struct StepView {
65 #[allow(dead_code)]
66 run_id: i64,
67 step: String,
68 status: String,
69 }
70
71 /// Short column headers for the matrix, in canonical step order.
72 const STEP_COLS: [(Step, &str); 10] = [
73 (Step::Checkout, "chk"),
74 (Step::Prebuild, "pre"),
75 (Step::Build, "bld"),
76 (Step::Sign, "sgn"),
77 (Step::Notarize, "ntz"),
78 (Step::Staple, "stp"),
79 (Step::Verify, "vfy"),
80 (Step::Package, "pkg"),
81 (Step::Publish, "pub"),
82 (Step::Collect, "col"),
83 ];
84
85 // ---------- shared app state ----------
86
87 #[derive(Default)]
88 struct Shared {
89 state: Option<StateView>,
90 last_err: Option<String>,
91 events: VecDeque<String>,
92 ws_ok: bool,
93 selected: usize,
94 notice: Option<String>,
95 tails: BTreeMap<StepRunId, StepTail>,
96 focus_run: Option<StepRunId>,
97 }
98
99 const EVENTS_CAP: usize = 200;
100 const TAILS_CAP: usize = 10;
101 const TAIL_LINES_CAP: usize = 200;
102
103 /// Per-step live-tail buffer. Receives `StepLogChunk` text and presents it as a
104 /// line-aware ring; chunks are not line-aligned at the transport, so a trailing
105 /// partial line is buffered across chunks.
106 struct StepTail {
107 target: String,
108 step: String,
109 lines: VecDeque<String>,
110 partial: String,
111 status: TailStatus,
112 }
113
114 #[derive(Clone, PartialEq, Eq)]
115 enum TailStatus {
116 InFlight,
117 Finished(String),
118 }
119
120 impl StepTail {
121 fn new(target: String, step: String) -> Self {
122 Self {
123 target,
124 step,
125 lines: VecDeque::new(),
126 partial: String::new(),
127 status: TailStatus::InFlight,
128 }
129 }
130
131 fn push_chunk(&mut self, text: &str) {
132 let combined = std::mem::take(&mut self.partial) + text;
133 let mut rest = combined.as_str();
134 while let Some(idx) = rest.find('\n') {
135 let (line, after) = rest.split_at(idx);
136 self.push_line(line.trim_end_matches('\r').to_string());
137 rest = &after[1..];
138 }
139 self.partial = rest.to_string();
140 }
141
142 fn push_line(&mut self, line: String) {
143 if self.lines.len() >= TAIL_LINES_CAP {
144 self.lines.pop_front();
145 }
146 self.lines.push_back(line);
147 }
148
149 fn finalize(&mut self, status_word: String) {
150 let trailing = std::mem::take(&mut self.partial);
151 if !trailing.is_empty() {
152 self.push_line(trailing);
153 }
154 self.status = TailStatus::Finished(status_word);
155 }
156 }
157
158 impl Shared {
159 fn push_event(&mut self, line: String) {
160 if self.events.len() >= EVENTS_CAP {
161 self.events.pop_front();
162 }
163 self.events.push_back(line);
164 }
165
166 fn open_tail(&mut self, run_id: StepRunId, target: String, step: String) {
167 if self.tails.len() >= TAILS_CAP {
168 if let Some((&oldest, _)) = self.tails.iter().next() {
169 self.tails.remove(&oldest);
170 }
171 }
172 self.tails.insert(run_id, StepTail::new(target, step));
173 self.focus_run = Some(run_id);
174 }
175
176 fn push_tail_chunk(&mut self, run_id: StepRunId, text: &str) {
177 if let Some(t) = self.tails.get_mut(&run_id) {
178 t.push_chunk(text);
179 }
180 }
181
182 fn finalize_tail(&mut self, run_id: StepRunId, status_word: String) {
183 if let Some(t) = self.tails.get_mut(&run_id) {
184 t.finalize(status_word);
185 }
186 }
187
188 /// App to act on: the latest build's app, else the env default.
189 fn app_name(&self, default_app: &str) -> String {
190 self.state
191 .as_ref()
192 .and_then(|s| s.build.as_ref())
193 .map(|b| b.app.clone())
194 .unwrap_or_else(|| default_app.to_string())
195 }
196
197 fn selected_target(&self) -> Option<String> {
198 self.state
199 .as_ref()
200 .and_then(|s| s.build.as_ref())
201 .and_then(|b| b.targets.get(self.selected))
202 .map(|t| t.target.clone())
203 }
204 }
205
206 // ---------- main ----------
207
208 fn main() -> Result<()> {
209 let daemon = std::env::var("BENTO_DAEMON").unwrap_or_else(|_| "http://127.0.0.1:7800".into());
210 let default_app = std::env::var("BENTO_APP").unwrap_or_else(|_| "goingson".into());
211 let shared = Arc::new(Mutex::new(Shared::default()));
212
213 let rt = tokio::runtime::Builder::new_multi_thread()
214 .enable_all()
215 .worker_threads(2)
216 .build()?;
217
218 let _g = rt.enter();
219 rt.spawn(state_poller(daemon.clone(), shared.clone()));
220 rt.spawn(events_subscriber(daemon.clone(), shared.clone()));
221
222 enable_raw_mode()?;
223 let mut stdout = io::stdout();
224 crossterm::execute!(stdout, EnterAlternateScreen)?;
225 let backend = CrosstermBackend::new(stdout);
226 let mut term = Terminal::new(backend)?;
227
228 let res = ui_loop(&mut term, &daemon, &default_app, &shared, rt.handle());
229
230 disable_raw_mode()?;
231 crossterm::execute!(term.backend_mut(), LeaveAlternateScreen)?;
232 term.show_cursor()?;
233 res
234 }
235
236 // ---------- background tasks ----------
237
238 async fn state_poller(daemon: String, shared: Arc<Mutex<Shared>>) {
239 let url = format!("{daemon}/state");
240 let client = reqwest::Client::new();
241 loop {
242 match client.get(&url).timeout(Duration::from_secs(2)).send().await {
243 Ok(resp) if resp.status().is_success() => match resp.json::<StateView>().await {
244 Ok(s) => {
245 let mut g = shared.lock().unwrap();
246 let n = s
247 .build
248 .as_ref()
249 .map(|b| b.targets.len().saturating_sub(1))
250 .unwrap_or(0);
251 if g.selected > n {
252 g.selected = n;
253 }
254 g.state = Some(s);
255 g.last_err = None;
256 }
257 Err(e) => shared.lock().unwrap().last_err = Some(format!("decode: {e}")),
258 },
259 Ok(resp) => shared.lock().unwrap().last_err = Some(format!("status {}", resp.status())),
260 Err(e) => shared.lock().unwrap().last_err = Some(e.to_string()),
261 }
262 tokio::time::sleep(Duration::from_secs(2)).await;
263 }
264 }
265
266 async fn events_subscriber(daemon: String, shared: Arc<Mutex<Shared>>) {
267 let ws_url = ws_url_from(&daemon);
268 loop {
269 match tokio_tungstenite::connect_async(&ws_url).await {
270 Ok((mut socket, _resp)) => {
271 shared.lock().unwrap().ws_ok = true;
272 while let Some(msg) = socket.next().await {
273 match msg {
274 Ok(tokio_tungstenite::tungstenite::Message::Text(t)) => {
275 dispatch_ws_frame(&shared, &t)
276 }
277 Ok(tokio_tungstenite::tungstenite::Message::Close(_)) | Err(_) => break,
278 _ => {}
279 }
280 }
281 shared.lock().unwrap().ws_ok = false;
282 }
283 Err(_) => shared.lock().unwrap().ws_ok = false,
284 }
285 tokio::time::sleep(Duration::from_secs(3)).await;
286 }
287 }
288
289 fn ws_url_from(daemon: &str) -> String {
290 daemon.replacen("https://", "wss://", 1).replacen("http://", "ws://", 1) + "/events"
291 }
292
293 /// Route a WS frame: log chunks update the per-step tail; everything else
294 /// becomes one line in the events ring. (Chunks never hit the ring — a busy
295 /// build emits hundreds per second and would evict every other event.)
296 fn dispatch_ws_frame(shared: &Arc<Mutex<Shared>>, raw: &str) {
297 if let Some(line) = format_lagged(raw) {
298 shared.lock().unwrap().push_event(line);
299 return;
300 }
301 let Ok(env) = serde_json::from_str::<EventEnvelope>(raw) else {
302 shared.lock().unwrap().push_event(raw.to_string());
303 return;
304 };
305 match &env.event {
306 Event::StepStart { run_id, target, step, .. } => {
307 let mut g = shared.lock().unwrap();
308 g.open_tail(*run_id, target.to_string(), step.to_string());
309 let line = format_event_line(&env);
310 g.push_event(line);
311 }
312 Event::StepLogChunk { run_id, text, .. } => {
313 shared.lock().unwrap().push_tail_chunk(*run_id, text);
314 }
315 Event::StepDone { run_id, status, .. } => {
316 let word = status.as_str().to_string();
317 let mut g = shared.lock().unwrap();
318 g.finalize_tail(*run_id, word);
319 let line = format_event_line(&env);
320 g.push_event(line);
321 }
322 _ => {
323 let line = format_event_line(&env);
324 shared.lock().unwrap().push_event(line);
325 }
326 }
327 }
328
329 fn format_event_line(env: &EventEnvelope) -> String {
330 let time = env.at.format("%H:%M:%S").to_string();
331 format!("{time} {}", format_event_body(&env.event))
332 }
333
334 fn format_event_body(e: &Event) -> String {
335 match e {
336 Event::BuildRequested { app, version, targets } => {
337 format!("build_requested {app} {version} [{}]", targets.iter().map(|t| t.to_string()).collect::<Vec<_>>().join(","))
338 }
339 Event::TargetAborted { target, .. } => format!("target_aborted {target}"),
340 Event::TargetStart { target, .. } => format!("target_start {target}"),
341 Event::StepStart { target, step, .. } => format!("step_start {target} {step}"),
342 Event::StepLogChunk { run_id, text, .. } => format!("log[{run_id}] {}", truncate(text.trim_end(), 80)),
343 Event::StepDone { target, step, status, .. } => format!("step_done {target} {step} {}", status.as_str()),
344 Event::TargetOk { target, artifacts, .. } => format!("target_ok {target} ({} artifacts)", artifacts.len()),
345 Event::TargetFailed { target, step, error, .. } => format!("target_failed {target} {step}: {}", truncate(error, 80)),
346 Event::NotarizeRetry { target, attempt, reason, .. } => format!("notarize_retry {target} #{attempt} {reason}"),
347 Event::ArtifactCollected { target, path, bytes, .. } => format!("artifact {target} {path} {bytes}B"),
348 Event::PublishOk { target, channel, .. } => format!("publish_ok {target} {channel}"),
349 Event::PublishFailed { target, channel, error, .. } => format!("publish_failed {target} {channel}: {}", truncate(error, 80)),
350 }
351 }
352
353 fn format_lagged(raw: &str) -> Option<String> {
354 let v: serde_json::Value = serde_json::from_str(raw).ok()?;
355 if v.get("kind")?.as_str()? == "lagged" {
356 Some(format!("(lagged, skipped {})", v.get("skipped").and_then(|n| n.as_i64()).unwrap_or(0)))
357 } else {
358 None
359 }
360 }
361
362 // ---------- actions ----------
363
364 #[derive(Clone, Debug)]
365 enum Action {
366 Build { app: String },
367 Retry { app: String, target: String },
368 }
369
370 impl std::fmt::Display for Action {
371 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372 match self {
373 Action::Build { app } => write!(f, "build {app}"),
374 Action::Retry { app, target } => write!(f, "retry {app}/{target}"),
375 }
376 }
377 }
378
379 async fn dispatch_action(daemon: &str, act: &Action) -> Result<String> {
380 let client = reqwest::Client::new();
381 let (url, body) = match act {
382 Action::Build { app } => (format!("{daemon}/build"), serde_json::json!({ "app": app })),
383 Action::Retry { app, target } => {
384 (format!("{daemon}/retry"), serde_json::json!({ "app": app, "target": target }))
385 }
386 };
387 let resp = client
388 .post(&url)
389 .timeout(Duration::from_secs(30))
390 .json(&body)
391 .send()
392 .await
393 .context("send")?;
394 let status = resp.status();
395 let text = resp.text().await.unwrap_or_default();
396 if status.is_success() {
397 Ok(truncate(&text, 120))
398 } else {
399 Err(anyhow::anyhow!("HTTP {status}: {}", truncate(&text, 200)))
400 }
401 }
402
403 // ---------- ui loop ----------
404
405 fn ui_loop<B: Backend>(
406 term: &mut Terminal<B>,
407 daemon: &str,
408 default_app: &str,
409 shared: &Arc<Mutex<Shared>>,
410 rt: &tokio::runtime::Handle,
411 ) -> Result<()> {
412 let (action_tx, mut action_rx) = mpsc::channel::<Action>(32);
413 {
414 let shared = shared.clone();
415 let daemon = daemon.to_string();
416 rt.spawn(async move {
417 while let Some(act) = action_rx.recv().await {
418 let res = dispatch_action(&daemon, &act).await;
419 let line = match res {
420 Ok(msg) => format!("[ok] {act}: {msg}"),
421 Err(e) => format!("[err] {act}: {e}"),
422 };
423 let mut g = shared.lock().unwrap();
424 g.notice = Some(line.clone());
425 g.push_event(format!(" action {line}"));
426 }
427 });
428 }
429
430 loop {
431 term.draw(|f| draw(f, daemon, shared))?;
432
433 if event::poll(Duration::from_millis(120))? {
434 if let XEvent::Key(k) = event::read()? {
435 match k.code {
436 KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
437 KeyCode::Char('c') if k.modifiers.contains(KeyModifiers::CONTROL) => {
438 return Ok(());
439 }
440 KeyCode::Up | KeyCode::Char('k') => {
441 let mut g = shared.lock().unwrap();
442 g.selected = g.selected.saturating_sub(1);
443 }
444 KeyCode::Down | KeyCode::Char('j') => {
445 let mut g = shared.lock().unwrap();
446 let max = g
447 .state
448 .as_ref()
449 .and_then(|s| s.build.as_ref())
450 .map(|b| b.targets.len().saturating_sub(1))
451 .unwrap_or(0);
452 if g.selected < max {
453 g.selected += 1;
454 }
455 }
456 KeyCode::Char('b') => {
457 let app = shared.lock().unwrap().app_name(default_app);
458 let _ = action_tx.try_send(Action::Build { app });
459 }
460 KeyCode::Char('R') => {
461 let (app, target) = {
462 let g = shared.lock().unwrap();
463 (g.app_name(default_app), g.selected_target())
464 };
465 if let Some(target) = target {
466 let _ = action_tx.try_send(Action::Retry { app, target });
467 }
468 }
469 KeyCode::Char('r') => {
470 shared.lock().unwrap().notice = Some("refresh on next tick".into());
471 }
472 KeyCode::Char('[') => {
473 let mut g = shared.lock().unwrap();
474 g.focus_run = cycle_focus(&g.tails, g.focus_run, -1);
475 }
476 KeyCode::Char(']') => {
477 let mut g = shared.lock().unwrap();
478 g.focus_run = cycle_focus(&g.tails, g.focus_run, 1);
479 }
480 _ => {}
481 }
482 }
483 }
484 }
485 }
486
487 // ---------- render ----------
488
489 fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) {
490 let g = shared.lock().unwrap();
491 let chunks = Layout::default()
492 .direction(Direction::Vertical)
493 .constraints([
494 Constraint::Length(3), // header
495 Constraint::Length(10), // matrix
496 Constraint::Min(4), // events
497 Constraint::Length(8), // tail
498 Constraint::Length(2), // status
499 ])
500 .split(f.area());
501
502 let ws_label = if g.ws_ok { "ws ok" } else { "ws ..." };
503 let build_label = g
504 .state
505 .as_ref()
506 .and_then(|s| s.build.as_ref())
507 .map(|b| format!("{} {} ({})", b.app, b.version, b.status))
508 .unwrap_or_else(|| "no builds yet".into());
509 let header = Paragraph::new(format!("bento -> {daemon} ({ws_label}) build: {build_label}"))
510 .block(Block::default().title("daemon").borders(Borders::ALL));
511 f.render_widget(header, chunks[0]);
512
513 // Matrix: rows = targets, columns = steps.
514 match g.state.as_ref().and_then(|s| s.build.as_ref()) {
515 Some(b) if !b.targets.is_empty() => {
516 let mut header_cells = vec![" target".to_string()];
517 header_cells.extend(STEP_COLS.iter().map(|(_, abbr)| abbr.to_string()));
518 let hr = Row::new(header_cells).style(Style::default().add_modifier(Modifier::BOLD));
519
520 let rows: Vec<Row> = b
521 .targets
522 .iter()
523 .enumerate()
524 .map(|(i, t)| {
525 let marker = if i == g.selected { ">" } else { " " };
526 let mut cells: Vec<Cell> =
527 vec![format!("{marker} {}", t.target).into()];
528 for (step, _) in STEP_COLS {
529 let status = t
530 .steps
531 .iter()
532 .find(|s| s.step == step.as_str())
533 .map(|s| s.status.as_str());
534 let (mark, style) = step_mark(status);
535 cells.push(Cell::from(mark).style(style));
536 }
537 let row = Row::new(cells);
538 if i == g.selected {
539 row.style(Style::default().add_modifier(Modifier::REVERSED))
540 } else {
541 row
542 }
543 })
544 .collect();
545
546 let mut widths = vec![Constraint::Length(20)];
547 widths.extend(STEP_COLS.iter().map(|_| Constraint::Length(4)));
548 let table = Table::new(rows, widths).header(hr).block(
549 Block::default()
550 .title(format!("matrix ({} targets) ↑/↓ select", b.targets.len()))
551 .borders(Borders::ALL),
552 );
553 f.render_widget(table, chunks[1]);
554 }
555 _ => {
556 let msg = g.last_err.clone().unwrap_or_else(|| "no active build — press [b] to start".into());
557 let placeholder = Paragraph::new(msg).block(Block::default().title("matrix").borders(Borders::ALL));
558 f.render_widget(placeholder, chunks[1]);
559 }
560 }
561
562 // Events.
563 let area = chunks[2];
564 let visible = area.height.saturating_sub(2) as usize;
565 let items: Vec<ListItem> = g.events.iter().rev().take(visible).rev().map(|l| ListItem::new(l.clone())).collect();
566 let events_block = List::new(items).block(
567 Block::default().title(format!("events ({})", g.events.len())).borders(Borders::ALL),
568 );
569 f.render_widget(events_block, area);
570
571 // Tail.
572 let tail_area = chunks[3];
573 let tail = g.focus_run.and_then(|id| g.tails.get(&id).map(|t| (id, t)));
574 let tail_widget = match tail {
575 Some((id, t)) => {
576 let state = match &t.status {
577 TailStatus::InFlight => "in-flight".to_string(),
578 TailStatus::Finished(s) => s.clone(),
579 };
580 let title = format!(
581 "tail [{id}] {}/{}{state} ([) prev / (]) next {} of {}",
582 t.target,
583 t.step,
584 tail_position(&g.tails, id),
585 g.tails.len(),
586 );
587 let visible = tail_area.height.saturating_sub(2) as usize;
588 let items: Vec<ListItem> =
589 t.lines.iter().rev().take(visible).rev().map(|l| ListItem::new(truncate(l, 200))).collect();
590 List::new(items).block(Block::default().title(title).borders(Borders::ALL))
591 }
592 None => List::new(vec![ListItem::new("no recent step runs")])
593 .block(Block::default().title("tail").borders(Borders::ALL)),
594 };
595 f.render_widget(tail_widget, tail_area);
596
597 // Status / keys. Surface the selected target's error if it failed.
598 let keys = "[b] build [R] retry target [↑↓] select [[/]] tail [r] refresh [q] quit";
599 let sel_err = g
600 .state
601 .as_ref()
602 .and_then(|s| s.build.as_ref())
603 .and_then(|b| b.targets.get(g.selected))
604 .filter(|t| t.status == "failed")
605 .and_then(|t| t.error.clone());
606 let status = if let Some(e) = sel_err {
607 format!("FAILED: {} {keys}", truncate(&e, 100))
608 } else if let Some(n) = &g.notice {
609 format!("{n} {keys}")
610 } else if let Some(e) = &g.last_err {
611 format!("error: {e} {keys}")
612 } else {
613 keys.into()
614 };
615 f.render_widget(Paragraph::new(status), chunks[4]);
616 }
617
618 /// Status -> (mark, style). `None` = no row yet (step not reached).
619 fn step_mark(status: Option<&str>) -> (&'static str, Style) {
620 match status {
621 Some("ok") => ("O", Style::default().fg(Color::Green)),
622 Some("failed") => ("X", Style::default().fg(Color::Red)),
623 Some("running") => (">", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
624 Some("pending") => (".", Style::default().fg(Color::DarkGray)),
625 _ => ("-", Style::default().fg(Color::DarkGray)),
626 }
627 }
628
629 fn tail_position(tails: &BTreeMap<StepRunId, StepTail>, id: StepRunId) -> usize {
630 tails.keys().position(|k| *k == id).map(|i| i + 1).unwrap_or(0)
631 }
632
633 fn cycle_focus(
634 tails: &BTreeMap<StepRunId, StepTail>,
635 current: Option<StepRunId>,
636 step: i32,
637 ) -> Option<StepRunId> {
638 if tails.is_empty() {
639 return None;
640 }
641 let keys: Vec<StepRunId> = tails.keys().copied().collect();
642 let idx = current
643 .and_then(|c| keys.iter().position(|k| *k == c))
644 .unwrap_or(keys.len().saturating_sub(1));
645 let len = keys.len() as i32;
646 let new_idx = ((idx as i32 + step) % len + len) % len;
647 Some(keys[new_idx as usize])
648 }
649
650 fn truncate(s: &str, n: usize) -> String {
651 if s.chars().count() <= n {
652 s.into()
653 } else {
654 s.chars().take(n).collect::<String>() + ""
655 }
656 }
657
658 #[cfg(test)]
659 mod tests {
660 use super::*;
661
662 #[test]
663 fn ws_url_swaps_scheme_once() {
664 assert_eq!(ws_url_from("http://x:7800"), "ws://x:7800/events");
665 assert_eq!(ws_url_from("https://x"), "wss://x/events");
666 }
667
668 #[test]
669 fn step_mark_colors() {
670 assert_eq!(step_mark(Some("ok")).0, "O");
671 assert_eq!(step_mark(Some("failed")).0, "X");
672 assert_eq!(step_mark(Some("running")).0, ">");
673 assert_eq!(step_mark(None).0, "-");
674 }
675
676 #[test]
677 fn tail_chunk_reassembles_lines() {
678 let mut t = StepTail::new("linux/x86_64".into(), "build".into());
679 t.push_chunk("Compil");
680 t.push_chunk("ing\nDone\n");
681 assert_eq!(t.lines, vec!["Compiling".to_string(), "Done".to_string()]);
682 t.finalize("ok".into());
683 assert_eq!(t.status, TailStatus::Finished("ok".into()));
684 }
685
686 #[test]
687 fn cycle_focus_wraps() {
688 let mut tails = BTreeMap::new();
689 tails.insert(StepRunId(1), StepTail::new("a".into(), "build".into()));
690 tails.insert(StepRunId(2), StepTail::new("b".into(), "sign".into()));
691 assert_eq!(cycle_focus(&tails, Some(StepRunId(2)), 1), Some(StepRunId(1)));
692 assert_eq!(cycle_focus(&tails, Some(StepRunId(1)), -1), Some(StepRunId(2)));
693 assert_eq!(cycle_focus(&BTreeMap::new(), None, 1), None);
694 }
695
696 #[test]
697 fn format_lagged_detects() {
698 assert!(format_lagged(r#"{"kind":"lagged","skipped":5}"#).unwrap().contains("5"));
699 assert!(format_lagged(r#"{"kind":"step_start"}"#).is_none());
700 }
701 }
702