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}