Skip to main content

rusty_rich/
pager.rs

1//! System pager integration — pipes output to `less` or `$PAGER`.
2//!
3//! Equivalent to Python Rich's `pager.py`. Provides a configurable pager
4//! that sends content through an external pager program (e.g. `less`)
5//! for scrolling through long output.
6
7use std::io::Write;
8use std::process::{Command, Stdio};
9
10// ---------------------------------------------------------------------------
11// SystemPager
12// ---------------------------------------------------------------------------
13
14/// A pager that uses the system's default pager (`$PAGER` or `less`/`more`).
15///
16/// The pager command is split into program + arguments to prevent command
17/// injection via environment variables (VULN-007).
18#[derive(Debug, Clone)]
19pub struct SystemPager {
20    /// The pager program to execute.
21    program: String,
22    /// Arguments to pass to the pager program.
23    args: Vec<String>,
24}
25
26impl SystemPager {
27    /// Create a new `SystemPager`, detecting the system pager from the
28    /// `PAGER` environment variable. Falls back to `less` on Unix and
29    /// `more` on Windows.
30    pub fn new() -> Self {
31        let pager_cmd = std::env::var("PAGER").unwrap_or_else(|_| default_pager());
32        let (program, args) = split_pager_command(&pager_cmd);
33        Self { program, args }
34    }
35
36    /// Pipe `content` through the system pager.
37    ///
38    /// Spawns the pager process, writes content to its stdin, and waits
39    /// for it to finish.
40    pub fn show(&self, content: &str) -> std::io::Result<()> {
41        let mut child = Command::new(&self.program)
42            .args(&self.args)
43            .stdin(Stdio::piped())
44            .stdout(Stdio::inherit())
45            .stderr(Stdio::inherit())
46            .spawn()?;
47
48        if let Some(ref mut stdin) = child.stdin {
49            stdin.write_all(content.as_bytes())?;
50        }
51
52        // Close stdin explicitly so the pager knows there's no more input
53        drop(child.stdin.take());
54
55        child.wait()?;
56        Ok(())
57    }
58}
59
60impl Default for SystemPager {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66// ---------------------------------------------------------------------------
67// Pager
68// ---------------------------------------------------------------------------
69
70/// A configurable pager for displaying long output.
71///
72/// Wraps [`SystemPager`] with options for enabling/disabling paging,
73/// setting a custom command, and preserving ANSI color codes.
74#[derive(Debug, Clone)]
75pub struct Pager {
76    /// Whether paging is enabled.
77    enabled: bool,
78    /// The pager command to use.
79    command: String,
80    /// Whether to preserve ANSI color codes in paged output.
81    color: bool,
82}
83
84impl Pager {
85    /// Create a new `Pager` with default settings (enabled, uses `$PAGER`,
86    /// color enabled). Falls back to `less` on Unix, `more` on Windows.
87    pub fn new() -> Self {
88        Self {
89            enabled: true,
90            command: std::env::var("PAGER").unwrap_or_else(|_| default_pager()),
91            color: true,
92        }
93    }
94
95    /// Builder: enable or disable paging.
96    pub fn enabled(mut self, value: bool) -> Self {
97        self.enabled = value;
98        self
99    }
100
101    /// Builder: set a custom pager command.
102    pub fn command(mut self, cmd: impl Into<String>) -> Self {
103        self.command = cmd.into();
104        self
105    }
106
107    /// Builder: enable or disable ANSI color passthrough.
108    pub fn color(mut self, value: bool) -> Self {
109        self.color = value;
110        self
111    }
112
113    /// Return `true` if paging is enabled.
114    pub fn is_enabled(&self) -> bool {
115        self.enabled
116    }
117
118    /// Return the pager command string.
119    pub fn command_str(&self) -> &str {
120        &self.command
121    }
122
123    /// Return `true` if color preservation is enabled.
124    pub fn is_color(&self) -> bool {
125        self.color
126    }
127
128    /// Show `content` through the pager.
129    ///
130    /// If paging is disabled, this is a no-op. If color is disabled,
131    /// ANSI escape sequences are stripped before sending to the pager.
132    pub fn show(&self, content: &str) -> std::io::Result<()> {
133        if !self.enabled {
134            // Paging disabled — just print to stdout
135            let stdout = std::io::stdout();
136            let mut handle = stdout.lock();
137            handle.write_all(content.as_bytes())?;
138            handle.flush()?;
139            return Ok(());
140        }
141
142        let display = if !self.color {
143            // Strip ANSI escape sequences using the comprehensive FSM-based
144            // version from the export module (handles CSI, OSC, DCS, etc.)
145            crate::export::strip_ansi_escapes(content)
146        } else {
147            content.to_string()
148        };
149
150        let (program, args) = split_pager_command(&self.command);
151        let pager = SystemPager { program, args };
152        pager.show(&display)
153    }
154}
155
156impl Default for Pager {
157    fn default() -> Self {
158        Self::new()
159    }
160}
161
162// ---------------------------------------------------------------------------
163// PagerContext
164// ---------------------------------------------------------------------------
165
166/// A context that accumulates content and pages it on drop.
167///
168/// Provides RAII-style paging: content is fed via [`feed`](PagerContext::feed)
169/// and automatically sent to the pager when the context is dropped.
170#[derive(Debug)]
171pub struct PagerContext {
172    /// The pager configuration.
173    pager: Pager,
174    /// The accumulated content.
175    content: String,
176    /// Whether paging is enabled for this context.
177    enabled: bool,
178}
179
180impl PagerContext {
181    /// Create a new `PagerContext` that uses the given [`Pager`].
182    pub fn new(pager: Pager) -> Self {
183        let enabled = pager.enabled;
184        Self {
185            pager,
186            content: String::new(),
187            enabled,
188        }
189    }
190
191    /// Append text to the content buffer.
192    pub fn feed(&mut self, text: &str) {
193        self.content.push_str(text);
194    }
195
196    /// Flush the accumulated content to the pager immediately,
197    /// bypassing the drop handler.
198    pub fn flush(&mut self) -> std::io::Result<()> {
199        if !self.content.is_empty() {
200            let result = self.pager.show(&self.content);
201            self.content.clear();
202            result
203        } else {
204            Ok(())
205        }
206    }
207
208    /// Return a reference to the accumulated content.
209    pub fn content(&self) -> &str {
210        &self.content
211    }
212
213    /// Return whether paging is enabled.
214    pub fn is_enabled(&self) -> bool {
215        self.enabled
216    }
217}
218
219impl Write for PagerContext {
220    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
221        let s = String::from_utf8_lossy(buf);
222        self.feed(&s);
223        Ok(buf.len())
224    }
225
226    fn flush(&mut self) -> std::io::Result<()> {
227        Ok(())
228    }
229}
230
231impl Drop for PagerContext {
232    fn drop(&mut self) {
233        if self.enabled && !self.content.is_empty() {
234            let _ = self.pager.show(&self.content);
235        }
236    }
237}
238
239// ---------------------------------------------------------------------------
240// Internal helpers
241// ---------------------------------------------------------------------------
242
243/// Return the platform-appropriate default pager command.
244fn default_pager() -> String {
245    if cfg!(windows) {
246        "more".into()
247    } else {
248        "less".into()
249    }
250}
251
252/// Split a pager command string into a program and arguments.
253///
254/// This prevents command injection via `$PAGER` by ensuring the program
255/// and arguments are passed separately to `Command::new()`.
256fn split_pager_command(cmd: &str) -> (String, Vec<String>) {
257    let mut parts: Vec<&str> = cmd.split_whitespace().collect();
258    if parts.is_empty() {
259        return (String::new(), vec![]);
260    }
261    let program = parts.remove(0).to_string();
262    let args: Vec<String> = parts.into_iter().map(String::from).collect();
263    (program, args)
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_system_pager_creation() {
272        let pager = SystemPager::new();
273        // Should detect PAGER or default to "less"/"more"
274        assert!(!pager.program.is_empty());
275    }
276
277    #[test]
278    fn test_pager_defaults() {
279        let pager = Pager::new();
280        assert!(pager.is_enabled());
281        assert!(pager.is_color());
282        assert!(!pager.command_str().is_empty());
283    }
284
285    #[test]
286    fn test_pager_builder() {
287        let pager = Pager::new().enabled(false).command("more").color(false);
288        assert!(!pager.is_enabled());
289        assert!(!pager.is_color());
290        assert_eq!(pager.command_str(), "more");
291    }
292
293    #[test]
294    fn test_pager_disabled_show() {
295        let pager = Pager::new().enabled(false);
296        // When disabled, show() writes to stdout — just verify it returns Ok
297        assert!(pager.show("test").is_ok());
298    }
299
300    #[test]
301    fn test_pager_context_feed() {
302        let pager = Pager::new().enabled(false);
303        let mut ctx = PagerContext::new(pager);
304        ctx.feed("Hello, ");
305        ctx.feed("World!");
306        assert_eq!(ctx.content(), "Hello, World!");
307    }
308
309    #[test]
310    fn test_pager_context_write_trait() {
311        use std::io::Write;
312        let pager = Pager::new().enabled(false);
313        let mut ctx = PagerContext::new(pager);
314        write!(ctx, "Hello {}!", "World").unwrap();
315        assert!(ctx.content().contains("Hello"));
316        assert!(ctx.content().contains("World"));
317    }
318
319    #[test]
320    fn test_strip_ansi_via_export() {
321        // Uses crate::export::strip_ansi_escapes (hand-written FSM)
322        let input = "\x1b[31mhello\x1b[0m world";
323        let result = crate::export::strip_ansi_escapes(input);
324        assert_eq!(result, "hello world");
325    }
326
327    #[test]
328    fn test_pager_context_flush() {
329        let pager = Pager::new().enabled(false);
330        let mut ctx = PagerContext::new(pager);
331        ctx.feed("test");
332        assert!(ctx.flush().is_ok());
333        assert!(ctx.content().is_empty());
334    }
335}