Skip to main content

scope_cli/cli/
output.rs

1//! # Command Output
2//!
3//! The writers that command handlers print through. The CLI binds them to
4//! stdout and stderr. The TUI and the tests bind them to in-memory buffers,
5//! so the same handler can run in both places without printing over the
6//! terminal UI.
7//!
8//! ## The one policy
9//!
10//! | What | Macro | Channel | A failed write |
11//! |---|---|---|---|
12//! | Data (the command's result) | `outln!`, `out!` | out | is the command's error |
13//! | Error reports, only in `errors.rs` and `tui/exec.rs` (a TUI session command's error) | `errln!` | err | is the command's error |
14//! | A prompt's question, only in `prompt.rs` | `err!` | err | is the prompt's error |
15//! | The macros above, defined in `output.rs` | `write_err` | err | is returned |
16//! | Everything else: status, notices, warnings, "saved to" | [`note!`](crate::note), [`Output::note_saved`] | err | is dropped |
17//!
18//! Data goes to `out`, so piped JSON and CSV stay clean. A note ("Fetching
19//! …", "Selected: …", "⚠ … unavailable") is best effort: when it cannot be
20//! written, it must not take the place of the command's own result, and
21//! it does not change the exit code. Library code in `scope` (core) has the
22//! same kind of best-effort line: `scope::diag::notice`.
23//!
24//! The guard tests (`tests/one_output_path.rs`) keep `errln!`, `err!` and
25//! `write_err` in the files this table names (they read the table), and
26//! status text off `outln!` and `out!`.
27//!
28//! ```rust,ignore
29//! use scope_cli::cli::output::Output;
30//! use scope_cli::{note, outln};
31//!
32//! let out = Output::stdio();
33//! outln!(out, "Balance: {}", 42)?;    // data → stdout
34//! note!(out, "Fetching balances..."); // status → stderr
35//! ```
36
37use scope::market::series::{Metric, Sample};
38use std::collections::VecDeque;
39use std::fmt;
40use std::io::{self, Write};
41use std::sync::atomic::{AtomicBool, Ordering};
42use std::sync::{Arc, Mutex};
43use std::time::Instant;
44
45/// A shared, thread-safe writer.
46type Sink = Arc<Mutex<dyn Write + Send>>;
47
48/// Where a repeating command is, for a screen that draws it.
49///
50/// It is not output. It never reaches the pane text, the copy or the
51/// journal. Only the command writes it; the TUI reads it.
52#[derive(Debug, Clone, Default)]
53pub struct RunProgress {
54    /// The run that is in progress or just ended. 0 before the first.
55    pub run: u64,
56    /// How many runs the command will make, when it is known.
57    pub total: Option<u64>,
58    /// When the next run starts. None while a run is in progress.
59    pub next_at: Option<Instant>,
60}
61
62/// A progress record shared between a command and the screen.
63pub type ProgressHook = Arc<Mutex<RunProgress>>;
64
65/// One run of a repeating command, for a screen that charts it (#58).
66#[derive(Debug, Clone)]
67pub struct RunRecord {
68    /// The run number, from 1.
69    pub run: u64,
70    /// When the run ended.
71    pub at: chrono::DateTime<chrono::Utc>,
72    /// The run's key values, in the order of [`Series::metrics`].
73    pub sample: Sample,
74    /// The report the run printed in the CLI.
75    pub snapshot: String,
76}
77
78/// The runs a screen keeps, oldest first, and the metric table they read in.
79#[derive(Debug, Default)]
80pub struct Series {
81    /// The metric table of every run's sample.
82    pub metrics: Vec<Metric>,
83    /// The newest [`MAX_SERIES_RUNS`] runs.
84    pub runs: VecDeque<RunRecord>,
85    /// How many older runs the ring dropped.
86    pub dropped: u64,
87}
88
89/// The series shared between a repeating command and the screen.
90pub type SeriesHook = Arc<Mutex<Series>>;
91
92/// The most runs a series keeps. Older runs are dropped.
93pub const MAX_SERIES_RUNS: usize = 500;
94
95/// The output channels for one command run.
96///
97/// Cloning is cheap: clones share the same writers.
98#[derive(Clone)]
99pub struct Output {
100    out: Sink,
101    err: Sink,
102    progress: bool,
103    hook: Option<ProgressHook>,
104    series: Option<SeriesHook>,
105    tui: bool,
106}
107
108impl Output {
109    /// Output bound to the process stdout and stderr, with progress
110    /// indicators enabled (they still hide themselves when stderr is not a TTY).
111    pub fn stdio() -> Self {
112        Self {
113            out: Arc::new(Mutex::new(io::stdout())),
114            err: Arc::new(Mutex::new(io::stderr())),
115            progress: true,
116            hook: None,
117            series: None,
118            tui: false,
119        }
120    }
121
122    /// Output bound to in-memory buffers, with progress indicators disabled.
123    ///
124    /// Returns the output and a handle that reads what was written.
125    pub fn capture() -> (Self, Captured) {
126        let out = Arc::new(Mutex::new(Vec::new()));
127        let err = Arc::new(Mutex::new(Vec::new()));
128        let captured = Captured {
129            data: out.clone(),
130            out: out.clone(),
131            err: err.clone(),
132            trimmed: Arc::default(),
133        };
134        let output = Self {
135            out,
136            err,
137            progress: false,
138            hook: None,
139            series: None,
140            tui: false,
141        };
142        (output, captured)
143    }
144
145    /// Output whose two channels write to one in-memory buffer, in the order
146    /// of the writes, with progress indicators disabled.
147    ///
148    /// The TUI uses it: its output pane shows data and diagnostics as one
149    /// stream, as a terminal does. [`Captured::out`] returns the whole
150    /// stream and [`Captured::err`] returns the same text.
151    /// [`Captured::data`] returns only what went to the data channel.
152    pub fn capture_merged() -> (Self, Captured) {
153        let buf = Arc::new(Mutex::new(Vec::new()));
154        let data = Arc::new(Mutex::new(Vec::new()));
155        let captured = Captured {
156            out: buf.clone(),
157            err: buf.clone(),
158            data: data.clone(),
159            trimmed: Arc::default(),
160        };
161        let output = Self {
162            out: Arc::new(Mutex::new(Tee {
163                merged: buf.clone(),
164                data,
165            })),
166            err: buf,
167            progress: false,
168            hook: Some(ProgressHook::default()),
169            series: Some(SeriesHook::default()),
170            tui: true,
171        };
172        (output, captured)
173    }
174
175    /// Output that still writes to this output's writers and also keeps a
176    /// copy of the text, as [`Output::capture_merged`] does.
177    ///
178    /// The TUI uses it for a command that runs outside it: the user sees
179    /// the text live on the terminal, and the TUI shows the copy in its
180    /// scrollback after it returns. Progress indicators stay as they are
181    /// here (they draw on the real terminal and are not copied). The copy
182    /// keeps at most about `limit` bytes, the end of the text; whole lines
183    /// are dropped from its start. [`Captured::trimmed`] says when that
184    /// happened.
185    ///
186    /// The result is a TUI output ([`Output::is_tui`]): its hints are read
187    /// in the TUI scrollback.
188    pub fn teed(&self, limit: usize) -> (Self, Captured) {
189        let buf = Arc::new(Mutex::new(Vec::new()));
190        let data = Arc::new(Mutex::new(Vec::new()));
191        let trimmed = Arc::new(AtomicBool::new(false));
192        let captured = Captured {
193            out: buf.clone(),
194            err: buf.clone(),
195            data: data.clone(),
196            trimmed: trimmed.clone(),
197        };
198        let copy = |real: &Sink, data: Option<Arc<Mutex<Vec<u8>>>>| -> Sink {
199            Arc::new(Mutex::new(TeeSink {
200                real: real.clone(),
201                merged: buf.clone(),
202                data,
203                limit,
204                trimmed: trimmed.clone(),
205            }))
206        };
207        let output = Self {
208            out: copy(&self.out, Some(data.clone())),
209            err: copy(&self.err, None),
210            progress: self.progress,
211            hook: None,
212            series: None,
213            tui: true,
214        };
215        (output, captured)
216    }
217
218    /// Output bound to the given writers, with progress indicators disabled.
219    #[cfg(test)]
220    pub(crate) fn from_writers(
221        out: impl Write + Send + 'static,
222        err: impl Write + Send + 'static,
223    ) -> Self {
224        Self {
225            out: Arc::new(Mutex::new(out)),
226            err: Arc::new(Mutex::new(err)),
227            progress: false,
228            hook: None,
229            series: None,
230            tui: false,
231        }
232    }
233
234    /// True when animated progress indicators may draw on the terminal.
235    pub fn progress_enabled(&self) -> bool {
236        self.progress
237    }
238
239    /// True when this output belongs to the TUI. Error text then gives TUI
240    /// next steps, not CLI flags.
241    pub fn is_tui(&self) -> bool {
242        self.tui
243    }
244
245    /// The progress record of this run, when the output has one. The TUI's
246    /// output has one; the plain CLI output does not.
247    pub fn progress_hook(&self) -> Option<ProgressHook> {
248        self.hook.clone()
249    }
250
251    /// The series of a repeating run, when the output has one: the TUI's
252    /// pane output has one; the plain CLI output does not.
253    pub fn series_hook(&self) -> Option<SeriesHook> {
254        self.series.clone()
255    }
256
257    /// Hands one run to the screen's series. Returns false when there is no
258    /// series (the CLI), so the caller prints the run instead.
259    pub fn record_run(&self, metrics: &[Metric], record: RunRecord) -> bool {
260        let Some(series) = &self.series else {
261            return false;
262        };
263        let mut series = lock(series);
264        if series.metrics.is_empty() {
265            series.metrics = metrics.to_vec();
266        }
267        series.runs.push_back(record);
268        while series.runs.len() > MAX_SERIES_RUNS {
269            series.runs.pop_front();
270            series.dropped += 1;
271        }
272        true
273    }
274
275    /// Changes the progress record. Does nothing when there is no hook.
276    pub fn update_progress(&self, change: impl FnOnce(&mut RunProgress)) {
277        if let Some(hook) = &self.hook {
278            change(&mut lock(hook));
279        }
280    }
281
282    /// Writes formatted data to the data channel. Use [`outln!`](crate::outln) / [`out!`](crate::out).
283    ///
284    /// # Errors
285    ///
286    /// Returns the writer's error, for example `BrokenPipe` when a reader
287    /// closes the pipe early or a storage error when stdout is a full disk.
288    pub fn write_out(&self, args: fmt::Arguments<'_>) -> io::Result<()> {
289        emit(&self.out, args)
290    }
291
292    /// Writes formatted text to the diagnostic channel. Use [`errln!`](crate::errln) / [`err!`](crate::err).
293    ///
294    /// # Errors
295    ///
296    /// Returns the writer's error.
297    pub fn write_err(&self, args: fmt::Arguments<'_>) -> io::Result<()> {
298        emit(&self.err, args)
299    }
300
301    /// Writes a status line to the diagnostic channel. Use
302    /// [`note!`](crate::note). A failed write is dropped: see the module
303    /// policy.
304    pub fn note(&self, args: fmt::Arguments<'_>) {
305        let _ = emit(&self.err, args);
306    }
307
308    /// The one "saved to" line: `<what> saved to: <where>`, as a note.
309    pub fn note_saved(&self, what: &str, path: impl fmt::Display) {
310        crate::note!(self, "{what} saved to: {path}");
311    }
312}
313
314/// Reads back what an [`Output::capture`] output received.
315#[derive(Clone)]
316pub struct Captured {
317    /// Only the data channel. Same buffer as `out` unless merged.
318    data: Arc<Mutex<Vec<u8>>>,
319    out: Arc<Mutex<Vec<u8>>>,
320    err: Arc<Mutex<Vec<u8>>>,
321    /// Set when a bounded copy ([`Output::teed`]) dropped text.
322    trimmed: Arc<AtomicBool>,
323}
324
325impl Captured {
326    /// True when the copy dropped the start of the text to stay in its
327    /// limit. Only an [`Output::teed`] copy has a limit.
328    pub fn trimmed(&self) -> bool {
329        self.trimmed.load(Ordering::Relaxed)
330    }
331
332    /// Everything written to the data channel so far.
333    pub fn out(&self) -> String {
334        String::from_utf8_lossy(&lock(&self.out)).into_owned()
335    }
336
337    /// Only what was written to the data channel, without the diagnostics
338    /// that a merged capture mixes in.
339    pub fn data(&self) -> String {
340        String::from_utf8_lossy(&lock(&self.data)).into_owned()
341    }
342
343    /// Everything written to the diagnostic channel so far.
344    pub fn err(&self) -> String {
345        String::from_utf8_lossy(&lock(&self.err)).into_owned()
346    }
347
348    /// The buffer behind the data channel, as a writer. The TUI gives it to
349    /// [`scope::diag::redirect`] so library warnings join the same stream.
350    pub fn out_sink(&self) -> scope::diag::Sink {
351        self.out.clone()
352    }
353}
354
355/// The data channel of a merged capture: writes to the merged stream and
356/// to a data-only buffer.
357struct Tee {
358    merged: Arc<Mutex<Vec<u8>>>,
359    data: Arc<Mutex<Vec<u8>>>,
360}
361
362impl Write for Tee {
363    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
364        lock(&self.merged).extend_from_slice(buf);
365        lock(&self.data).extend_from_slice(buf);
366        Ok(buf.len())
367    }
368    fn flush(&mut self) -> io::Result<()> {
369        Ok(())
370    }
371}
372
373/// One channel of an [`Output::teed`] output: keeps a bounded copy, then
374/// writes to the real writer.
375struct TeeSink {
376    real: Sink,
377    merged: Arc<Mutex<Vec<u8>>>,
378    /// The data-only copy; `None` on the diagnostic channel.
379    data: Option<Arc<Mutex<Vec<u8>>>>,
380    limit: usize,
381    trimmed: Arc<AtomicBool>,
382}
383
384impl Write for TeeSink {
385    /// Copies first, so the TUI still shows text that the terminal could
386    /// not take; then returns the real writer's result.
387    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
388        for copy in std::iter::once(&self.merged).chain(self.data.as_ref()) {
389            let mut copy = lock(copy);
390            copy.extend_from_slice(buf);
391            if keep_tail(&mut copy, self.limit) {
392                self.trimmed.store(true, Ordering::Relaxed);
393            }
394        }
395        lock(&self.real).write_all(buf)?;
396        Ok(buf.len())
397    }
398    fn flush(&mut self) -> io::Result<()> {
399        lock(&self.real).flush()
400    }
401}
402
403/// Drops text from the start of `buf` when it is longer than `limit`,
404/// down to about half of it, so a long run trims rarely. It cuts after a
405/// newline in the half it keeps; with no newline there, it keeps the end
406/// of the line, cut where no character or escape sequence is split.
407/// Returns true when it dropped text.
408fn keep_tail(buf: &mut Vec<u8>, limit: usize) -> bool {
409    if buf.len() <= limit {
410        return false;
411    }
412    let cut = buf.len() - limit / 2;
413    let start = buf[cut..]
414        .iter()
415        .position(|&b| b == b'\n')
416        .map(|i| cut + i + 1)
417        .filter(|&start| start < buf.len())
418        .unwrap_or_else(|| whole_cut(buf, cut));
419    buf.drain(..start);
420    true
421}
422
423/// The first index at or after `cut` that is inside neither an escape
424/// sequence ([`crate::cli::ansi`]) nor a UTF-8 character. It walks from
425/// the start, so it knows where each sequence begins.
426fn whole_cut(buf: &[u8], cut: usize) -> usize {
427    let mut i = 0;
428    while i < cut {
429        i = crate::cli::ansi::escape_end(buf, i).unwrap_or(i + 1);
430    }
431    let continuation = |b: u8| b & 0b1100_0000 == 0b1000_0000;
432    buf[i.min(buf.len())..]
433        .iter()
434        .position(|&b| !continuation(b))
435        .map_or(buf.len(), |n| i + n)
436}
437
438/// Locks a mutex, recovering the data if a panicking writer poisoned it.
439/// Output must keep working after an unrelated panic on another thread.
440fn lock<T: ?Sized>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
441    m.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
442}
443
444/// Writes one formatted message to a sink and returns the write error.
445/// Data and error reports pass it on, so a command whose output did not
446/// arrive (closed pipe, full disk) does not report success; a note drops
447/// it (see the module policy).
448fn emit(sink: &Mutex<dyn Write + Send>, args: fmt::Arguments<'_>) -> io::Result<()> {
449    lock(sink).write_fmt(args)
450}
451
452/// Prints to the data channel of an [`Output`], with a newline.
453#[macro_export]
454macro_rules! outln {
455    ($o:expr) => {
456        $o.write_out(format_args!("\n"))
457    };
458    ($o:expr, $($arg:tt)*) => {
459        $o.write_out(format_args!("{}\n", format_args!($($arg)*)))
460    };
461}
462
463/// Prints to the data channel of an [`Output`], without a newline.
464#[macro_export]
465macro_rules! out {
466    ($o:expr, $($arg:tt)*) => {
467        $o.write_out(format_args!($($arg)*))
468    };
469}
470
471/// Prints to the diagnostic channel of an [`Output`], with a newline.
472#[macro_export]
473macro_rules! errln {
474    ($o:expr) => {
475        $o.write_err(format_args!("\n"))
476    };
477    ($o:expr, $($arg:tt)*) => {
478        $o.write_err(format_args!("{}\n", format_args!($($arg)*)))
479    };
480}
481
482/// Writes a status line (a note) to the diagnostic channel of an
483/// [`Output`], with a newline. Best effort: it returns nothing.
484#[macro_export]
485macro_rules! note {
486    ($o:expr) => {
487        $o.note(format_args!("\n"))
488    };
489    ($o:expr, $($arg:tt)*) => {
490        $o.note(format_args!("{}\n", format_args!($($arg)*)))
491    };
492}
493
494/// Prints to the diagnostic channel of an [`Output`], without a newline.
495#[macro_export]
496macro_rules! err {
497    ($o:expr, $($arg:tt)*) => {
498        $o.write_err(format_args!($($arg)*))
499    };
500}
501
502// ============================================================================
503// Unit Tests
504// ============================================================================
505
506#[cfg(test)]
507pub(crate) mod tests {
508    use super::*;
509
510    #[test]
511    fn test_capture_separates_data_from_diagnostics() {
512        // Piped JSON must not contain warnings: the two channels stay apart.
513        let (o, cap) = Output::capture();
514        outln!(o, "{{\"ok\":{}}}", true).unwrap();
515        errln!(o, "Warning: {}", "partial data").unwrap();
516        assert_eq!(cap.out(), "{\"ok\":true}\n");
517        assert_eq!(cap.err(), "Warning: partial data\n");
518    }
519
520    #[test]
521    fn test_a_note_goes_to_the_diagnostic_channel_and_never_fails() {
522        // A status line must not end a command whose real work went well,
523        // and must stay out of data.
524        let (o, cap) = Output::capture();
525        note!(o, "Fetching {}...", "USDC");
526        assert_eq!(cap.err(), "Fetching USDC...\n");
527        assert_eq!(cap.out(), "");
528        let closed = Output::from_writers(Vec::new(), ClosedPipe);
529        note!(closed, "dropped");
530    }
531
532    #[test]
533    fn test_note_saved_is_one_line_on_the_diagnostic_channel() {
534        let (o, cap) = Output::capture();
535        o.note_saved("Report", "r.md");
536        assert_eq!(cap.err(), "Report saved to: r.md\n");
537        assert_eq!(cap.out(), "");
538    }
539
540    #[test]
541    fn test_clones_share_writers() {
542        // Helpers receive clones; their output must land in the same buffer.
543        let (o, cap) = Output::capture();
544        let o2 = o.clone();
545        out!(o, "a").unwrap();
546        out!(o2, "b").unwrap();
547        outln!(o).unwrap();
548        assert_eq!(cap.out(), "ab\n");
549    }
550
551    /// A writer that fails like stdout does after `| head -1` closes the pipe.
552    pub(crate) struct ClosedPipe;
553
554    impl Write for ClosedPipe {
555        fn write(&mut self, _: &[u8]) -> io::Result<usize> {
556            Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed"))
557        }
558        fn flush(&mut self) -> io::Result<()> {
559            Ok(())
560        }
561    }
562
563    #[test]
564    fn test_merged_capture_keeps_data_apart_from_diagnostics() {
565        let (o, cap) = Output::capture_merged();
566        outln!(o, "data").unwrap();
567        errln!(o, "warn").unwrap();
568        assert_eq!(cap.data(), "data\n");
569        assert_eq!(cap.out(), "data\nwarn\n");
570    }
571
572    #[test]
573    fn test_failed_write_is_returned_not_dropped() {
574        // A command whose output did not arrive must not report success.
575        let o = Output::from_writers(ClosedPipe, ClosedPipe);
576        let e = outln!(o, "data").unwrap_err();
577        assert_eq!(e.kind(), io::ErrorKind::BrokenPipe);
578        assert_eq!(
579            errln!(o, "warn").unwrap_err().kind(),
580            io::ErrorKind::BrokenPipe
581        );
582    }
583
584    #[test]
585    fn test_only_the_tui_capture_is_tui() {
586        assert!(Output::capture_merged().0.is_tui());
587        assert!(!Output::capture().0.is_tui());
588        assert!(!Output::stdio().is_tui());
589        assert!(Output::stdio().teed(1).0.is_tui());
590    }
591
592    #[test]
593    fn test_capture_merged_keeps_write_order() {
594        // The TUI pane must show a warning where it happened, between the
595        // data lines, not after all of them.
596        let (o, cap) = Output::capture_merged();
597        outln!(o, "row 1").unwrap();
598        errln!(o, "warning").unwrap();
599        outln!(o, "row 2").unwrap();
600        assert_eq!(cap.out(), "row 1\nwarning\nrow 2\n");
601    }
602
603    #[test]
604    fn test_teed_output_reaches_the_terminal_and_keeps_a_copy() {
605        // Outside the TUI the user reads the text live; the TUI shows the
606        // copy when it returns, and Ctrl-Y gets only the data.
607        let (terminal, screen) = Output::capture();
608        let (o, copy) = terminal.teed(1024);
609        outln!(o, "row").unwrap();
610        errln!(o, "warn").unwrap();
611        assert_eq!(screen.out(), "row\n");
612        assert_eq!(screen.err(), "warn\n");
613        assert_eq!(copy.out(), "row\nwarn\n");
614        assert_eq!(copy.data(), "row\n");
615        assert!(!copy.trimmed());
616        // The copy is read in the TUI: TUI wording for hints. Progress
617        // bars still draw on the real terminal as before.
618        assert!(o.is_tui());
619        assert_eq!(o.progress_enabled(), terminal.progress_enabled());
620        assert!(Output::stdio().teed(1).0.progress_enabled());
621        assert!(o.progress_hook().is_none());
622    }
623
624    #[test]
625    fn test_teed_copy_keeps_whole_lines_from_the_end_within_its_limit() {
626        // A long crawl must not grow the copy without bound.
627        let (terminal, screen) = Output::capture();
628        let (o, copy) = terminal.teed(100);
629        for i in 0..1000 {
630            outln!(o, "line {i:04}").unwrap();
631        }
632        assert!(copy.trimmed());
633        let kept = copy.out();
634        assert!(kept.len() <= 100, "{}", kept.len());
635        assert!(kept.starts_with("line "), "whole lines: {kept:?}");
636        assert!(kept.ends_with("line 0999\n"), "{kept:?}");
637        assert!(copy.data().ends_with("line 0999\n"));
638        assert_eq!(screen.out().lines().count(), 1000, "the terminal got all");
639    }
640
641    #[test]
642    fn test_teed_copy_keeps_the_tail_of_one_long_line() {
643        // A progress line or a JSON blob can be one write with no newline
644        // in its end half. The copy must keep its end, not drop it all.
645        let (terminal, _screen) = Output::capture();
646        let (o, copy) = terminal.teed(100);
647        let blob: String = (0..300)
648            .map(|i| char::from(b'a' + (i % 26) as u8))
649            .collect();
650        out!(o, "{blob}").unwrap();
651        assert!(copy.trimmed());
652        let kept = copy.out();
653        assert!(!kept.is_empty() && kept.len() <= 100, "{}", kept.len());
654        assert!(blob.ends_with(&kept), "the end of the line: {kept:?}");
655    }
656
657    #[test]
658    fn test_trim_of_one_line_splits_no_character_and_no_escape() {
659        // The cut lands in the middle of a two-byte character, then in
660        // the middle of a color sequence. Either would show as garbage.
661        let mut chars = "é".repeat(60).into_bytes(); // 120 bytes
662        assert!(keep_tail(&mut chars, 102)); // cuts at 120 - 51 = 69, inside an é
663        assert!(String::from_utf8(chars.clone()).is_ok(), "{chars:?}");
664        assert_eq!(chars.len(), 50);
665        let mut colored = b"x".repeat(60);
666        colored.extend_from_slice(b"\x1b[38;5;196mred");
667        // 74 bytes; limit 20 cuts at 64, inside the sequence at 60..71.
668        assert!(keep_tail(&mut colored, 20));
669        assert_eq!(colored, b"red");
670        let mut link = b"y".repeat(60);
671        link.extend_from_slice(b"\x1b]8;;https://example.com/a/long/path\x1b\\text");
672        // The OSC string runs from 60 to 98; a cut at 80 is inside it.
673        let cut_at = link.len() - 40 / 2;
674        assert!(cut_at > 60 && cut_at < 98, "{cut_at}");
675        assert!(keep_tail(&mut link, 40));
676        assert_eq!(link, b"text");
677    }
678
679    #[test]
680    fn test_teed_copy_keeps_text_the_terminal_refused() {
681        // A broken terminal must not lose the text the TUI can still show.
682        let (o, copy) = Output::from_writers(ClosedPipe, ClosedPipe).teed(64);
683        assert!(outln!(o, "data").is_err());
684        assert_eq!(copy.out(), "data\n");
685    }
686
687    #[test]
688    fn test_capture_disables_progress() {
689        // Animated spinners would draw over a TUI screen.
690        let (o, _) = Output::capture();
691        assert!(!o.progress_enabled());
692        assert!(Output::stdio().progress_enabled());
693    }
694}