rskit-cli 0.2.0-alpha.2

CLI framework: progress bars, structured output, signal handling
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
//! [`LiveConsole`] — a multi-region live terminal renderer.
//!
//! Renders several concurrent output streams as fixed-height "tiles" stacked in
//! a live area at the bottom of the terminal, each showing a bounded virtual
//! terminal of one stream (via [`RegionScreen`]). The tiles are an ephemeral
//! progress peek: rows that scroll off the top are dropped from the live view,
//! not flushed to scrollback, so a chatty stream does not flood the transcript.
//! Durable signal is emitted only at [`finish`](LiveConsole::finish) time — a
//! one-line verdict — and, for a failed region, a bounded replay of its retained
//! tail via [`finish_with_replay`](LiveConsole::finish_with_replay).
//!
//! The terminal mechanics (cursor positioning, redraw rate-limiting, width
//! handling, resize) are delegated to `indicatif`'s multi-progress engine, so
//! this type only owns the tile layout, the bounded failure ring, and the
//! verdict flush. It is generic: callers feed labeled byte streams and get
//! terminal rendering out, with no domain types involved.

use std::collections::HashMap;
use std::collections::VecDeque;

use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};

use super::screen::RegionScreen;

/// The string each tile indents its content by, beneath the region header. The
/// virtual terminal grid is sized to the tile width minus this indent's width
/// so a child's output fills the visible content area exactly, without being
/// chopped.
const TILE_INDENT: &str = "  ";

/// How the live console lays out and truncates tiles.
///
/// The console does not auto-detect the terminal width or react to resizes:
/// `cols` is a fixed tile width the caller supplies once. Passing a value that
/// does not match the real terminal only over- or under-truncates the tiles; it
/// never corrupts output, since every tile line is clamped to `cols`.
#[derive(Debug, Clone, Copy)]
pub struct LiveConfig {
    /// Content rows shown per region tile — the height of its virtual terminal.
    pub rows: usize,
    /// Terminal columns: the tile width. A child's output is applied to a grid
    /// sized to the visible content area (this width minus the content indent),
    /// so a real width must be passed (unlike the old truncation width, `0` is
    /// not "disable").
    pub cols: usize,
    /// How many rows that scroll off a tile are retained per region for a
    /// failure replay. The live tile stays a bounded peek; on failure this many
    /// of the most-recent scrolled-off rows (plus the final on-screen rows) are
    /// flushed to scrollback as the failure block. `0` retains nothing, so a
    /// failure replays only the rows still on screen.
    pub scrollback: usize,
}

impl Default for LiveConfig {
    fn default() -> Self {
        Self {
            rows: 5,
            cols: 80,
            scrollback: 200,
        }
    }
}

impl LiveConfig {
    /// The inner virtual-terminal grid width: the tile width minus the content
    /// indent rendered under each region header.
    ///
    /// A child whose output is fed to the tile must be told its terminal is this
    /// wide (not the full tile width), so its own line wrapping matches the grid.
    /// Otherwise a full-width in-place progress redraw wraps at the grid edge,
    /// scrolls the short grid, and churns the retained failure tail with stale
    /// half-frames on every tick — which then surface in the bounded replay when
    /// a region fails.
    #[must_use]
    pub fn content_cols(&self) -> usize {
        self.cols.saturating_sub(TILE_INDENT.len()).max(1)
    }
}

/// One in-flight region: its virtual terminal, label, backing progress line,
/// and a bounded ring of the rows that have scrolled off its tile — kept only
/// so a failure can replay recent context.
struct Region {
    bar: ProgressBar,
    screen: RegionScreen,
    label: String,
    retained: VecDeque<String>,
}

/// A live, multi-region terminal renderer.
///
/// Lifecycle per region: [`begin`](Self::begin) with a label,
/// [`feed`](Self::feed) raw bytes as they arrive, then [`finish`](Self::finish)
/// with a one-line verdict. [`set_header`](Self::set_header) updates a status
/// line pinned above the tiles.
pub struct LiveConsole {
    multi: MultiProgress,
    header: ProgressBar,
    regions: HashMap<String, Region>,
    config: LiveConfig,
}

impl LiveConsole {
    /// Create a console that renders to stderr.
    ///
    /// stderr keeps the live UI off stdout, leaving any machine-readable stream
    /// there uncorrupted.
    #[must_use]
    pub fn to_stderr(config: LiveConfig) -> Self {
        Self::with_target(ProgressDrawTarget::stderr(), config)
    }

    /// Create a console whose output is discarded — for tests and non-terminal
    /// runs where the live area must not render.
    #[must_use]
    pub fn hidden(config: LiveConfig) -> Self {
        Self::with_target(ProgressDrawTarget::hidden(), config)
    }

