Skip to main content

cqlsh_rs/
pager.rs

1//! Output pager for displaying large result sets.
2//!
3//! Two modes:
4//! - **Batch** (`page_content`): write all content to temp file, open with pager.
5//! - **Streaming** (`page_stream`): pipe rows directly to `less` stdin.
6//!   `less` stores received bytes in its own internal buffer and allows full
7//!   backward scrolling. Data appears as it arrives; no polling required.
8//!
9//! Pager resolution order:
10//! 1. `$PAGER` environment variable (pipe mode — custom pager gets stdin)
11//! 2. `less` with stdin pipe (`less -R -S`)
12//! 3. `more` as fallback (pipe mode)
13//! 4. Error if nothing available
14
15use std::io::Write;
16use std::process::{Child, Command, Stdio};
17
18use tempfile::NamedTempFile;
19
20pub fn page_content(content: &str, _title: &str) -> anyhow::Result<()> {
21    let mut tmp = NamedTempFile::new()?;
22    tmp.write_all(content.as_bytes())?;
23    tmp.flush()?;
24
25    let path = tmp.path();
26
27    if let Ok(pager_env) = std::env::var("PAGER") {
28        let parts: Vec<&str> = pager_env.split_whitespace().collect();
29        if let Some((cmd, args)) = parts.split_first() {
30            let status = Command::new(cmd).args(args).arg(path).status();
31            if let Ok(s) = status {
32                if s.success() {
33                    return Ok(());
34                }
35            }
36        }
37    }
38
39    if let Ok(status) = Command::new("less").args(["-R", "-S"]).arg(path).status() {
40        if status.success() {
41            return Ok(());
42        }
43    }
44
45    if let Ok(status) = Command::new("more").arg(path).status() {
46        if status.success() {
47            return Ok(());
48        }
49    }
50
51    anyhow::bail!("no external pager available")
52}
53
54/// A writable handle that streams rows to a pager.
55///
56/// Rows are piped directly to the pager's stdin. The pager buffers received
57/// data internally (less stores it in its own temp file) and allows full
58/// backward scrolling without holding everything in our process memory.
59pub struct PagerWriter {
60    stdin: Option<std::process::ChildStdin>,
61    child: Option<Child>,
62}
63
64impl PagerWriter {
65    /// Returns true when a child pager process owns the terminal.
66    /// In that case stderr writes would corrupt the pager display.
67    pub fn is_file_mode(&self) -> bool {
68        self.child.is_some()
69    }
70}
71
72impl Write for PagerWriter {
73    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
74        match self.stdin.as_mut() {
75            Some(stdin) => stdin.write(buf),
76            None => Err(std::io::Error::new(
77                std::io::ErrorKind::BrokenPipe,
78                "pager stdin closed",
79            )),
80        }
81    }
82
83    fn flush(&mut self) -> std::io::Result<()> {
84        match self.stdin.as_mut() {
85            Some(stdin) => stdin.flush(),
86            None => Ok(()),
87        }
88    }
89}
90
91impl Drop for PagerWriter {
92    fn drop(&mut self) {
93        if let Some(mut stdin) = self.stdin.take() {
94            let _ = writeln!(
95                stdin,
96                "\n\x1b[2m-- end of results (press q to quit) --\x1b[0m"
97            );
98        }
99        if let Some(mut child) = self.child.take() {
100            let _ = child.wait();
101        }
102    }
103}
104
105/// Spawn a pager for streaming output, returning a [`PagerWriter`] to write rows into.
106///
107/// All pager variants receive data via piped stdin. `less` buffers received bytes
108/// in its own internal temp file, enabling full backward scrolling without holding
109/// all rows in our process memory. On drop, an end-of-results marker is written and
110/// we wait for the user to quit the pager.
111pub fn page_stream(title: &str) -> anyhow::Result<PagerWriter> {
112    let spawn_piped = |cmd: &mut Command| -> Option<PagerWriter> {
113        if let Ok(mut child) = cmd.stdin(Stdio::piped()).spawn() {
114            let stdin = child.stdin.take()?;
115            Some(PagerWriter {
116                stdin: Some(stdin),
117                child: Some(child),
118            })
119        } else {
120            None
121        }
122    };
123
124    if let Ok(pager_env) = std::env::var("PAGER") {
125        let parts: Vec<&str> = pager_env.split_whitespace().collect();
126        if let Some((cmd, args)) = parts.split_first() {
127            if let Some(w) = spawn_piped(Command::new(cmd).args(args)) {
128                return Ok(w);
129            }
130        }
131    }
132
133    let prompt = if title.is_empty() {
134        String::from("-Pline %lt/%L")
135    } else {
136        format!("-P{title}  line %lt/%L")
137    };
138
139    if let Some(w) = spawn_piped(Command::new("less").args(["-R", "-S", &prompt])) {
140        return Ok(w);
141    }
142
143    if let Some(w) = spawn_piped(&mut Command::new("more")) {
144        return Ok(w);
145    }
146
147    anyhow::bail!("no external pager available")
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn page_content_with_cat_pager() {
156        std::env::set_var("PAGER", "cat");
157        let result = page_content("hello world\n", "test title");
158        std::env::remove_var("PAGER");
159        assert!(result.is_ok());
160    }
161
162    #[test]
163    fn page_content_with_true_pager() {
164        std::env::set_var("PAGER", "true");
165        let result = page_content("test content", "");
166        std::env::remove_var("PAGER");
167        assert!(result.is_ok());
168    }
169
170    #[test]
171    fn page_content_writes_temp_file() {
172        std::env::set_var("PAGER", "grep -q hello");
173        let result = page_content("hello world", "title");
174        std::env::remove_var("PAGER");
175        assert!(result.is_ok());
176    }
177
178    #[test]
179    fn page_content_empty_string() {
180        std::env::set_var("PAGER", "true");
181        let result = page_content("", "empty");
182        std::env::remove_var("PAGER");
183        assert!(result.is_ok());
184    }
185
186    #[test]
187    fn page_content_large_content() {
188        let content = "x".repeat(100_000);
189        std::env::set_var("PAGER", "true");
190        let result = page_content(&content, "big");
191        std::env::remove_var("PAGER");
192        assert!(result.is_ok());
193    }
194
195    #[test]
196    fn page_content_multiline() {
197        let content = "line1\nline2\nline3\n";
198        std::env::set_var("PAGER", "wc -l");
199        let result = page_content(content, "lines");
200        std::env::remove_var("PAGER");
201        assert!(result.is_ok());
202    }
203
204    #[test]
205    fn page_stream_with_cat() {
206        std::env::set_var("PAGER", "cat");
207        let mut writer = page_stream("test").unwrap();
208        writer.write_all(b"streaming content\n").unwrap();
209        drop(writer);
210        std::env::remove_var("PAGER");
211    }
212
213    #[test]
214    fn page_stream_write_multiple() {
215        std::env::set_var("PAGER", "cat");
216        let mut writer = page_stream("").unwrap();
217        writer.write_all(b"line 1\n").unwrap();
218        writer.write_all(b"line 2\n").unwrap();
219        drop(writer);
220        std::env::remove_var("PAGER");
221    }
222
223    #[test]
224    fn pager_writer_is_file_mode_with_child() {
225        std::env::set_var("PAGER", "cat");
226        let writer = page_stream("title").unwrap();
227        assert!(writer.is_file_mode());
228        std::env::remove_var("PAGER");
229    }
230
231    #[test]
232    fn pager_writer_is_file_mode_without_child() {
233        let writer = PagerWriter {
234            stdin: None,
235            child: None,
236        };
237        assert!(!writer.is_file_mode());
238    }
239
240    #[test]
241    fn pager_writer_write_without_stdin_returns_broken_pipe() {
242        let mut writer = PagerWriter {
243            stdin: None,
244            child: None,
245        };
246        let result = writer.write(b"data");
247        assert!(result.is_err());
248        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::BrokenPipe);
249    }
250
251    #[test]
252    fn pager_writer_flush_without_stdin_ok() {
253        let mut writer = PagerWriter {
254            stdin: None,
255            child: None,
256        };
257        assert!(writer.flush().is_ok());
258    }
259
260    #[test]
261    fn page_stream_empty_title() {
262        std::env::set_var("PAGER", "true");
263        let writer = page_stream("");
264        std::env::remove_var("PAGER");
265        assert!(writer.is_ok());
266    }
267
268    #[test]
269    fn page_stream_nonempty_title() {
270        std::env::set_var("PAGER", "true");
271        let writer = page_stream("my table");
272        std::env::remove_var("PAGER");
273        assert!(writer.is_ok());
274    }
275
276    #[test]
277    fn pager_writer_drop_writes_end_marker() {
278        std::env::set_var("PAGER", "cat");
279        let writer = page_stream("").unwrap();
280        drop(writer);
281        std::env::remove_var("PAGER");
282    }
283}