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