Skip to main content

mago_pager/
lib.rs

1//! This crate provides functionality to pipe terminal output through a pager
2//! program (like `less` or `more`) on Unix-like systems. It is designed to be
3//! used in command-line applications that generate large amounts of output,
4//! allowing users to scroll through the content conveniently.
5//!
6//! The primary entry point is the `Pager` builder, which allows for configuration
7//! and ultimately spawns a pager process via the `spawn()` method. The returned
8//! `PagerSession` struct handles the lifecycle of the pager process. When the
9//! `PagerSession` is dropped, it gracefully terminates the pager and restores
10//! the original standard output.
11//!
12//! This functionality is only active on Unix-like systems and when the output
13//! is being sent to an interactive terminal. On Windows or in non-interactive
14//! sessions, it gracefully becomes a no-op.
15//!
16//! ## Usage
17//!
18//! ```no_run
19//! use mago_pager::Pager;
20//!
21//! # fn run() -> std::io::Result<()> {
22//! // Spawn a pager if the environment is suitable.
23//! let _session = Pager::new().spawn()?;
24//!
25//! // All subsequent writes to stdout will be piped to the pager.
26//! println!("This line goes to the pager.");
27//! println!("So does this one.");
28//!
29//! // When `_session` goes out of scope, the pager is closed and stdout is restored.
30//! # Ok(())
31//! # }
32//! ```
33//!
34//! ## Environment Variables
35//!
36//! - `PAGER`: Overrides the default pager command.
37//! - `NOPAGER`: If set to any value, disables the pager entirely.
38
39use std::env;
40use std::ffi::OsStr;
41use std::ffi::OsString;
42use std::io::Result;
43
44#[cfg(unix)]
45use std::fs::File;
46#[cfg(unix)]
47use std::io::Error;
48#[cfg(unix)]
49use std::io::IsTerminal;
50#[cfg(unix)]
51use std::io::Write;
52#[cfg(unix)]
53use std::io::stdout;
54#[cfg(unix)]
55use std::os::fd::AsRawFd;
56#[cfg(unix)]
57use std::os::fd::RawFd;
58#[cfg(unix)]
59use std::process::Child;
60#[cfg(unix)]
61use std::process::Command;
62#[cfg(unix)]
63use std::process::Stdio;
64
65/// Environment variable to specify a custom pager command.
66pub const DEFAULT_PAGER_ENV: &str = "PAGER";
67
68/// Environment variable to disable the pager.
69pub const NOPAGER_ENV: &str = "NOPAGER";
70
71/// The fallback pager command if no other is specified.
72pub const DEFAULT_PAGER: &str = "less";
73
74/// A builder for configuring and spawning a terminal pager.
75///
76/// This struct follows the builder pattern to allow for flexible configuration
77/// before spawning the pager process.
78#[derive(Debug, Default)]
79pub struct Pager {
80    /// A pager command explicitly set via a builder method.
81    /// This takes precedence over environment variables or defaults.
82    command: Option<OsString>,
83    /// A fallback command to use if `PAGER` is not set.
84    default_command: Option<OsString>,
85    /// Additional environment variables to pass to the pager process.
86    envs: Vec<(OsString, OsString)>,
87}
88
89impl Pager {
90    /// Creates a new `Pager` builder with default settings.
91    pub fn new() -> Self {
92        Self::default()
93    }
94
95    /// Sets a specific command to use for the pager.
96    ///
97    /// This overrides any pager specified by environment variables or defaults. The command
98    /// will be parsed by `shell_words`.
99    ///
100    /// # Arguments
101    ///
102    /// * `command` - The pager command to execute (e.g., "less -R").
103    pub fn command<S: Into<OsString>>(mut self, command: S) -> Self {
104        self.command = Some(command.into());
105        self
106    }
107
108    /// Sets a default pager command to use if no other pager is configured.
109    ///
110    /// This is used as a fallback if `PAGER` is not set and no explicit
111    /// command was provided via `.command()`.
112    ///
113    /// # Arguments
114    ///
115    /// * `command` - The default pager command (e.g., "less").
116    pub fn default_command<S: Into<OsString>>(mut self, command: S) -> Self {
117        self.default_command = Some(command.into());
118        self
119    }
120
121    /// Adds environment variables to be set for the pager process.
122    ///
123    /// # Arguments
124    ///
125    /// * `envs` - An iterator of key-value pairs representing the environment variables.
126    pub fn envs<I, K, V>(mut self, envs: I) -> Self
127    where
128        I: IntoIterator<Item = (K, V)>,
129        K: Into<OsString>,
130        V: Into<OsString>,
131    {
132        self.envs = envs.into_iter().map(|(k, v)| (k.into(), v.into())).collect();
133        self
134    }
135
136    /// Determines the appropriate pager command based on configuration and environment.
137    ///
138    /// The selection process follows this order of precedence:
139    /// 1. The command set via `Pager::command()`.
140    /// 2. The `PAGER` environment variable.
141    /// 3. The command set via `Pager::default_command()`.
142    /// 4. The hardcoded `DEFAULT_PAGER` ("more").
143    ///
144    /// If the `NOPAGER` environment variable is set, this method returns `None`.
145    #[cfg(unix)]
146    fn determine_command(&self) -> Option<OsString> {
147        if env::var_os(NOPAGER_ENV).is_some() {
148            return None;
149        }
150
151        self.command
152            .clone()
153            .or_else(|| env::var_os(DEFAULT_PAGER_ENV))
154            .or_else(|| self.default_command.clone())
155            .or_else(|| Some(OsStr::new(DEFAULT_PAGER).into()))
156    }
157
158    /// Spawns the pager process if the conditions are met.
159    ///
160    /// This method will only attempt to spawn a pager if:
161    /// - The target OS is Unix-like.
162    /// - The current process is running in an interactive terminal.
163    /// - A pager command is available (i.e., not disabled by `NOPAGER`).
164    ///
165    /// On Windows or in non-interactive sessions, it returns a no-op `PagerSession`
166    /// that does nothing and has no overhead.
167    ///
168    /// # Returns
169    ///
170    /// A `Result` containing a `PagerSession` on success, or an `std::io::Error`
171    /// if the pager process fails to spawn or redirect output.
172    pub fn spawn(self) -> Result<PagerSession> {
173        #[cfg(not(unix))]
174        {
175            Ok(PagerSession {})
176        }
177
178        #[cfg(unix)]
179        {
180            if !stdout().is_terminal() {
181                // Not a TTY, so no pager is needed.
182                return Ok(PagerSession::new_inactive());
183            }
184
185            let Some(pager_command) = self.determine_command() else {
186                // Pager is disabled via NOPAGER env var.
187                return Ok(PagerSession::new_inactive());
188            };
189
190            // The command might contain arguments, so we need to parse it.
191            let pager_str =
192                pager_command.to_str().ok_or_else(|| Error::other("pager command contains non-UTF8 characters"))?;
193
194            let args = shell_words::split(pager_str).map_err(Error::other)?;
195            let Some((program, args)) = args.split_first() else {
196                return Err(Error::other("pager command is empty"));
197            };
198
199            // Spawn the pager process with its stdin piped.
200            let pager_process = Command::new(program).args(args).envs(self.envs).stdin(Stdio::piped()).spawn()?;
201
202            // This is the file descriptor for the pager's stdin.
203            let pager_stdin_fd = pager_process.stdin.as_ref().expect("stdin must be piped as configured").as_raw_fd();
204
205            // The following block redirects the process's standard output to the
206            // pager's standard input. We must be careful to restore the original
207            // stdout when the PagerSession is dropped.
208            let original_stdout_fd;
209            unsafe {
210                // 1. Save the original stdout file descriptor. `dup` creates a copy.
211                original_stdout_fd = libc::dup(libc::STDOUT_FILENO);
212                if original_stdout_fd < 0 {
213                    return Err(Error::last_os_error());
214                }
215
216                // 2. Redirect stdout to the pager's stdin. `dup2` closes the
217                //    current STDOUT_FILENO and makes it a copy of `pager_stdin_fd`.
218                if libc::dup2(pager_stdin_fd, libc::STDOUT_FILENO) != libc::STDOUT_FILENO {
219                    // In case of failure, close the saved fd and return the error.
220                    libc::close(original_stdout_fd);
221                    return Err(Error::last_os_error());
222                }
223            }
224
225            Ok(PagerSession::new_active(pager_process, original_stdout_fd))
226        }
227    }
228
229    /// Executes a closure within a pager session, handling setup and teardown automatically.
230    ///
231    /// This is a convenience method that simplifies using the pager. It spawns a pager
232    /// session, executes the provided `action` closure, and then ensures the session
233    /// is closed, restoring the original standard output. This avoids the need to
234    /// manually manage the `PagerSession` guard variable.
235    ///
236    /// The closure receives a boolean argument, `is_active`, which is `true` if
237    /// the output is being piped to a pager, and `false` otherwise. This allows for
238    /// conditional logic within your application, such as printing a different header
239    /// or footer when paging is active.
240    ///
241    /// # Arguments
242    ///
243    /// * `action`: A closure to execute. It receives a boolean indicating if the
244    ///   pager is active and should return a `Result`.
245    ///
246    /// # Returns
247    ///
248    /// This method returns the `Result<T>` where `T` is the return type of the closure.
249    /// If spawning the pager fails, it returns an `std::io::Error`.
250    ///
251    /// # Example
252    ///
253    /// ```no_run
254    /// use mago_pager::Pager;
255    /// use std::io::Result;
256    ///
257    /// fn main() -> Result<()> {
258    ///     Pager::new().page(|is_active| {
259    ///         if is_active {
260    ///             println!("--- Pager is active. Showing 1000 lines. ---");
261    ///         }
262    ///
263    ///         for i in 0..1000 {
264    ///             println!("This is line number {}", i);
265    ///         }
266    ///     })?;
267    ///
268    ///     // Pager is automatically closed here.
269    ///     Ok(())
270    /// }
271    /// ```
272    pub fn page<T, F>(self, action: F) -> Result<T>
273    where
274        F: FnOnce(bool) -> T,
275    {
276        let session = self.spawn()?;
277        let result = action(session.is_active());
278        drop(session);
279        Ok(result)
280    }
281}
282
283/// Represents an active pager session.
284///
285/// This struct's main responsibility is to clean up resources when it is dropped (RAII).
286/// On Unix, it holds the pager's child process and the original stdout file descriptor.
287/// On other platforms (like Windows), it is a zero-sized, no-op struct.
288#[must_use = "if the pager session is not bound, it will be dropped immediately, closing the pager"]
289pub struct PagerSession {
290    /// The spawned pager process. Only present on Unix.
291    #[cfg(unix)]
292    process: Option<Child>,
293    /// The saved file descriptor for the original standard output. Only present on Unix.
294    #[cfg(unix)]
295    original_stdout_fd: Option<RawFd>,
296}
297
298#[cfg(unix)]
299impl PagerSession {
300    /// Creates a new, active pager session that will manage the child process.
301    fn new_active(process: Child, original_stdout_fd: RawFd) -> Self {
302        Self { process: Some(process), original_stdout_fd: Some(original_stdout_fd) }
303    }
304
305    /// Creates a new, inactive pager session (a no-op).
306    fn new_inactive() -> Self {
307        Self { process: None, original_stdout_fd: None }
308    }
309}
310
311impl PagerSession {
312    /// Returns `true` if a pager process was successfully spawned.
313    ///
314    /// On non-Unix platforms, this will always return `false`.
315    pub fn is_active(&self) -> bool {
316        #[cfg(unix)]
317        {
318            self.process.is_some()
319        }
320        #[cfg(not(unix))]
321        {
322            false
323        }
324    }
325}
326
327/// The Drop implementation is crucial for restoring the terminal's state on Unix.
328#[cfg(unix)]
329impl Drop for PagerSession {
330    fn drop(&mut self) {
331        let Some(mut process) = self.process.take() else {
332            // Nothing to do if the process was never started.
333            return;
334        };
335
336        let Some(original_stdout_fd) = self.original_stdout_fd.take() else {
337            // Should not happen if process is Some, but we guard anyway.
338            return;
339        };
340
341        unsafe {
342            // Before restoring stdout, we must flush any buffered output from
343            // Rust's `stdout` handle. If we don't, this buffered content might get
344            // printed to the terminal *after* the pager has already exited.
345            //
346            // To prevent this, we temporarily redirect stdout to `/dev/null`,
347            // flush the buffer (sending its contents to oblivion), and then
348            // finally restore the original stdout.
349            if let Ok(null_device) = File::create("/dev/null") {
350                // Redirect stdout to /dev/null
351                libc::dup2(null_device.as_raw_fd(), libc::STDOUT_FILENO);
352            }
353
354            // Now, flush the buffer. We ignore potential errors here, as there's
355            // little we can do to recover.
356            let _ = stdout().flush();
357
358            // Finally, restore the original stdout file descriptor.
359            libc::dup2(original_stdout_fd, libc::STDOUT_FILENO);
360            // And close the duplicated file descriptor we created.
361            libc::close(original_stdout_fd);
362        }
363
364        // Wait for the pager process to exit. We ignore the result because
365        // we can't do anything about an error at this point. The user might
366        // have quit the pager (e.g., with 'q'), which can result in a non-zero exit status.
367        let _ = process.wait();
368    }
369}
370
371#[cfg(all(test, unix))]
372mod tests {
373    use super::*;
374    use std::sync::Mutex;
375
376    // Environment variables are a global resource, so tests that modify them
377    // must be serialized to prevent race conditions. A static mutex is a
378    // common way to achieve this.
379    static ENV_LOCK: Mutex<()> = Mutex::new(());
380
381    /// A RAII guard to temporarily modify an environment variable for a test.
382    struct EnvGuard {
383        key: OsString,
384        old_value: Option<OsString>,
385    }
386
387    impl EnvGuard {
388        fn set(key: &str, value: &str) -> Self {
389            let key = OsString::from(key);
390            let old_value = env::var_os(&key);
391            unsafe {
392                env::set_var(&key, value);
393            }
394
395            Self { key, old_value }
396        }
397
398        fn remove(key: &str) -> Self {
399            let key = OsString::from(key);
400            let old_value = env::var_os(&key);
401            unsafe {
402                env::remove_var(&key);
403            }
404
405            Self { key, old_value }
406        }
407    }
408
409    impl Drop for EnvGuard {
410        fn drop(&mut self) {
411            unsafe {
412                if let Some(old_value) = &self.old_value {
413                    env::set_var(&self.key, old_value);
414                } else {
415                    env::remove_var(&self.key);
416                }
417            }
418        }
419    }
420
421    fn assert_command(pager: &Pager, expected: &str) {
422        assert_eq!(pager.determine_command(), Some(OsStr::new(expected).into()));
423    }
424
425    #[test]
426    fn test_pager_disabled_by_nopager_env() {
427        let _lock = ENV_LOCK.lock().unwrap();
428        let _guard = EnvGuard::set(NOPAGER_ENV, "1");
429
430        let pager = Pager::new();
431        assert!(pager.determine_command().is_none());
432    }
433
434    #[test]
435    fn test_default_fallback_pager_is_more() {
436        let _lock = ENV_LOCK.lock().unwrap();
437        // Ensure relevant env vars are not set
438        let _guard1 = EnvGuard::remove(NOPAGER_ENV);
439        let _guard2 = EnvGuard::remove(DEFAULT_PAGER_ENV);
440
441        let pager = Pager::new();
442        assert_command(&pager, DEFAULT_PAGER); // "more"
443    }
444
445    #[test]
446    fn test_pager_reads_from_mago_pager_env() {
447        let _lock = ENV_LOCK.lock().unwrap();
448        let _guard = EnvGuard::set(DEFAULT_PAGER_ENV, "less -R");
449
450        let pager = Pager::new();
451        assert_command(&pager, "less -R");
452    }
453
454    #[test]
455    fn test_explicit_command_overrides_env() {
456        let _lock = ENV_LOCK.lock().unwrap();
457        let _guard = EnvGuard::set(DEFAULT_PAGER_ENV, "less");
458
459        let pager = Pager::new().command("bat");
460        assert_command(&pager, "bat");
461    }
462
463    #[test]
464    fn test_explicit_default_command_is_used_as_fallback() {
465        let _lock = ENV_LOCK.lock().unwrap();
466        let _guard = EnvGuard::remove(DEFAULT_PAGER_ENV);
467
468        let pager = Pager::new().default_command("less");
469        assert_command(&pager, "less");
470    }
471
472    #[test]
473    fn test_env_overrides_explicit_default_command() {
474        let _lock = ENV_LOCK.lock().unwrap();
475        let _guard = EnvGuard::set(DEFAULT_PAGER_ENV, "bat");
476
477        let pager = Pager::new().default_command("less");
478        assert_command(&pager, "bat");
479    }
480}