Skip to main content

sqry_cli/output/
pager.rs

1//! Pager integration for long output (P2-29)
2//!
3//! Provides automatic paging when output exceeds terminal height.
4//! Uses external pagers like `less`, `bat`, or `more`.
5//!
6//! # Features
7//!
8//! - **Auto-detection**: Pages only when stdout is a TTY and output exceeds threshold
9//! - **Capped buffering**: Max 1MB buffer, then streams to pager
10//! - **Cross-platform**: Uses `terminal_size` crate for terminal dimensions
11//! - **Unicode-aware**: Uses `unicode-width` for accurate line-wrap calculation
12//! - **Safe command parsing**: Uses `shlex` for proper argument handling
13//!
14//! # Example
15//!
16//! ```ignore
17//! use sqry_cli::output::pager::{BufferedOutput, PagerConfig, PagerExitStatus, PagerMode};
18//!
19//! let config = PagerConfig {
20//!     enabled: PagerMode::Auto,
21//!     ..Default::default()
22//! };
23//!
24//! let mut output = BufferedOutput::new(config);
25//! output.write("Hello, world!\n")?;
26//!
27//! // Finalize output - returns pager exit status
28//! let status = output.finish()?;
29//! if let Some(exit_code) = status.exit_code() {
30//!     std::process::exit(exit_code);
31//! }
32//! ```
33
34use std::io::{self, Write};
35use std::process::{Child, Command, ExitStatus, Stdio};
36use unicode_width::UnicodeWidthChar;
37
38/// Capped buffer size to prevent unbounded memory growth (1MB)
39const BUFFER_CAP_BYTES: usize = 1024 * 1024;
40
41/// Pager mode selection
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum PagerMode {
44    /// Auto-detect: page if TTY and output exceeds threshold
45    #[default]
46    Auto,
47    /// Always use pager (--pager flag)
48    Always,
49    /// Never use pager (--no-pager flag)
50    Never,
51}
52
53/// Pager exit status for distinguishing normal exits from signal terminations
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum PagerExitStatus {
56    /// Pager exited successfully (exit code 0 or user quit with SIGPIPE/q)
57    Success,
58    /// Pager exited with non-zero exit code
59    ExitCode(i32),
60    /// Pager was terminated by a signal (Unix only)
61    #[cfg_attr(not(unix), allow(dead_code))]
62    Signal(i32),
63}
64
65impl PagerExitStatus {
66    /// Returns the suggested process exit code
67    ///
68    /// - Success returns None (don't override exit code)
69    /// - `ExitCode` returns the exit code
70    /// - Signal returns 128 + signal number (Unix convention)
71    #[must_use]
72    #[allow(dead_code)]
73    pub fn exit_code(self) -> Option<i32> {
74        match self {
75            Self::Success => None,
76            Self::ExitCode(code) => Some(code),
77            Self::Signal(sig) => Some(128 + sig),
78        }
79    }
80
81    /// Returns true if the pager exited successfully
82    #[must_use]
83    #[allow(dead_code)]
84    pub fn is_success(self) -> bool {
85        matches!(self, Self::Success)
86    }
87}
88
89/// Configuration for pager behavior
90#[derive(Debug, Clone)]
91pub struct PagerConfig {
92    /// Pager command (e.g., "less -R", "bat")
93    pub command: String,
94    /// Whether paging is enabled
95    pub enabled: PagerMode,
96    /// Minimum lines before auto-paging triggers (None = use terminal height)
97    pub threshold: Option<usize>,
98}
99
100impl Default for PagerConfig {
101    fn default() -> Self {
102        Self {
103            command: Self::default_pager_command(),
104            enabled: PagerMode::Auto,
105            threshold: None,
106        }
107    }
108}
109
110impl PagerConfig {
111    /// Resolve pager command from environment/flags
112    ///
113    /// Priority order:
114    /// 1. `$SQRY_PAGER` environment variable
115    /// 2. `$PAGER` environment variable
116    /// 3. Default: `less -FRX`
117    ///
118    /// The default flags mean:
119    /// - `-F`: Quit if output fits on one screen (no need to press 'q')
120    /// - `-R`: Raw control characters (preserve ANSI colors)
121    /// - `-X`: Don't clear screen on exit (preserve output visibility)
122    #[must_use]
123    pub fn default_pager_command() -> String {
124        std::env::var("SQRY_PAGER")
125            .or_else(|_| std::env::var("PAGER"))
126            .unwrap_or_else(|_| "less -FRX".to_string())
127    }
128
129    /// Build config from CLI args
130    ///
131    /// # Arguments
132    ///
133    /// * `pager_flag` - Whether `--pager` was specified
134    /// * `no_pager_flag` - Whether `--no-pager` was specified
135    /// * `pager_cmd` - Optional custom pager command from `--pager-cmd`
136    #[must_use]
137    pub fn from_cli_flags(pager_flag: bool, no_pager_flag: bool, pager_cmd: Option<&str>) -> Self {
138        let mode = if no_pager_flag {
139            PagerMode::Never
140        } else if pager_flag {
141            PagerMode::Always
142        } else {
143            PagerMode::Auto
144        };
145
146        let command = pager_cmd.map_or_else(Self::default_pager_command, String::from);
147
148        Self {
149            command,
150            enabled: mode,
151            threshold: None,
152        }
153    }
154}
155
156/// Determines whether to use pager for current output
157pub struct PagerDecision {
158    config: PagerConfig,
159    is_tty: bool,
160    terminal_height: Option<usize>,
161}
162
163impl PagerDecision {
164    /// Create a new pager decision based on current terminal state
165    #[must_use]
166    pub fn new(config: PagerConfig) -> Self {
167        use is_terminal::IsTerminal;
168
169        let is_tty = std::io::stdout().is_terminal();
170        let terminal_height = Self::detect_terminal_height();
171
172        Self {
173            config,
174            is_tty,
175            terminal_height,
176        }
177    }
178
179    /// Public accessor for TTY detection result
180    #[must_use]
181    pub fn is_tty(&self) -> bool {
182        self.is_tty
183    }
184
185    /// Check if paging should be used based on displayed row count
186    ///
187    /// This is the preferred method that accounts for line wrapping.
188    #[must_use]
189    pub fn should_page_rows(&self, displayed_rows: usize) -> bool {
190        match self.config.enabled {
191            PagerMode::Never => false,
192            PagerMode::Always => true,
193            PagerMode::Auto => {
194                if !self.is_tty {
195                    return false; // Don't page when piping
196                }
197
198                let threshold = self.config.threshold.or(self.terminal_height).unwrap_or(24);
199
200                displayed_rows > threshold
201            }
202        }
203    }
204
205    /// Cross-platform terminal height detection using `terminal_size` crate
206    #[must_use]
207    fn detect_terminal_height() -> Option<usize> {
208        use terminal_size::{Height, terminal_size};
209        terminal_size().map(|(_, Height(h))| h as usize)
210    }
211
212    /// Cross-platform terminal width detection
213    #[must_use]
214    pub fn detect_terminal_width() -> Option<usize> {
215        use terminal_size::{Width, terminal_size};
216        terminal_size().map(|(Width(w), _)| w as usize)
217    }
218}
219
220// Test helper for constructing PagerDecision with overrides
221#[cfg(test)]
222impl PagerDecision {
223    /// Test-only constructor that allows overriding `is_tty` and `terminal_height`.
224    /// Use this in unit tests to simulate different TTY/terminal configurations.
225    #[must_use]
226    pub fn for_testing(config: PagerConfig, is_tty: bool, terminal_height: Option<usize>) -> Self {
227        Self {
228            config,
229            is_tty,
230            terminal_height,
231        }
232    }
233}
234
235/// Manages output to pager process
236///
237/// Note: Debug is not derived because `Child` and `ChildStdin` don't implement Debug.
238/// For test assertions, use `assert!(result.is_err())` pattern instead of `unwrap_err()`.
239pub struct PagerWriter {
240    child: Child,
241    stdin: std::process::ChildStdin,
242}
243
244impl PagerWriter {
245    /// Spawn pager process using shlex for proper argument parsing
246    ///
247    /// # Errors
248    ///
249    /// Returns an error if:
250    /// - The command syntax is invalid
251    /// - The pager executable cannot be found
252    /// - The pager process fails to spawn
253    ///
254    /// # Panics
255    /// Panics if the parsed command unexpectedly contains no program.
256    pub fn spawn(command: &str) -> io::Result<Self> {
257        // Use shlex for proper parsing that handles:
258        // - Quoted arguments: "C:\Program Files\Git\usr\bin\less.exe" -R
259        // - Escaped spaces: /path/with\ spaces/less
260        // - Shell-style quoting: 'bat --style=plain'
261        let parts = shlex::split(command).ok_or_else(|| {
262            io::Error::new(
263                io::ErrorKind::InvalidInput,
264                format!("Invalid pager command syntax: {command}"),
265            )
266        })?;
267
268        if parts.is_empty() {
269            return Err(io::Error::new(
270                io::ErrorKind::InvalidInput,
271                "Empty pager command",
272            ));
273        }
274
275        let (program, args) = parts.split_first().expect("Already checked non-empty");
276
277        let mut child = Command::new(program)
278            .args(args)
279            .stdin(Stdio::piped())
280            .spawn()?;
281
282        let stdin = child
283            .stdin
284            .take()
285            .ok_or_else(|| io::Error::other("Failed to open pager stdin"))?;
286
287        Ok(Self { child, stdin })
288    }
289
290    /// Write content to pager
291    ///
292    /// # Errors
293    ///
294    /// Returns an error if writing to the pager's stdin fails.
295    pub fn write(&mut self, content: &str) -> io::Result<()> {
296        self.stdin.write_all(content.as_bytes())
297    }
298
299    /// Wait for pager to exit, returning the exit status
300    ///
301    /// # Errors
302    ///
303    /// Returns an error if waiting for the pager process fails.
304    pub fn wait(mut self) -> io::Result<ExitStatus> {
305        drop(self.stdin); // Close stdin to signal EOF
306        self.child.wait()
307    }
308}
309
310impl Write for PagerWriter {
311    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
312        self.stdin.write(buf)
313    }
314
315    fn flush(&mut self) -> io::Result<()> {
316        self.stdin.flush()
317    }
318}
319
320/// Output mode after initial decision
321enum OutputMode {
322    /// Still buffering, haven't decided yet
323    Buffering,
324    /// Streaming to pager (threshold/cap exceeded)
325    Pager(PagerWriter),
326    /// Direct stdout (decided: below threshold)
327    Direct,
328}
329
330/// Buffered output with capped streaming for auto mode
331///
332/// # Behavior by Mode
333///
334/// - **Never**: Writes directly to stdout (no buffering)
335/// - **Always**: Spawns pager immediately
336/// - **Auto + TTY**: Buffers output, spawns pager if threshold/cap exceeded
337/// - **Auto + non-TTY**: Writes directly to stdout (no buffering, streams immediately)
338///
339/// # Memory Safety (TTY only)
340///
341/// When in Auto mode with a TTY, buffer never exceeds `BUFFER_CAP_BYTES` (1MB).
342/// When the cap is reached, pager is spawned and buffer is flushed.
343pub struct BufferedOutput {
344    buffer: String,
345    config: PagerConfig,
346    decision: PagerDecision,
347    mode: OutputMode,
348    /// Terminal width for future display row calculation (currently unused)
349    #[allow(dead_code)]
350    terminal_width: Option<usize>,
351    /// Number of complete lines in buffer (lines ending with \n)
352    complete_lines: usize,
353    /// Length of partial line at end of buffer (content after last \n)
354    partial_line_len: usize,
355    /// Deferred spawn error for non-NotFound failures (per CLI spec: exit 1)
356    spawn_error: Option<io::Error>,
357}
358
359impl BufferedOutput {
360    /// Create a new buffered output with the given configuration
361    #[must_use]
362    pub fn new(config: PagerConfig) -> Self {
363        let decision = PagerDecision::new(config.clone());
364        let terminal_width = PagerDecision::detect_terminal_width();
365
366        // For explicit modes, decide immediately
367        // Also for Auto mode when not a TTY, write directly to stdout (no buffering)
368        let (mode, spawn_error) = match config.enabled {
369            PagerMode::Never => (OutputMode::Direct, None),
370            PagerMode::Always => {
371                // Spawn pager immediately for Always mode
372                match PagerWriter::spawn(&config.command) {
373                    Ok(pager) => (OutputMode::Pager(pager), None),
374                    Err(e) => {
375                        // Per CLI spec: differentiate "not found" (warning, exit 0)
376                        // from other spawn errors (error, exit 1)
377                        let pager_name = config
378                            .command
379                            .split_whitespace()
380                            .next()
381                            .unwrap_or(&config.command);
382                        if e.kind() == io::ErrorKind::NotFound {
383                            eprintln!(
384                                "Warning: pager '{pager_name}' not found. Output will not be paged. \
385                                 To enable paging, install '{pager_name}' or set the SQRY_PAGER environment variable."
386                            );
387                            (OutputMode::Direct, None)
388                        } else {
389                            eprintln!(
390                                "Error: Failed to start pager '{pager_name}': {e}. \
391                                 Please check that the binary is correct and executable, \
392                                 or set a different pager using the SQRY_PAGER environment variable."
393                            );
394                            // Defer error - still write to stdout, but finish() will return error
395                            (OutputMode::Direct, Some(e))
396                        }
397                    }
398                }
399            }
400            PagerMode::Auto => {
401                // Non-TTY: stream immediately without buffering
402                // TTY: buffer until we know if paging is needed
403                if decision.is_tty() {
404                    (OutputMode::Buffering, None)
405                } else {
406                    (OutputMode::Direct, None)
407                }
408            }
409        };
410
411        Self {
412            buffer: String::new(),
413            config,
414            decision,
415            mode,
416            terminal_width,
417            complete_lines: 0,
418            partial_line_len: 0,
419            spawn_error,
420        }
421    }
422
423    /// Create a new buffered output for testing with explicit buffering mode
424    ///
425    /// This forces Buffering mode regardless of TTY detection, allowing tests
426    /// to verify the line counting logic without needing a real TTY.
427    #[cfg(test)]
428    #[must_use]
429    pub fn new_for_testing(config: PagerConfig) -> Self {
430        let decision = PagerDecision::new(config.clone());
431        let terminal_width = PagerDecision::detect_terminal_width();
432
433        Self {
434            buffer: String::new(),
435            config,
436            decision,
437            mode: OutputMode::Buffering, // Always buffer for testing
438            terminal_width,
439            complete_lines: 0,
440            partial_line_len: 0,
441            spawn_error: None,
442        }
443    }
444
445    fn write_direct(content: &str) -> io::Result<()> {
446        std::io::stdout().write_all(content.as_bytes())
447    }
448
449    fn write_pager(pager: &mut PagerWriter, content: &str) -> io::Result<()> {
450        match pager.write(content) {
451            Ok(()) => Ok(()),
452            Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
453            Err(e) => Err(e),
454        }
455    }
456
457    fn update_line_counts(&mut self, content: &str) {
458        let newline_count = content.bytes().filter(|&b| b == b'\n').count();
459        self.complete_lines += newline_count;
460        self.update_partial_line_len(content);
461    }
462
463    fn update_partial_line_len(&mut self, content: &str) {
464        if let Some(last_nl_offset) = content.rfind('\n') {
465            self.partial_line_len = content.len().saturating_sub(last_nl_offset + 1);
466        } else {
467            self.partial_line_len += content.len();
468        }
469    }
470
471    fn displayed_row_estimate(&self) -> usize {
472        self.complete_lines + usize::from(self.partial_line_len > 0)
473    }
474
475    fn should_transition_to_pager(&self, displayed_rows: usize) -> bool {
476        self.decision.should_page_rows(displayed_rows) || self.buffer.len() > BUFFER_CAP_BYTES
477    }
478
479    fn transition_to_pager(&mut self) -> io::Result<()> {
480        match PagerWriter::spawn(&self.config.command) {
481            Ok(mut pager) => {
482                pager.write(&self.buffer)?;
483                self.buffer.clear();
484                self.mode = OutputMode::Pager(pager);
485                Ok(())
486            }
487            Err(e) => self.handle_pager_spawn_error(e),
488        }
489    }
490
491    fn handle_pager_spawn_error(&mut self, err: io::Error) -> io::Result<()> {
492        let pager_name = self
493            .config
494            .command
495            .split_whitespace()
496            .next()
497            .unwrap_or(&self.config.command);
498        if err.kind() == io::ErrorKind::NotFound {
499            eprintln!(
500                "Warning: pager '{pager_name}' not found. Output will not be paged. \
501                 To enable paging, install '{pager_name}' or set the SQRY_PAGER environment variable."
502            );
503        } else {
504            eprintln!(
505                "Error: Failed to start pager '{pager_name}': {err}. \
506                 Please check that the binary is correct and executable, \
507                 or set a different pager using the SQRY_PAGER environment variable."
508            );
509            self.spawn_error = Some(err);
510        }
511
512        Self::write_direct(&self.buffer)?;
513        self.buffer.clear();
514        self.mode = OutputMode::Direct;
515        Ok(())
516    }
517
518    /// Write content, handling mode transitions
519    ///
520    /// # Errors
521    ///
522    /// Returns an error if writing to stdout or pager fails.
523    pub fn write(&mut self, content: &str) -> io::Result<()> {
524        match &mut self.mode {
525            OutputMode::Direct => {
526                // Write directly to stdout
527                Self::write_direct(content)
528            }
529            OutputMode::Pager(pager) => {
530                // Stream to pager, handling broken pipe gracefully
531                Self::write_pager(pager, content)
532            }
533            OutputMode::Buffering => {
534                // Append to buffer first
535                self.buffer.push_str(content);
536
537                // Incremental line counting: count newlines in the new content
538                // This is O(n) in the new content, not O(n) in the entire buffer
539                self.update_line_counts(content);
540
541                // Calculate displayed rows:
542                // - Each complete line is 1+ rows (depends on wrapping)
543                // - Partial line at end is 1 row (if non-empty)
544                // For simplicity in threshold checking, use complete_lines + 1 if partial exists
545                // This is a conservative estimate that may trigger paging slightly early
546                let displayed_rows = self.displayed_row_estimate();
547
548                // Check thresholds
549                if self.should_transition_to_pager(displayed_rows) {
550                    // Threshold or buffer cap exceeded: transition to pager mode
551                    // Note: Non-TTY output uses Direct mode from the start, so we only
552                    // reach here when is_tty() is true
553                    self.transition_to_pager()?;
554                }
555                // Otherwise: continue buffering (below threshold and cap)
556                Ok(())
557            }
558        }
559    }
560
561    /// Finalize output, flushing any buffered content
562    ///
563    /// Returns a `PagerExitStatus` indicating how the pager terminated:
564    /// - `Success`: Pager exited normally (code 0, SIGPIPE, or no pager used)
565    /// - `ExitCode(n)`: Pager exited with non-zero code
566    /// - `Signal(n)`: Pager was terminated by signal (non-SIGPIPE)
567    ///
568    /// # Errors
569    ///
570    /// Returns an error if flushing or waiting for pager fails, or if there
571    /// was a deferred pager spawn error (non-NotFound spawn failures).
572    pub fn finish(self) -> io::Result<PagerExitStatus> {
573        // Check for deferred spawn error first (non-NotFound spawn failures)
574        // Per CLI spec: spawn failures (other than not-found) should exit 1
575        if let Some(spawn_err) = self.spawn_error {
576            return Err(spawn_err);
577        }
578
579        match self.mode {
580            OutputMode::Direct => Ok(PagerExitStatus::Success),
581            OutputMode::Pager(pager) => {
582                let status = pager.wait()?;
583                Ok(exit_status_to_pager_status(status))
584            }
585            OutputMode::Buffering => {
586                // Never transitioned, output is small - write directly
587                std::io::stdout().write_all(self.buffer.as_bytes())?;
588                Ok(PagerExitStatus::Success)
589            }
590        }
591    }
592}
593
594/// Check if exit status indicates broken pipe (user quit pager early)
595#[must_use]
596fn is_broken_pipe_exit(status: ExitStatus) -> bool {
597    #[cfg(unix)]
598    {
599        use std::os::unix::process::ExitStatusExt;
600        // SIGPIPE = signal 13
601        status.signal() == Some(13)
602    }
603    #[cfg(not(unix))]
604    {
605        // On Windows, treat exit code 0 or 1 as "user quit"
606        matches!(status.code(), Some(0) | Some(1))
607    }
608}
609
610/// Convert process exit status to `PagerExitStatus`
611///
612/// Handles the differences between Unix and Windows:
613/// - Unix: Distinguishes exit codes from signal terminations
614/// - Windows: Only has exit codes
615fn exit_status_to_pager_status(status: ExitStatus) -> PagerExitStatus {
616    // Success or SIGPIPE is treated as normal user quit
617    if status.success() || is_broken_pipe_exit(status) {
618        return PagerExitStatus::Success;
619    }
620
621    #[cfg(unix)]
622    {
623        use std::os::unix::process::ExitStatusExt;
624        // Check for signal termination (excluding SIGPIPE which was handled above)
625        if let Some(signal) = status.signal() {
626            return PagerExitStatus::Signal(signal);
627        }
628    }
629
630    // Non-zero exit code
631    if let Some(code) = status.code() {
632        PagerExitStatus::ExitCode(code)
633    } else {
634        // Shouldn't happen: not success, no signal, no code
635        // Default to exit code 1
636        PagerExitStatus::ExitCode(1)
637    }
638}
639
640/// Standard tab width for display calculation
641#[allow(dead_code)]
642const TAB_WIDTH: usize = 8;
643
644fn skip_csi_sequence(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
645    while let Some(&next) = chars.peek() {
646        chars.next();
647        if (0x40..=0x7E).contains(&(next as u8)) {
648            break;
649        }
650    }
651}
652
653fn skip_osc_sequence(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
654    while let Some(&next) = chars.peek() {
655        if next == '\x07' {
656            chars.next();
657            break;
658        }
659        if next == '\x1b' {
660            chars.next();
661            if chars.peek() == Some(&'\\') {
662                chars.next();
663            }
664            break;
665        }
666        chars.next();
667    }
668}
669
670/// Strip ANSI escape sequences from a string
671///
672/// Removes CSI sequences (ESC [ ... `final_byte`) and OSC sequences (ESC ] ... ST).
673/// This ensures ANSI color codes don't inflate width calculations.
674#[allow(dead_code)]
675fn strip_ansi(s: &str) -> String {
676    let mut result = String::with_capacity(s.len());
677    let mut chars = s.chars().peekable();
678
679    while let Some(c) = chars.next() {
680        if c == '\x1b' {
681            // Start of escape sequence
682            match chars.peek().copied() {
683                Some('[') => {
684                    chars.next();
685                    skip_csi_sequence(&mut chars);
686                }
687                Some(']') => {
688                    chars.next();
689                    skip_osc_sequence(&mut chars);
690                }
691                _ => {}
692            }
693        } else {
694            result.push(c);
695        }
696    }
697    result
698}
699
700/// Calculate displayed width of a line, accounting for tabs
701///
702/// Tabs expand to the next tab stop (every 8 columns by default).
703#[allow(dead_code)]
704fn displayed_line_width(line: &str) -> usize {
705    let mut width = 0;
706    for c in line.chars() {
707        if c == '\t' {
708            // Expand to next tab stop
709            width = (width / TAB_WIDTH + 1) * TAB_WIDTH;
710        } else {
711            width += UnicodeWidthChar::width(c).unwrap_or(0);
712        }
713    }
714    width
715}
716
717/// Count displayed rows accounting for line wrapping
718///
719/// Uses unicode-width for accurate character width calculation,
720/// which handles CJK characters, emoji, and other wide characters.
721/// Strips ANSI escape sequences and expands tabs before calculation.
722#[allow(dead_code)]
723#[must_use]
724pub fn count_displayed_rows(content: &str, terminal_width: Option<usize>) -> usize {
725    let width = terminal_width.unwrap_or(80);
726
727    content
728        .lines()
729        .map(|line| {
730            // Strip ANSI escape sequences before width calculation
731            let clean_line = strip_ansi(line);
732            let line_width = displayed_line_width(&clean_line);
733            if line_width == 0 {
734                1 // Empty line still takes 1 row
735            } else {
736                // Ceiling division: how many terminal rows does this line span?
737                line_width.div_ceil(width)
738            }
739        })
740        .sum()
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746    use serial_test::serial;
747
748    // ===== PagerMode Tests =====
749
750    #[test]
751    fn test_pager_mode_default() {
752        assert_eq!(PagerMode::default(), PagerMode::Auto);
753    }
754
755    // ===== PagerConfig Tests =====
756
757    #[test]
758    fn test_pager_config_default() {
759        let config = PagerConfig::default();
760        assert_eq!(config.enabled, PagerMode::Auto);
761        assert!(config.threshold.is_none());
762        // Command depends on environment, don't assert exact value
763    }
764
765    #[test]
766    #[serial]
767    fn test_pager_config_env_sqry_pager() {
768        // SAFETY: Test isolation via serial_test
769        unsafe {
770            std::env::set_var("SQRY_PAGER", "bat --style=plain");
771            std::env::remove_var("PAGER");
772        }
773
774        let cmd = PagerConfig::default_pager_command();
775        assert_eq!(cmd, "bat --style=plain");
776
777        unsafe {
778            std::env::remove_var("SQRY_PAGER");
779        }
780    }
781
782    #[test]
783    #[serial]
784    fn test_pager_config_env_pager_fallback() {
785        // SAFETY: Test isolation via serial_test
786        unsafe {
787            std::env::remove_var("SQRY_PAGER");
788            std::env::set_var("PAGER", "more");
789        }
790
791        let cmd = PagerConfig::default_pager_command();
792        assert_eq!(cmd, "more");
793
794        unsafe {
795            std::env::remove_var("PAGER");
796        }
797    }
798
799    #[test]
800    #[serial]
801    fn test_pager_config_env_sqry_pager_priority() {
802        // SQRY_PAGER takes priority over PAGER
803        // SAFETY: Test isolation via serial_test
804        unsafe {
805            std::env::set_var("SQRY_PAGER", "bat");
806            std::env::set_var("PAGER", "less");
807        }
808
809        let cmd = PagerConfig::default_pager_command();
810        assert_eq!(cmd, "bat");
811
812        unsafe {
813            std::env::remove_var("SQRY_PAGER");
814            std::env::remove_var("PAGER");
815        }
816    }
817
818    #[test]
819    #[serial]
820    fn test_pager_config_env_default_fallback() {
821        // Neither env var set - should fall back to "less -FRX"
822        // SAFETY: Test isolation via serial_test
823        unsafe {
824            std::env::remove_var("SQRY_PAGER");
825            std::env::remove_var("PAGER");
826        }
827
828        let cmd = PagerConfig::default_pager_command();
829        assert_eq!(cmd, "less -FRX");
830    }
831
832    #[test]
833    fn test_pager_config_from_cli_flags_no_pager() {
834        let config = PagerConfig::from_cli_flags(false, true, None);
835        assert_eq!(config.enabled, PagerMode::Never);
836    }
837
838    #[test]
839    fn test_pager_config_from_cli_flags_pager() {
840        let config = PagerConfig::from_cli_flags(true, false, None);
841        assert_eq!(config.enabled, PagerMode::Always);
842    }
843
844    #[test]
845    fn test_pager_config_from_cli_flags_auto() {
846        let config = PagerConfig::from_cli_flags(false, false, None);
847        assert_eq!(config.enabled, PagerMode::Auto);
848    }
849
850    #[test]
851    fn test_pager_config_from_cli_flags_custom_cmd() {
852        let config = PagerConfig::from_cli_flags(true, false, Some("bat --color=always"));
853        assert_eq!(config.command, "bat --color=always");
854    }
855
856    // ===== PagerDecision Tests =====
857
858    #[test]
859    fn test_pager_decision_never_mode() {
860        let config = PagerConfig {
861            enabled: PagerMode::Never,
862            ..Default::default()
863        };
864        let decision = PagerDecision::for_testing(config, true, Some(24));
865        assert!(!decision.should_page_rows(1000));
866    }
867
868    #[test]
869    fn test_pager_decision_always_mode() {
870        let config = PagerConfig {
871            enabled: PagerMode::Always,
872            ..Default::default()
873        };
874        let decision = PagerDecision::for_testing(config, true, Some(24));
875        assert!(decision.should_page_rows(1));
876    }
877
878    #[test]
879    fn test_pager_decision_auto_below_threshold() {
880        let config = PagerConfig {
881            enabled: PagerMode::Auto,
882            threshold: Some(100),
883            ..Default::default()
884        };
885        let decision = PagerDecision::for_testing(config, true, Some(24));
886        assert!(!decision.should_page_rows(50));
887    }
888
889    #[test]
890    fn test_pager_decision_auto_above_threshold() {
891        let config = PagerConfig {
892            enabled: PagerMode::Auto,
893            threshold: Some(100),
894            ..Default::default()
895        };
896        let decision = PagerDecision::for_testing(config, true, Some(24));
897        assert!(decision.should_page_rows(150));
898    }
899
900    #[test]
901    fn test_pager_decision_auto_non_tty() {
902        // When not a TTY (piped), should never page in Auto mode
903        let config = PagerConfig {
904            enabled: PagerMode::Auto,
905            ..Default::default()
906        };
907        let decision = PagerDecision::for_testing(config, false, Some(24));
908        assert!(!decision.should_page_rows(1000));
909    }
910
911    #[test]
912    fn test_pager_decision_auto_uses_terminal_height() {
913        let config = PagerConfig {
914            enabled: PagerMode::Auto,
915            threshold: None, // Use terminal height
916            ..Default::default()
917        };
918        let decision = PagerDecision::for_testing(config, true, Some(30));
919        assert!(!decision.should_page_rows(25)); // Below 30
920        assert!(decision.should_page_rows(35)); // Above 30
921    }
922
923    #[test]
924    fn test_pager_decision_auto_default_threshold() {
925        // When no threshold and no terminal height, use default of 24
926        let config = PagerConfig {
927            enabled: PagerMode::Auto,
928            threshold: None,
929            ..Default::default()
930        };
931        let decision = PagerDecision::for_testing(config, true, None);
932        assert!(!decision.should_page_rows(20)); // Below 24
933        assert!(decision.should_page_rows(30)); // Above 24
934    }
935
936    // ===== count_displayed_rows Tests =====
937
938    #[test]
939    fn test_count_displayed_rows_simple() {
940        let content = "line1\nline2\nline3\n";
941        assert_eq!(count_displayed_rows(content, Some(80)), 3);
942    }
943
944    #[test]
945    fn test_count_displayed_rows_empty_lines() {
946        let content = "line1\n\nline3\n";
947        assert_eq!(count_displayed_rows(content, Some(80)), 3);
948    }
949
950    #[test]
951    fn test_count_displayed_rows_long_line_wraps() {
952        // 160 chars should wrap to 2 rows at width 80
953        let long_line = "a".repeat(160);
954        assert_eq!(count_displayed_rows(&long_line, Some(80)), 2);
955    }
956
957    #[test]
958    fn test_count_displayed_rows_exactly_width() {
959        // 80 chars at width 80 = 1 row
960        let exact_line = "a".repeat(80);
961        assert_eq!(count_displayed_rows(&exact_line, Some(80)), 1);
962    }
963
964    #[test]
965    fn test_count_displayed_rows_unicode() {
966        // CJK characters are typically 2-width
967        let cjk = "中文字符"; // 4 CJK chars = 8 width units
968        // At width 80, this fits in 1 row
969        assert_eq!(count_displayed_rows(cjk, Some(80)), 1);
970
971        // At width 4, this would be 2 rows (8/4 = 2)
972        assert_eq!(count_displayed_rows(cjk, Some(4)), 2);
973    }
974
975    #[test]
976    fn test_count_displayed_rows_default_width() {
977        let content = "test line\n";
978        // Default width is 80
979        assert_eq!(count_displayed_rows(content, None), 1);
980    }
981
982    // ===== PagerWriter Tests =====
983
984    #[test]
985    fn test_pager_writer_spawn_invalid_syntax() {
986        // Unterminated quote
987        let result = PagerWriter::spawn("less \"unclosed");
988        assert!(result.is_err());
989        // PagerWriter doesn't implement Debug, so we can't use unwrap_err()
990        // Instead, check the error kind directly
991        let err = result.err().expect("Should be an error");
992        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
993    }
994
995    #[test]
996    fn test_pager_writer_spawn_empty_command() {
997        let result = PagerWriter::spawn("");
998        assert!(result.is_err());
999        let err = result.err().expect("Should be an error");
1000        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1001    }
1002
1003    #[test]
1004    fn test_shlex_parsing_simple() {
1005        let parts = shlex::split("less -R").unwrap();
1006        assert_eq!(parts, vec!["less", "-R"]);
1007    }
1008
1009    #[test]
1010    fn test_shlex_parsing_quoted() {
1011        let parts = shlex::split("\"bat\" --style=plain").unwrap();
1012        assert_eq!(parts, vec!["bat", "--style=plain"]);
1013    }
1014
1015    #[test]
1016    fn test_shlex_parsing_windows_path() {
1017        let parts = shlex::split("\"C:\\Program Files\\Git\\usr\\bin\\less.exe\" -R").unwrap();
1018        assert_eq!(
1019            parts,
1020            vec!["C:\\Program Files\\Git\\usr\\bin\\less.exe", "-R"]
1021        );
1022    }
1023
1024    // ===== BufferedOutput Tests =====
1025
1026    #[test]
1027    fn test_buffered_output_never_mode_writes_directly() {
1028        // In Never mode, output goes directly to stdout (can't easily test stdout,
1029        // but we can verify mode selection)
1030        let config = PagerConfig {
1031            enabled: PagerMode::Never,
1032            ..Default::default()
1033        };
1034        let output = BufferedOutput::new(config);
1035        assert!(matches!(output.mode, OutputMode::Direct));
1036    }
1037
1038    #[test]
1039    fn test_buffered_output_auto_mode_non_tty_streams_directly() {
1040        // In Auto mode, non-TTY output goes directly to stdout (no buffering)
1041        // CI/test environments are typically non-TTY
1042        let config = PagerConfig {
1043            enabled: PagerMode::Auto,
1044            ..Default::default()
1045        };
1046        let output = BufferedOutput::new(config);
1047        // In test environment (non-TTY), should use Direct mode
1048        assert!(
1049            matches!(output.mode, OutputMode::Direct)
1050                || matches!(output.mode, OutputMode::Buffering),
1051            "Expected Direct (non-TTY) or Buffering (TTY), got neither"
1052        );
1053    }
1054
1055    // ===== is_broken_pipe_exit Tests =====
1056
1057    #[test]
1058    #[cfg(unix)]
1059    fn test_is_broken_pipe_exit_sigpipe() {
1060        use std::os::unix::process::ExitStatusExt;
1061        // SIGPIPE = 13
1062        let status = ExitStatus::from_raw(13 << 8 | 0x7f); // Signal 13, stopped
1063        // This is tricky to test directly, so we just ensure the function compiles
1064        let _ = is_broken_pipe_exit(status);
1065    }
1066
1067    // ===== Integration-style Tests =====
1068
1069    #[test]
1070    fn test_buffer_cap_constant() {
1071        // Verify the cap is 1MB
1072        assert_eq!(BUFFER_CAP_BYTES, 1024 * 1024);
1073    }
1074
1075    // ===== PagerExitStatus Tests =====
1076
1077    #[test]
1078    fn test_pager_exit_status_success() {
1079        let status = PagerExitStatus::Success;
1080        assert!(status.is_success());
1081        assert_eq!(status.exit_code(), None);
1082    }
1083
1084    #[test]
1085    fn test_pager_exit_status_exit_code() {
1086        let status = PagerExitStatus::ExitCode(42);
1087        assert!(!status.is_success());
1088        assert_eq!(status.exit_code(), Some(42));
1089    }
1090
1091    #[test]
1092    fn test_pager_exit_status_signal() {
1093        // Signal 9 (SIGKILL) should return 128 + 9 = 137
1094        let status = PagerExitStatus::Signal(9);
1095        assert!(!status.is_success());
1096        assert_eq!(status.exit_code(), Some(137));
1097    }
1098
1099    // ===== ANSI Stripping Tests =====
1100
1101    #[test]
1102    fn test_strip_ansi_plain_text() {
1103        assert_eq!(strip_ansi("hello world"), "hello world");
1104    }
1105
1106    #[test]
1107    fn test_strip_ansi_csi_color() {
1108        // Red text: ESC[31m hello ESC[0m
1109        let colored = "\x1b[31mhello\x1b[0m";
1110        assert_eq!(strip_ansi(colored), "hello");
1111    }
1112
1113    #[test]
1114    fn test_strip_ansi_multiple_codes() {
1115        // Bold red: ESC[1;31m hello ESC[0m
1116        let colored = "\x1b[1;31mhello\x1b[0m world";
1117        assert_eq!(strip_ansi(colored), "hello world");
1118    }
1119
1120    #[test]
1121    fn test_strip_ansi_osc_sequence() {
1122        // OSC sequence: ESC ] 0 ; title BEL
1123        let with_osc = "before\x1b]0;window title\x07after";
1124        assert_eq!(strip_ansi(with_osc), "beforeafter");
1125    }
1126
1127    #[test]
1128    fn test_strip_ansi_preserves_unicode() {
1129        let text = "\x1b[32m日本語\x1b[0m";
1130        assert_eq!(strip_ansi(text), "日本語");
1131    }
1132
1133    // ===== Tab Width Tests =====
1134
1135    #[test]
1136    fn test_displayed_line_width_no_tabs() {
1137        assert_eq!(displayed_line_width("hello"), 5);
1138    }
1139
1140    #[test]
1141    fn test_displayed_line_width_single_tab_start() {
1142        // Tab at start expands to position 8
1143        assert_eq!(displayed_line_width("\thello"), 8 + 5);
1144    }
1145
1146    #[test]
1147    fn test_displayed_line_width_tab_after_text() {
1148        // "hi" (2 chars) + tab expands to position 8
1149        assert_eq!(displayed_line_width("hi\tworld"), 8 + 5);
1150    }
1151
1152    #[test]
1153    fn test_displayed_line_width_multiple_tabs() {
1154        // Tab to 8, tab to 16
1155        assert_eq!(displayed_line_width("\t\t"), 16);
1156    }
1157
1158    #[test]
1159    fn test_displayed_line_width_cjk() {
1160        // CJK characters are 2 columns wide
1161        assert_eq!(displayed_line_width("日本"), 4);
1162    }
1163
1164    // ===== count_displayed_rows with ANSI =====
1165
1166    #[test]
1167    fn test_count_displayed_rows_strips_ansi() {
1168        // Without stripping, ANSI codes would inflate the count
1169        let colored = "\x1b[31mhello\x1b[0m"; // "hello" with color codes
1170        // "hello" is 5 chars, fits in 80 columns = 1 row
1171        assert_eq!(count_displayed_rows(colored, Some(80)), 1);
1172    }
1173
1174    #[test]
1175    fn test_count_displayed_rows_with_tabs() {
1176        // "hi\tworld" = position 8 + 5 = 13 chars
1177        // In 80-column terminal, fits in 1 row
1178        assert_eq!(count_displayed_rows("hi\tworld", Some(80)), 1);
1179
1180        // In 10-column terminal: 13 chars needs ceiling(13/10) = 2 rows
1181        assert_eq!(count_displayed_rows("hi\tworld", Some(10)), 2);
1182    }
1183
1184    #[test]
1185    fn test_count_displayed_rows_ansi_and_tabs_combined() {
1186        // Colored text with tabs
1187        let content = "\x1b[32m\tindented\x1b[0m";
1188        // After stripping: "\tindented" = 8 + 8 = 16 chars
1189        assert_eq!(count_displayed_rows(content, Some(80)), 1);
1190        assert_eq!(count_displayed_rows(content, Some(10)), 2);
1191    }
1192
1193    // ===== Incremental Line Counting Tests =====
1194
1195    #[test]
1196    fn test_incremental_line_counting_single_write() {
1197        // Create a BufferedOutput that forces Buffering mode for testing
1198        let config = PagerConfig::default();
1199        let mut output = BufferedOutput::new_for_testing(config);
1200
1201        // Write 5 complete lines
1202        output.write("line1\nline2\nline3\nline4\nline5\n").unwrap();
1203
1204        assert_eq!(output.complete_lines, 5);
1205        assert_eq!(output.partial_line_len, 0);
1206    }
1207
1208    #[test]
1209    fn test_incremental_line_counting_chunked_writes() {
1210        // This is the case that was broken before the fix
1211        // Tests the pattern used by write_result: content followed by newline
1212        let config = PagerConfig::default();
1213        let mut output = BufferedOutput::new_for_testing(config);
1214
1215        // Simulate how write_result sends content and newlines separately
1216        output.write("line1").unwrap();
1217        assert_eq!(output.complete_lines, 0);
1218        assert_eq!(output.partial_line_len, 5);
1219
1220        output.write("\n").unwrap();
1221        assert_eq!(output.complete_lines, 1);
1222        assert_eq!(output.partial_line_len, 0);
1223
1224        output.write("line2").unwrap();
1225        assert_eq!(output.complete_lines, 1);
1226        assert_eq!(output.partial_line_len, 5);
1227
1228        output.write("\n").unwrap();
1229        assert_eq!(output.complete_lines, 2);
1230        assert_eq!(output.partial_line_len, 0);
1231
1232        // Continue for more lines
1233        for i in 3..=10 {
1234            output.write(&format!("line{i}")).unwrap();
1235            output.write("\n").unwrap();
1236        }
1237
1238        // Should have 10 complete lines
1239        assert_eq!(output.complete_lines, 10);
1240        assert_eq!(output.partial_line_len, 0);
1241    }
1242
1243    #[test]
1244    fn test_incremental_line_counting_mixed_writes() {
1245        let config = PagerConfig::default();
1246        let mut output = BufferedOutput::new_for_testing(config);
1247
1248        // Mix of complete lines and chunked writes
1249        output.write("line1\nline2\n").unwrap();
1250        assert_eq!(output.complete_lines, 2);
1251        assert_eq!(output.partial_line_len, 0);
1252
1253        output.write("partial").unwrap();
1254        assert_eq!(output.complete_lines, 2);
1255        assert_eq!(output.partial_line_len, 7);
1256
1257        output.write(" more").unwrap();
1258        assert_eq!(output.complete_lines, 2);
1259        assert_eq!(output.partial_line_len, 12);
1260
1261        output.write("\nline4\n").unwrap();
1262        assert_eq!(output.complete_lines, 4);
1263        assert_eq!(output.partial_line_len, 0);
1264    }
1265
1266    #[test]
1267    fn test_incremental_line_counting_multiple_newlines_in_one_write() {
1268        let config = PagerConfig::default();
1269        let mut output = BufferedOutput::new_for_testing(config);
1270
1271        // Write content with multiple embedded newlines
1272        output.write("a\nb\nc\nd\ne").unwrap();
1273        assert_eq!(output.complete_lines, 4); // 4 newlines = 4 complete lines
1274        assert_eq!(output.partial_line_len, 1); // "e" is partial
1275    }
1276}