    fn with_target(target: ProgressDrawTarget, mut config: LiveConfig) -> Self {
        // Keep the grid and the renderer consistent: a tile always shows at
        // least one content row and its virtual terminal needs a real width.
        config.rows = config.rows.max(1);
        config.cols = config.cols.max(1);
        let multi = MultiProgress::with_draw_target(target);
        let header = multi.add(ProgressBar::new(0));
        header.set_style(message_style());
        Self {
            multi,
            header,
            regions: HashMap::new(),
            config,
        }
    }

    /// The virtual terminal grid width: the tile width minus the content
    /// indent, so a child fills the visible content area without being chopped.
    /// Children feeding a tile should be sized to this; see
    /// [`LiveConfig::content_cols`].
    #[must_use]
    pub fn content_cols(&self) -> usize {
        self.config.content_cols()
    }

    /// Set the status line shown above the tiles.
    pub fn set_header(&self, text: impl Into<String>) {
        self.header.set_message(text.into());
    }

    /// Start a new region tile labeled `label`, keyed by `id`.
    ///
    /// A duplicate `id` discards the existing region first — dropping its tile
    /// and retained tail — so a re-used key neither leaks a stale tile nor
    /// double-reports.
    pub fn begin(&mut self, id: impl Into<String>, label: impl Into<String>) {
        let id = id.into();
        let label = label.into();
        if let Some(old) = self.regions.remove(&id) {
            old.bar.finish_and_clear();
            self.multi.remove(&old.bar);
        }
        let bar = self.multi.add(ProgressBar::new(0));
        bar.set_style(message_style());
        let region = Region {
            bar,
            screen: RegionScreen::new(self.config.rows, self.content_cols()),
            label,
            retained: VecDeque::new(),
        };
        region.bar.set_message(render_tile(
            &region.label,
            &[] as &[&str],
            self.config.cols,
            self.config.rows,
        ));
        self.regions.insert(id, region);
    }

    /// Feed raw output bytes to region `id`, updating its tile.
    ///
    /// Rows evicted from the tile are dropped from the live view but appended to
    /// the region's bounded retention ring, so a later failure can replay recent
    /// context. A feed for an unknown `id` is ignored, so late output after a
    /// finish cannot panic.
    pub fn feed(&mut self, id: &str, bytes: &[u8]) {
        let scrollback = self.config.scrollback;
        let Some(region) = self.regions.get_mut(id) else {
            return;
        };
        for line in region.screen.feed(bytes) {
            region.retained.push_back(line);
            while region.retained.len() > scrollback {
                region.retained.pop_front();
            }
        }
        let visible = region.screen.render();
        region.bar.set_message(render_tile(
            &region.label,
            &visible,
            self.config.cols,
            self.config.rows,
        ));
    }

    /// Finish region `id`, removing its tile and printing `verdict` as the only
    /// scrollback line — the collapsed signal for a succeeding unit.
    ///
    /// The region's peeked output is discarded, not flushed: on success the
    /// verdict (which the caller may enrich with a run summary) is the whole
    /// story. A finish for an unknown `id` prints only the verdict. Returns any
    /// I/O error from the verdict write.
    pub fn finish(&mut self, id: &str, verdict: impl AsRef<str>) -> std::io::Result<()> {
        if let Some(region) = self.regions.remove(id) {
            region.bar.finish_and_clear();
            self.multi.remove(&region.bar);
        }
        self.multi.println(verdict.as_ref())
    }

    /// Finish a failed region `id`, replaying its retained tail to scrollback as
    /// one contiguous, label-prefixed block, then printing `verdict`.
    ///
    /// The replay is the region's retention ring (rows that scrolled off the
    /// tile, oldest first) followed by the rows still on screen — a bounded,
    /// un-interleaved failure transcript. A finish for an unknown `id` prints
    /// only the verdict. Returns any I/O error from the flush or verdict write.
    pub fn finish_with_replay(
        &mut self,
        id: &str,
        verdict: impl AsRef<str>,
    ) -> std::io::Result<()> {
        if let Some(region) = self.regions.remove(id) {
            let Region {
                bar,
                screen,
                label,
                retained,
            } = region;
            for line in replay_body(&retained, screen.drain()) {
                let prefixed = scrollback_line(&label, &line);
                self.multi.println(truncate(&prefixed, self.config.cols))?;
            }
            bar.finish_and_clear();
            self.multi.remove(&bar);
        }
        self.multi.println(verdict.as_ref())
    }

