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