nextest_runner/
pager.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Pager support for nextest output.
5//!
6//! This module provides functionality to page output through an external pager
7//! (like `less`) or a builtin pager (streampager) when appropriate. Paging is
8//! useful for commands that produce long output, such as `nextest list`.
9
10use crate::{
11    user_config::elements::{PagerSetting, PaginateSetting, StreampagerConfig},
12    write_str::WriteStr,
13};
14use std::{
15    io::{self, IsTerminal, PipeWriter, Stdout, Write},
16    process::{Child, ChildStdin, Stdio},
17    thread::{self, JoinHandle},
18};
19use tracing::warn;
20
21/// Output wrapper that optionally pages output through a pager.
22///
23/// When a pager is active, output is piped to the pager process (external) or
24/// thread (builtin). When finalized, the pipe is closed and we wait for the
25/// pager to exit.
26///
27/// Implements [`Drop`] to ensure cleanup happens even if `finalize` is not
28/// called explicitly. During a panic, pipes are closed but we skip waiting for
29/// the pager to avoid potential double-panic.
30///
31/// [`finalize`]: Self::finalize
32pub enum PagedOutput {
33    /// Direct output to terminal (no paging).
34    Terminal {
35        /// Standard output handle.
36        stdout: Stdout,
37    },
38    /// Output through an external pager process.
39    ExternalPager {
40        /// The pager child process.
41        child: Child,
42        /// Stdin pipe to the pager (for writing output).
43        ///
44        /// This is an `Option` to allow taking ownership in [`Drop`] and
45        /// [`finalize`](Self::finalize).
46        child_stdin: Option<ChildStdin>,
47    },
48    /// Output through the builtin streampager.
49    BuiltinPager {
50        /// Pipe writer for stdout (for writing output).
51        ///
52        /// This is an `Option` to allow taking ownership in [`Drop`] and
53        /// [`finalize`](Self::finalize).
54        out_writer: Option<PipeWriter>,
55        /// The pager thread handle.
56        ///
57        /// This is an `Option` to allow taking ownership in `finalize`.
58        pager_thread: Option<JoinHandle<streampager::Result<()>>>,
59    },
60}
61
62impl PagedOutput {
63    /// Creates a new terminal output (no paging).
64    pub fn terminal() -> Self {
65        Self::Terminal {
66            stdout: io::stdout(),
67        }
68    }
69
70    /// Attempts to spawn a pager if conditions are met.
71    ///
72    /// Returns `Terminal` output if:
73    /// - `paginate` is `Never`
74    /// - stdout is not a TTY
75    /// - the pager command fails to spawn
76    ///
77    /// On pager spawn failure, a warning is logged and terminal output is
78    /// returned.
79    pub fn request_pager(
80        pager: &PagerSetting,
81        paginate: PaginateSetting,
82        streampager_config: &StreampagerConfig,
83    ) -> Self {
84        // Check if paging is disabled.
85        if matches!(paginate, PaginateSetting::Never) {
86            return Self::terminal();
87        }
88
89        // Check if stdout is a TTY.
90        if !io::stdout().is_terminal() {
91            return Self::terminal();
92        }
93
94        match pager {
95            PagerSetting::Builtin => Self::spawn_builtin_pager(streampager_config),
96            PagerSetting::External(command_and_args) => {
97                // Try to spawn the external pager.
98                let mut cmd = command_and_args.to_command();
99                cmd.stdin(Stdio::piped());
100
101                match cmd.spawn() {
102                    Ok(mut child) => {
103                        let child_stdin = child
104                            .stdin
105                            .take()
106                            .expect("child stdin should be present when piped");
107                        Self::ExternalPager {
108                            child,
109                            child_stdin: Some(child_stdin),
110                        }
111                    }
112                    Err(error) => {
113                        warn!(
114                            "failed to spawn pager '{}': {error}",
115                            command_and_args.command_name()
116                        );
117                        Self::terminal()
118                    }
119                }
120            }
121        }
122    }
123
124    /// Spawns the builtin streampager.
125    fn spawn_builtin_pager(config: &StreampagerConfig) -> Self {
126        let streampager_config = streampager::config::Config {
127            wrapping_mode: config.streampager_wrapping_mode(),
128            interface_mode: config.streampager_interface_mode(),
129            show_ruler: config.show_ruler,
130            // Don't scroll past EOF - it can leave empty lines on screen after
131            // exiting with quit-if-one-page mode.
132            scroll_past_eof: false,
133            ..Default::default()
134        };
135
136        // Initialize with tty instead of stdin/stdout. We spawn pager so long
137        // as stdout is a tty, which means stdin may be redirected.
138        let pager_result = streampager::Pager::new_using_system_terminal_with_config(
139            streampager_config,
140        )
141        .and_then(|mut pager| {
142            // Create a pipe for stdout.
143            let (out_reader, out_writer) = io::pipe()?;
144            pager.add_stream(out_reader, "")?;
145            Ok((pager, out_writer))
146        });
147
148        match pager_result {
149            Ok((pager, out_writer)) => Self::BuiltinPager {
150                out_writer: Some(out_writer),
151                pager_thread: Some(thread::spawn(|| pager.run())),
152            },
153            Err(error) => {
154                warn!("failed to set up builtin pager: {error}");
155                Self::terminal()
156            }
157        }
158    }
159
160    /// Returns true if output will be displayed interactively.
161    ///
162    /// This is used to determine whether to use human-readable formatting
163    /// (interactive) or machine-friendly oneline formatting (piped).
164    ///
165    /// - For terminal output: returns whether stdout is a TTY.
166    /// - For paged output: always returns true, since the pager displays
167    ///   output interactively to the user.
168    pub fn is_interactive(&self) -> bool {
169        match self {
170            Self::Terminal { stdout, .. } => stdout.is_terminal(),
171            // Paged output is always interactive - the user sees it in a
172            // terminal via the pager.
173            Self::ExternalPager { .. } | Self::BuiltinPager { .. } => true,
174        }
175    }
176
177    /// Finalizes the pager output.
178    ///
179    /// For terminal output, this is a no-op.
180    /// For paged output, this closes the pipe and waits for the pager
181    /// process/thread to exit. Errors during wait are logged but not propagated.
182    ///
183    /// This method is also called by [`Drop`], so explicit calls are optional
184    /// but recommended for clarity.
185    pub fn finalize(mut self) {
186        self.finalize_inner();
187    }
188
189    // ---
190    // Helper methods
191    // ---
192
193    // This is not made public: we want everyone to go through WriteStr, which
194    // squelches BrokenPipe errors.
195    fn stdout(&mut self) -> &mut dyn Write {
196        match self {
197            Self::Terminal { stdout, .. } => stdout,
198            Self::ExternalPager { child_stdin, .. } => child_stdin
199                .as_mut()
200                .expect("stdout should not be called after finalize"),
201            Self::BuiltinPager { out_writer, .. } => out_writer
202                .as_mut()
203                .expect("stdout should not be called after finalize"),
204        }
205    }
206
207    fn finalize_inner(&mut self) {
208        match self {
209            Self::Terminal { .. } => {
210                // Nothing to do.
211            }
212            Self::ExternalPager { child, child_stdin } => {
213                // If stdin is already taken, we've already finalized.
214                let Some(stdin) = child_stdin.take() else {
215                    return;
216                };
217
218                // Close stdin to signal EOF to the pager.
219                drop(stdin);
220
221                // Wait for the pager to exit. (Ignore broken pipes -- they're
222                // expected with less.)
223                if let Err(error) = child.wait()
224                    && error.kind() != io::ErrorKind::BrokenPipe
225                {
226                    warn!("failed to wait on pager: {error}");
227                }
228                // Note: We intentionally ignore the exit status from the child process. The pager may
229                // exit with a non-zero status if the user quits early (e.g.,
230                // pressing 'q' in less), which is normal behavior.
231            }
232            Self::BuiltinPager {
233                out_writer,
234                pager_thread,
235            } => {
236                // If writer is already taken, we've already finalized.
237                let Some(writer) = out_writer.take() else {
238                    return;
239                };
240
241                // Close the pipe to signal EOF to the pager.
242                drop(writer);
243
244                // Wait for the pager thread to exit.
245                if let Some(thread) = pager_thread.take() {
246                    match thread.join() {
247                        Ok(Ok(())) => {}
248                        Ok(Err(error)) => {
249                            warn!("failed to run builtin pager: {error}");
250                        }
251                        Err(_) => {
252                            warn!("builtin pager thread panicked");
253                        }
254                    }
255                }
256            }
257        }
258    }
259}
260
261impl Drop for PagedOutput {
262    fn drop(&mut self) {
263        if std::thread::panicking() {
264            // During a panic, close pipes to signal EOF but don't wait for the
265            // pager. This avoids potential issues if wait()/join() were to panic.
266            match self {
267                Self::Terminal { .. } => {}
268                Self::ExternalPager { child_stdin, .. } => {
269                    drop(child_stdin.take());
270                }
271                Self::BuiltinPager { out_writer, .. } => {
272                    drop(out_writer.take());
273                }
274            }
275            return;
276        }
277        self.finalize_inner();
278    }
279}
280
281impl WriteStr for PagedOutput {
282    fn write_str(&mut self, s: &str) -> io::Result<()> {
283        squelch_broken_pipe(self.stdout().write_all(s.as_bytes()))
284    }
285
286    fn write_str_flush(&mut self) -> io::Result<()> {
287        squelch_broken_pipe(self.stdout().flush())
288    }
289}
290
291fn squelch_broken_pipe(res: io::Result<()>) -> io::Result<()> {
292    match res {
293        Ok(()) => Ok(()),
294        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
295        Err(e) => Err(e),
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::user_config::elements::{StreampagerInterface, StreampagerWrapping};
303
304    #[test]
305    fn test_terminal_output() {
306        let mut output = PagedOutput::terminal();
307        // Just verify we can get a writer.
308        let _ = output.stdout();
309        output.finalize();
310    }
311
312    #[test]
313    fn test_terminal_output_drop() {
314        // Verify Drop works without explicit finalize.
315        let mut output = PagedOutput::terminal();
316        let _ = output.stdout();
317        // No explicit finalize - Drop handles it.
318    }
319
320    #[test]
321    fn test_request_pager_never_paginate() {
322        let pager = PagerSetting::default();
323        let streampager = StreampagerConfig {
324            interface: StreampagerInterface::QuitIfOnePage,
325            wrapping: StreampagerWrapping::Word,
326            show_ruler: true,
327        };
328        let output = PagedOutput::request_pager(&pager, PaginateSetting::Never, &streampager);
329        assert!(matches!(output, PagedOutput::Terminal { .. }));
330        output.finalize();
331    }
332
333    #[test]
334    #[cfg(unix)]
335    fn test_external_pager_write_and_finalize() {
336        // Spawn `cat` as a simple pager that consumes input.
337        let mut child = std::process::Command::new("cat")
338            .stdin(Stdio::piped())
339            .stdout(Stdio::null())
340            .spawn()
341            .expect("failed to spawn cat");
342
343        let child_stdin = child.stdin.take().expect("stdin should be piped");
344
345        let mut output = PagedOutput::ExternalPager {
346            child,
347            child_stdin: Some(child_stdin),
348        };
349
350        // Write some data.
351        writeln!(output.stdout(), "hello pager").expect("write should succeed");
352
353        // Finalize should close stdin and wait for cat to exit.
354        output.finalize();
355    }
356
357    #[test]
358    #[cfg(unix)]
359    fn test_external_pager_drop_without_finalize() {
360        let mut child = std::process::Command::new("cat")
361            .stdin(Stdio::piped())
362            .stdout(Stdio::null())
363            .spawn()
364            .expect("failed to spawn cat");
365
366        let child_stdin = child.stdin.take().expect("stdin should be piped");
367
368        let mut output = PagedOutput::ExternalPager {
369            child,
370            child_stdin: Some(child_stdin),
371        };
372
373        writeln!(output.stdout(), "hello pager").expect("write should succeed");
374
375        // No explicit finalize, so Drop should handle cleanup.
376        drop(output);
377    }
378
379    #[test]
380    #[cfg(unix)]
381    fn test_external_pager_double_finalize_is_idempotent() {
382        let mut child = std::process::Command::new("cat")
383            .stdin(Stdio::piped())
384            .stdout(Stdio::null())
385            .spawn()
386            .expect("failed to spawn cat");
387
388        let child_stdin = child.stdin.take().expect("stdin should be piped");
389
390        let mut output = PagedOutput::ExternalPager {
391            child,
392            child_stdin: Some(child_stdin),
393        };
394
395        // Call finalize_inner twice - second call should be a no-op.
396        output.finalize_inner();
397        output.finalize_inner();
398        // Drop will also try to finalize, should be safe.
399    }
400
401    #[test]
402    #[cfg(unix)]
403    fn test_external_pager_early_exit_squelches_broken_pipe() {
404        // `true` exits immediately, causing writes to fail with BrokenPipe.
405        let mut child = std::process::Command::new("true")
406            .stdin(Stdio::piped())
407            .spawn()
408            .expect("failed to spawn true");
409
410        let child_stdin = child.stdin.take().expect("stdin should be piped");
411
412        // Wait for the process to exit before constructing PagedOutput.
413        let _ = child.wait();
414
415        let mut output = PagedOutput::ExternalPager {
416            child,
417            child_stdin: Some(child_stdin),
418        };
419
420        output
421            .write_str("hello\n")
422            .expect("BrokenPipe should be squelched for write_str");
423        let error = output
424            .stdout()
425            .write(b"hello\n")
426            .expect_err("Write should fail with BrokenPipe");
427        assert_eq!(error.kind(), io::ErrorKind::BrokenPipe);
428        output
429            .write_str_flush()
430            .expect("BrokenPipe should be squelched for write_str_flush");
431        output.finalize();
432    }
433}