    /// Print `line` to scrollback above the live area, without touching any tile.
    ///
    /// For output that is not tied to a live region — a header banner, or a
    /// completed unit's buffered block on the rare path where a unit is not
    /// live-tailed. Returns any I/O error from the write.
    pub fn note(&self, line: impl AsRef<str>) -> std::io::Result<()> {
        self.multi.println(line.as_ref())
    }

    /// Drop every remaining region, then clear the live area and blank the
    /// header, leaving the console reusable.
    ///
    /// Remaining regions are units that never finished; their peeked output is
    /// discarded (durable signal is emitted at finish time). Returns any I/O
    /// error from the terminal clear.
    pub fn clear(&mut self) -> std::io::Result<()> {
        for (_, region) in self.regions.drain() {
            region.bar.finish_and_clear();
            self.multi.remove(&region.bar);
        }
        self.header.set_message("");
        self.multi.clear()
    }

    /// The rows a region has retained for a failure replay (oldest first).
    #[cfg(test)]
    fn retained(&self, id: &str) -> Option<Vec<String>> {
        self.regions
            .get(id)
            .map(|region| region.retained.iter().cloned().collect())
    }
}

/// A bar style that renders only its message (no bar, timer, or spinner).
fn message_style() -> ProgressStyle {
    ProgressStyle::with_template("{msg}").unwrap_or_else(|_| ProgressStyle::default_spinner())
}

/// Render one tile as a labeled header line plus exactly `rows` indented
/// content lines (padded with blanks), each truncated to `cols` display
/// columns. As with [`truncate`], `cols == 0` disables truncation (used only by
/// tests; the live console always resolves `cols` to at least 1). The fixed
/// line count keeps every tile a constant height so the live area does not
/// reflow as streams emit output.
fn render_tile<S: AsRef<str>>(label: &str, lines: &[S], cols: usize, rows: usize) -> String {
    let header = format!("{}", console::style(format!("{label}")).bold());
    let mut out = truncate(&header, cols);
    for index in 0..rows {
        out.push('\n');
        let line = lines.get(index).map_or("", AsRef::as_ref);
        out.push_str(&truncate(&format!("{TILE_INDENT}{line}"), cols));
    }
    out
}

/// Prefix a scrolled-out line with its region label for scrollback attribution.
fn scrollback_line(label: &str, line: &str) -> String {
    format!("{} {line}", console::style(format!("{label}")).dim())
}

/// Build a failed region's replay body: its retained scrolled-off rows (oldest
/// first) followed by the rows still on screen, with trailing blank rows
/// trimmed. The caller label-prefixes each line for scrollback attribution.
fn replay_body(retained: &VecDeque<String>, on_screen: Vec<String>) -> Vec<String> {
    let mut lines: Vec<String> = retained.iter().cloned().collect();
    lines.extend(on_screen);
    while lines.last().is_some_and(String::is_empty) {
        lines.pop();
    }
    lines
}

/// Truncate `line` to `width` display columns (ANSI- and width-aware), leaving
/// it untouched when `width` is `0`.
fn truncate(line: &str, width: usize) -> String {
    if width == 0 {
        return line.to_string();
    }
    console::truncate_str(line, width, "").into_owned()
}

#[cfg(test)]
mod tests {
    use std::collections::VecDeque;

    use super::{LiveConfig, LiveConsole, render_tile, replay_body, scrollback_line, truncate};

    fn config(rows: usize, cols: usize) -> LiveConfig {
        LiveConfig {
            rows,
            cols,
            ..LiveConfig::default()
        }
    }

    #[test]
    fn content_cols_reserves_the_indent_and_never_underflows() {
        assert_eq!(config(6, 80).content_cols(), 78);
        // Degenerate widths clamp to a usable grid rather than underflowing.
        assert_eq!(config(6, 1).content_cols(), 1);
        assert_eq!(config(6, 0).content_cols(), 1);
    }

    #[test]
    fn drives_full_region_lifecycle_without_panicking() -> std::io::Result<()> {
        let mut console = LiveConsole::hidden(config(2, 40));
        console.set_header("wave 1/2 · running 1");
        console.begin("u1", "rust:core#test");
        console.feed("u1", b"compiling\r\n");
        console.feed("u1", b"running 3 tests\r\nok\r\nok\r\n");
        console.finish("u1", "ok rust:core#test")?;
        console.clear()
    }

    #[test]
    fn reused_id_replaces_region_without_leaking() -> std::io::Result<()> {
        let mut console = LiveConsole::hidden(LiveConfig::default());
        console.begin("u1", "first");
        console.feed("u1", b"old\n");
        console.begin("u1", "second");
        console.feed("u1", b"new\n");
        console.finish("u1", "ok")
    }

    #[test]
    fn console_is_reusable_after_clear() -> std::io::Result<()> {
        let mut console = LiveConsole::hidden(LiveConfig::default());
        console.set_header("first pass");
        console.begin("u1", "task");
        console.feed("u1", b"partial output\n");
        console.clear()?;
        console.set_header("second pass");
        console.begin("u1", "task");
        console.feed("u1", b"more\n");
        console.finish("u1", "ok")
    }

    #[test]
    fn zero_rows_still_renders_content() -> std::io::Result<()> {
        let mut console = LiveConsole::hidden(config(0, 40));
        console.begin("u1", "task");
        console.feed("u1", b"visible line\r\n");
        console.finish("u1", "ok")
    }

    #[test]
    fn feed_and_finish_for_unknown_region_are_ignored() -> std::io::Result<()> {
        let mut console = LiveConsole::hidden(LiveConfig::default());
        console.feed("ghost", b"noise\n");
        console.finish("ghost", "done")
    }

    #[test]
    fn note_prints_to_scrollback_without_a_region() -> std::io::Result<()> {
        let console = LiveConsole::hidden(LiveConfig::default());
        console.note("standalone line")
    }

    #[test]
    fn scrolled_rows_are_retained_for_replay_not_lost() {
        let mut console = LiveConsole::hidden(LiveConfig {
            rows: 2,
            cols: 20,
            scrollback: 200,
        });
        console.begin("u1", "task");
        console.feed("u1", b"l1\nl2\nl3\nl4\n");
        // Only the last row stays on screen; the earlier rows scrolled off but
        // are retained for a possible failure replay.
        assert_eq!(
            console.retained("u1"),
            Some(vec!["l1".into(), "l2".into(), "l3".into()])
        );
    }

    #[test]
    fn retention_ring_is_bounded_under_a_long_feed() {
        let mut console = LiveConsole::hidden(LiveConfig {
            rows: 2,
            cols: 20,
            scrollback: 3,
        });
        console.begin("u1", "task");
        for _ in 0..50 {
            console.feed("u1", b"line\n");
        }
        assert!(console.retained("u1").is_some_and(|rows| rows.len() <= 3));
    }

    #[test]
    fn replay_body_orders_retained_then_on_screen_and_trims_blanks() {
        let retained = VecDeque::from(vec!["a".to_string(), "b".to_string()]);
        let on_screen = vec!["c".to_string(), "d".to_string(), String::new()];
        assert_eq!(replay_body(&retained, on_screen), vec!["a", "b", "c", "d"]);
    }

    #[test]
    fn failure_replay_and_success_finish_both_drop_the_region() -> std::io::Result<()> {
        let mut console = LiveConsole::hidden(config(2, 20));
        console.begin("ok", "task");
        console.feed("ok", b"noise\n");
        console.finish("ok", "ok task")?;
        assert!(console.retained("ok").is_none());

        console.begin("bad", "task");
        console.feed("bad", b"panic!\n");
        console.finish_with_replay("bad", "failed task")?;
        assert!(console.retained("bad").is_none());
        Ok(())
    }

    #[test]
    fn render_tile_labels_and_indents_lines() {
        let tile = render_tile("core", &["a", "b"], 0, 2);
        let stripped = console::strip_ansi_codes(&tile);
        assert_eq!(stripped, "• core\n  a\n  b");
    }

    #[test]
    fn render_tile_pads_to_fixed_height() {
        let tile = render_tile("core", &["only"], 0, 3);
        let stripped = console::strip_ansi_codes(&tile);
        assert_eq!(stripped, "• core\n  only\n  \n  ");
    }

    #[test]
    fn render_tile_truncates_header_and_content_to_width() {
        let tile = render_tile("a-very-long-label", &["a-very-long-content-line"], 8, 1);
        for line in console::strip_ansi_codes(&tile).lines() {
            assert!(console::measure_text_width(line) <= 8);
        }
    }

    #[test]
    fn scrollback_line_prefixes_with_label() {
        let line = scrollback_line("core", "hello");
        let stripped = console::strip_ansi_codes(&line);
        assert_eq!(stripped, "core │ hello");
    }

    #[test]
    fn replay_line_clamped_to_width_never_wraps() {
        // A content row is sized to the tile's content area, but the scrollback
        // replay prefixes it with the full unit label — the composed line must be
        // clamped to the console width so it never wraps into an orphan fragment.
        let content = "x".repeat(200);
        let composed = scrollback_line("rust@rust:core#test", &content);
        let clamped = truncate(&composed, 140);
        assert!(console::measure_text_width(&clamped) <= 140);
    }

    #[test]
    fn truncate_respects_width_and_passthrough() {
        assert_eq!(truncate("hello world", 0), "hello world");
        let cut = truncate("hello world", 5);
        assert!(console::measure_text_width(&cut) <= 5);
    }
}