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    pub fn new_for_testing(config: PagerConfig) -> Self {
429        let decision = PagerDecision::new(config.clone());
430        let terminal_width = PagerDecision::detect_terminal_width();
431
432        Self {
433            buffer: String::new(),
434            config,
435            decision,
436            mode: OutputMode::Buffering, // Always buffer for testing
437            terminal_width,
438            complete_lines: 0,
439            partial_line_len: 0,
440            spawn_error: None,
441        }
442    }
443
444    fn write_direct(content: &str) -> io::Result<()> {
445        std::io::stdout().write_all(content.as_bytes())
446    }
447
448    fn write_pager(pager: &mut PagerWriter, content: &str) -> io::Result<()> {
449        match pager.write(content) {
450            Ok(()) => Ok(()),
451            Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
452            Err(e) => Err(e),
453        }
454    }
455
456    fn update_line_counts(&mut self, content: &str) {
457        let newline_count = content.bytes().filter(|&b| b == b'\n').count();
458        self.complete_lines += newline_count;
459        self.update_partial_line_len(content);
460    }
461
462    fn update_partial_line_len(&mut self, content: &str) {
463        if let Some(last_nl_offset) = content.rfind('\n') {
464            self.partial_line_len = content.len().saturating_sub(last_nl_offset + 1);
465        } else {
466            self.partial_line_len += content.len();
467        }
468    }
469
470    fn displayed_row_estimate(&self) -> usize {
471        self.complete_lines + usize::from(self.partial_line_len > 0)
472    }
473
474    fn should_transition_to_pager(&self, displayed_rows: usize) -> bool {
475        self.decision.should_page_rows(displayed_rows) || self.buffer.len() > BUFFER_CAP_BYTES
476    }
477
478    fn transition_to_pager(&mut self) -> io::Result<()> {
479        match PagerWriter::spawn(&self.config.command) {
480            Ok(mut pager) => {
481                pager.write(&self.buffer)?;
482                self.buffer.clear();
483                self.mode = OutputMode::Pager(pager);
484                Ok(())
485            }
486            Err(e) => self.handle_pager_spawn_error(e),
487        }
488    }
489
490    fn handle_pager_spawn_error(&mut self, err: io::Error) -> io::Result<()> {
491        let pager_name = self
492            .config
493            .command
494            .split_whitespace()
495            .next()
496            .unwrap_or(&self.config.command);
497        if err.kind() == io::ErrorKind::NotFound {
498            eprintln!(
499                "Warning: pager '{pager_name}' not found. Output will not be paged. \
500                 To enable paging, install '{pager_name}' or set the SQRY_PAGER environment variable."
501            );
502        } else {
503            eprintln!(
504                "Error: Failed to start pager '{pager_name}': {err}. \
505                 Please check that the binary is correct and executable, \
506                 or set a different pager using the SQRY_PAGER environment variable."
507            );
508            self.spawn_error = Some(err);
509        }
510
511        Self::write_direct(&self.buffer)?;
512        self.buffer.clear();
513        self.mode = OutputMode::Direct;
514        Ok(())
515    }
516
517    /// Write content, handling mode transitions
518    ///
519    /// # Errors
520    ///
521    /// Returns an error if writing to stdout or pager fails.
522    pub fn write(&mut self, content: &str) -> io::Result<()> {
523        match &mut self.mode {
524            OutputMode::Direct => {
525                // Write directly to stdout
526                Self::write_direct(content)
527            }
528            OutputMode::Pager(pager) => {
529                // Stream to pager, handling broken pipe gracefully
530                Self::write_pager(pager, content)
531            }
532            OutputMode::Buffering => {
533                // Append to buffer first
534                self.buffer.push_str(content);
535
536                // Incremental line counting: count newlines in the new content
537                // This is O(n) in the new content, not O(n) in the entire buffer
538                self.update_line_counts(content);
539
540                // Calculate displayed rows:
541                // - Each complete line is 1+ rows (depends on wrapping)
542                // - Partial line at end is 1 row (if non-empty)
543                // For simplicity in threshold checking, use complete_lines + 1 if partial exists
544                // This is a conservative estimate that may trigger paging slightly early
545                let displayed_rows = self.displayed_row_estimate();
546
547                // Check thresholds
548                if self.should_transition_to_pager(displayed_rows) {
549                    // Threshold or buffer cap exceeded: transition to pager mode
550                    // Note: Non-TTY output uses Direct mode from the start, so we only
551                    // reach here when is_tty() is true
552                    self.transition_to_pager()?;
553                }
554                // Otherwise: continue buffering (below threshold and cap)
555                Ok(())
556            }
557        }
558    }
559
560    /// Finalize output, flushing any buffered content
561    ///
562    /// Returns a `PagerExitStatus` indicating how the pager terminated:
563    /// - `Success`: Pager exited normally (code 0, SIGPIPE, or no pager used)
564    /// - `ExitCode(n)`: Pager exited with non-zero code
565    /// - `Signal(n)`: Pager was terminated by signal (non-SIGPIPE)
566    ///
567    /// # Errors
568    ///
569    /// Returns an error if flushing or waiting for pager fails, or if there
570    /// was a deferred pager spawn error (non-NotFound spawn failures).
571    pub fn finish(self) -> io::Result<PagerExitStatus> {
572        // Check for deferred spawn error first (non-NotFound spawn failures)
573        // Per CLI spec: spawn failures (other than not-found) should exit 1
574        if let Some(spawn_err) = self.spawn_error {
575            return Err(spawn_err);
576        }
577
578        match self.mode {
579            OutputMode::Direct => Ok(PagerExitStatus::Success),
580            OutputMode::Pager(pager) => {
581                let status = pager.wait()?;
582                Ok(exit_status_to_pager_status(status))
583            }
584            OutputMode::Buffering => {
585                // Never transitioned, output is small - write directly
586                std::io::stdout().write_all(self.buffer.as_bytes())?;
587                Ok(PagerExitStatus::Success)
588            }
589        }
590    }
591}
592
593/// Check if exit status indicates broken pipe (user quit pager early)
594#[must_use]
595fn is_broken_pipe_exit(status: ExitStatus) -> bool {
596    #[cfg(unix)]
597    {
598        use std::os::unix::process::ExitStatusExt;
599        // SIGPIPE = signal 13
600        status.signal() == Some(13)
601    }
602    #[cfg(not(unix))]
603    {
604        // On Windows, treat exit code 0 or 1 as "user quit"
605        matches!(status.code(), Some(0) | Some(1))
606    }
607}
608
609/// Convert process exit status to `PagerExitStatus`
610///
611/// Handles the differences between Unix and Windows:
612/// - Unix: Distinguishes exit codes from signal terminations
613/// - Windows: Only has exit codes
614fn exit_status_to_pager_status(status: ExitStatus) -> PagerExitStatus {
615    // Success or SIGPIPE is treated as normal user quit
616    if status.success() || is_broken_pipe_exit(status) {
617        return PagerExitStatus::Success;
618    }
619
620    #[cfg(unix)]
621    {
622        use std::os::unix::process::ExitStatusExt;
623        // Check for signal termination (excluding SIGPIPE which was handled above)
624        if let Some(signal) = status.signal() {
625            return PagerExitStatus::Signal(signal);
626        }
627    }
628
629    // Non-zero exit code
630    if let Some(code) = status.code() {
631        PagerExitStatus::ExitCode(code)
632    } else {
633        // Shouldn't happen: not success, no signal, no code
634        // Default to exit code 1
635        PagerExitStatus::ExitCode(1)
636    }
637}
638
639/// Standard tab width for display calculation
640#[allow(dead_code)]
641const TAB_WIDTH: usize = 8;
642
643fn skip_csi_sequence(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
644    while let Some(&next) = chars.peek() {
645        chars.next();
646        if (0x40..=0x7E).contains(&(next as u8)) {
647            break;
648        }
649    }
650}
651
652fn skip_osc_sequence(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
653    while let Some(&next) = chars.peek() {
654        if next == '\x07' {
655            chars.next();
656            break;
657        }
658        if next == '\x1b' {
659            chars.next();
660            if chars.peek() == Some(&'\\') {
661                chars.next();
662            }
663            break;
664        }
665        chars.next();
666    }
667}
668
669/// Strip ANSI escape sequences from a string
670///
671/// Removes CSI sequences (ESC [ ... `final_byte`) and OSC sequences (ESC ] ... ST).
672/// This ensures ANSI color codes don't inflate width calculations.
673#[allow(dead_code)]
674fn strip_ansi(s: &str) -> String {
675    let mut result = String::with_capacity(s.len());
676    let mut chars = s.chars().peekable();
677
678    while let Some(c) = chars.next() {
679        if c == '\x1b' {
680            // Start of escape sequence
681            match chars.peek().copied() {
682                Some('[') => {
683                    chars.next();
684                    skip_csi_sequence(&mut chars);
685                }
686                Some(']') => {
687                    chars.next();
688                    skip_osc_sequence(&mut chars);
689                }
690                _ => {}
691            }
692        } else {
693            result.push(c);
694        }
695    }
696    result
697}
698
699/// Calculate displayed width of a line, accounting for tabs
700///
701/// Tabs expand to the next tab stop (every 8 columns by default).
702#[allow(dead_code)]
703fn displayed_line_width(line: &str) -> usize {
704    let mut width = 0;
705    for c in line.chars() {
706        if c == '\t' {
707            // Expand to next tab stop
708            width = (width / TAB_WIDTH + 1) * TAB_WIDTH;
709        } else {
710            width += UnicodeWidthChar::width(c).unwrap_or(0);
711        }
712    }
713    width
714}
715
716/// Count displayed rows accounting for line wrapping
717///
718/// Uses unicode-width for accurate character width calculation,
719/// which handles CJK characters, emoji, and other wide characters.
720/// Strips ANSI escape sequences and expands tabs before calculation.
721#[allow(dead_code)]
722#[must_use]
723pub fn count_displayed_rows(content: &str, terminal_width: Option<usize>) -> usize {
724    let width = terminal_width.unwrap_or(80);
725
726    content
727        .lines()
728        .map(|line| {
729            // Strip ANSI escape sequences before width calculation
730            let clean_line = strip_ansi(line);
731            let line_width = displayed_line_width(&clean_line);
732            if line_width == 0 {
733                1 // Empty line still takes 1 row
734            } else {
735                // Ceiling division: how many terminal rows does this line span?
736                line_width.div_ceil(width)
737            }
738        })
739        .sum()
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745    use serial_test::serial;
746
747    // ===== PagerMode Tests =====
748
749    #[test]
750    fn test_pager_mode_default() {
751        assert_eq!(PagerMode::default(), PagerMode::Auto);
752    }
753
754    // ===== PagerConfig Tests =====
755
756    #[test]
757    fn test_pager_config_default() {
758        let config = PagerConfig::default();
759        assert_eq!(config.enabled, PagerMode::Auto);
760        assert!(config.threshold.is_none());
761        // Command depends on environment, don't assert exact value
762    }
763
764    #[test]
765    #[serial]
766    fn test_pager_config_env_sqry_pager() {
767        // SAFETY: Test isolation via serial_test
768        unsafe {
769            std::env::set_var("SQRY_PAGER", "bat --style=plain");
770            std::env::remove_var("PAGER");
771        }
772
773        let cmd = PagerConfig::default_pager_command();
774        assert_eq!(cmd, "bat --style=plain");
775
776        unsafe {
777            std::env::remove_var("SQRY_PAGER");
778        }
779    }
780
781    #[test]
782    #[serial]
783    fn test_pager_config_env_pager_fallback() {
784        // SAFETY: Test isolation via serial_test
785        unsafe {
786            std::env::remove_var("SQRY_PAGER");
787            std::env::set_var("PAGER", "more");
788        }
789
790        let cmd = PagerConfig::default_pager_command();
791        assert_eq!(cmd, "more");
792
793        unsafe {
794            std::env::remove_var("PAGER");
795        }
796    }
797
798    #[test]
799    #[serial]
800    fn test_pager_config_env_sqry_pager_priority() {
801        // SQRY_PAGER takes priority over PAGER
802        // SAFETY: Test isolation via serial_test
803        unsafe {
804            std::env::set_var("SQRY_PAGER", "bat");
805            std::env::set_var("PAGER", "less");
806        }
807
808        let cmd = PagerConfig::default_pager_command();
809        assert_eq!(cmd, "bat");
810
811        unsafe {
812            std::env::remove_var("SQRY_PAGER");
813            std::env::remove_var("PAGER");
814        }
815    }
816
817    #[test]
818    #[serial]
819    fn test_pager_config_env_default_fallback() {
820        // Neither env var set - should fall back to "less -FRX"
821        // SAFETY: Test isolation via serial_test
822        unsafe {
823            std::env::remove_var("SQRY_PAGER");
824            std::env::remove_var("PAGER");
825        }
826
827        let cmd = PagerConfig::default_pager_command();
828        assert_eq!(cmd, "less -FRX");
829    }
830
831    #[test]
832    fn test_pager_config_from_cli_flags_no_pager() {
833        let config = PagerConfig::from_cli_flags(false, true, None);
834        assert_eq!(config.enabled, PagerMode::Never);
835    }
836
837    #[test]
838    fn test_pager_config_from_cli_flags_pager() {
839        let config = PagerConfig::from_cli_flags(true, false, None);
840        assert_eq!(config.enabled, PagerMode::Always);
841    }
842
843    #[test]
844    fn test_pager_config_from_cli_flags_auto() {
845        let config = PagerConfig::from_cli_flags(false, false, None);
846        assert_eq!(config.enabled, PagerMode::Auto);
847    }
848
849    #[test]
850    fn test_pager_config_from_cli_flags_custom_cmd() {
851        let config = PagerConfig::from_cli_flags(true, false, Some("bat --color=always"));
852        assert_eq!(config.command, "bat --color=always");
853    }
854
855    // ===== PagerDecision Tests =====
856
857    #[test]
858    fn test_pager_decision_never_mode() {
859        let config = PagerConfig {
860            enabled: PagerMode::Never,
861            ..Default::default()
862        };
863        let decision = PagerDecision::for_testing(config, true, Some(24));
864        assert!(!decision.should_page_rows(1000));
865    }
866
867    #[test]
868    fn test_pager_decision_always_mode() {
869        let config = PagerConfig {
870            enabled: PagerMode::Always,
871            ..Default::default()
872        };
873        let decision = PagerDecision::for_testing(config, true, Some(24));
874        assert!(decision.should_page_rows(1));
875    }
876
877    #[test]
878    fn test_pager_decision_auto_below_threshold() {
879        let config = PagerConfig {
880            enabled: PagerMode::Auto,
881            threshold: Some(100),
882            ..Default::default()
883        };
884        let decision = PagerDecision::for_testing(config, true, Some(24));
885        assert!(!decision.should_page_rows(50));
886    }
887
888    #[test]
889    fn test_pager_decision_auto_above_threshold() {
890        let config = PagerConfig {
891            enabled: PagerMode::Auto,
892            threshold: Some(100),
893            ..Default::default()
894        };
895        let decision = PagerDecision::for_testing(config, true, Some(24));
896        assert!(decision.should_page_rows(150));
897    }
898
899    #[test]
900    fn test_pager_decision_auto_non_tty() {
901        // When not a TTY (piped), should never page in Auto mode
902        let config = PagerConfig {
903            enabled: PagerMode::Auto,
904            ..Default::default()
905        };
906        let decision = PagerDecision::for_testing(config, false, Some(24));
907        assert!(!decision.should_page_rows(1000));
908    }
909
910    #[test]
911    fn test_pager_decision_auto_uses_terminal_height() {
912        let config = PagerConfig {
913            enabled: PagerMode::Auto,
914            threshold: None, // Use terminal height
915            ..Default::default()
916        };
917        let decision = PagerDecision::for_testing(config, true, Some(30));
918        assert!(!decision.should_page_rows(25)); // Below 30
919        assert!(decision.should_page_rows(35)); // Above 30
920    }
921
922    #[test]
923    fn test_pager_decision_auto_default_threshold() {
924        // When no threshold and no terminal height, use default of 24
925        let config = PagerConfig {
926            enabled: PagerMode::Auto,
927            threshold: None,
928            ..Default::default()
929        };
930        let decision = PagerDecision::for_testing(config, true, None);
931        assert!(!decision.should_page_rows(20)); // Below 24
932        assert!(decision.should_page_rows(30)); // Above 24
933    }
934
935    // ===== count_displayed_rows Tests =====
936
937    #[test]
938    fn test_count_displayed_rows_simple() {
939        let content = "line1\nline2\nline3\n";
940        assert_eq!(count_displayed_rows(content, Some(80)), 3);
941    }
942
943    #[test]
944    fn test_count_displayed_rows_empty_lines() {
945        let content = "line1\n\nline3\n";
946        assert_eq!(count_displayed_rows(content, Some(80)), 3);
947    }
948
949    #[test]
950    fn test_count_displayed_rows_long_line_wraps() {
951        // 160 chars should wrap to 2 rows at width 80
952        let long_line = "a".repeat(160);
953        assert_eq!(count_displayed_rows(&long_line, Some(80)), 2);
954    }
955
956    #[test]
957    fn test_count_displayed_rows_exactly_width() {
958        // 80 chars at width 80 = 1 row
959        let exact_line = "a".repeat(80);
960        assert_eq!(count_displayed_rows(&exact_line, Some(80)), 1);
961    }
962
963    #[test]
964    fn test_count_displayed_rows_unicode() {
965        // CJK characters are typically 2-width
966        let cjk = "中文字符"; // 4 CJK chars = 8 width units
967        // At width 80, this fits in 1 row
968        assert_eq!(count_displayed_rows(cjk, Some(80)), 1);
969
970        // At width 4, this would be 2 rows (8/4 = 2)
971        assert_eq!(count_displayed_rows(cjk, Some(4)), 2);
972    }
973
974    #[test]
975    fn test_count_displayed_rows_default_width() {
976        let content = "test line\n";
977        // Default width is 80
978        assert_eq!(count_displayed_rows(content, None), 1);
979    }
980
981    // ===== PagerWriter Tests =====
982
983    #[test]
984    fn test_pager_writer_spawn_invalid_syntax() {
985        // Unterminated quote
986        let result = PagerWriter::spawn("less \"unclosed");
987        assert!(result.is_err());
988        // PagerWriter doesn't implement Debug, so we can't use unwrap_err()
989        // Instead, check the error kind directly
990        let err = result.err().expect("Should be an error");
991        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
992    }
993
994    #[test]
995    fn test_pager_writer_spawn_empty_command() {
996        let result = PagerWriter::spawn("");
997        assert!(result.is_err());
998        let err = result.err().expect("Should be an error");
999        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1000    }
1001
1002    #[test]
1003    fn test_shlex_parsing_simple() {
1004        let parts = shlex::split("less -R").unwrap();
1005        assert_eq!(parts, vec!["less", "-R"]);
1006    }
1007
1008    #[test]
1009    fn test_shlex_parsing_quoted() {
1010        let parts = shlex::split("\"bat\" --style=plain").unwrap();
1011        assert_eq!(parts, vec!["bat", "--style=plain"]);
1012    }
1013
1014    #[test]
1015    fn test_shlex_parsing_windows_path() {
1016        let parts = shlex::split("\"C:\\Program Files\\Git\\usr\\bin\\less.exe\" -R").unwrap();
1017        assert_eq!(
1018            parts,
1019            vec!["C:\\Program Files\\Git\\usr\\bin\\less.exe", "-R"]
1020        );
1021    }
1022
1023    // ===== BufferedOutput Tests =====
1024
1025    #[test]
1026    fn test_buffered_output_never_mode_writes_directly() {
1027        // In Never mode, output goes directly to stdout (can't easily test stdout,
1028        // but we can verify mode selection)
1029        let config = PagerConfig {
1030            enabled: PagerMode::Never,
1031            ..Default::default()
1032        };
1033        let output = BufferedOutput::new(config);
1034        assert!(matches!(output.mode, OutputMode::Direct));
1035    }
1036
1037    #[test]
1038    fn test_buffered_output_auto_mode_non_tty_streams_directly() {
1039        // In Auto mode, non-TTY output goes directly to stdout (no buffering)
1040        // CI/test environments are typically non-TTY
1041        let config = PagerConfig {
1042            enabled: PagerMode::Auto,
1043            ..Default::default()
1044        };
1045        let output = BufferedOutput::new(config);
1046        // In test environment (non-TTY), should use Direct mode
1047        assert!(
1048            matches!(output.mode, OutputMode::Direct)
1049                || matches!(output.mode, OutputMode::Buffering),
1050            "Expected Direct (non-TTY) or Buffering (TTY), got neither"
1051        );
1052    }
1053
1054    // ===== is_broken_pipe_exit Tests =====
1055
1056    #[test]
1057    #[cfg(unix)]
1058    fn test_is_broken_pipe_exit_sigpipe() {
1059        use std::os::unix::process::ExitStatusExt;
1060        // SIGPIPE = 13
1061        let status = ExitStatus::from_raw(13 << 8 | 0x7f); // Signal 13, stopped
1062        // This is tricky to test directly, so we just ensure the function compiles
1063        let _ = is_broken_pipe_exit(status);
1064    }
1065
1066    // ===== Integration-style Tests =====
1067
1068    #[test]
1069    fn test_buffer_cap_constant() {
1070        // Verify the cap is 1MB
1071        assert_eq!(BUFFER_CAP_BYTES, 1024 * 1024);
1072    }
1073
1074    // ===== PagerExitStatus Tests =====
1075
1076    #[test]
1077    fn test_pager_exit_status_success() {
1078        let status = PagerExitStatus::Success;
1079        assert!(status.is_success());
1080        assert_eq!(status.exit_code(), None);
1081    }
1082
1083    #[test]
1084    fn test_pager_exit_status_exit_code() {
1085        let status = PagerExitStatus::ExitCode(42);
1086        assert!(!status.is_success());
1087        assert_eq!(status.exit_code(), Some(42));
1088    }
1089
1090    #[test]
1091    fn test_pager_exit_status_signal() {
1092        // Signal 9 (SIGKILL) should return 128 + 9 = 137
1093        let status = PagerExitStatus::Signal(9);
1094        assert!(!status.is_success());
1095        assert_eq!(status.exit_code(), Some(137));
1096    }
1097
1098    // ===== ANSI Stripping Tests =====
1099
1100    #[test]
1101    fn test_strip_ansi_plain_text() {
1102        assert_eq!(strip_ansi("hello world"), "hello world");
1103    }
1104
1105    #[test]
1106    fn test_strip_ansi_csi_color() {
1107        // Red text: ESC[31m hello ESC[0m
1108        let colored = "\x1b[31mhello\x1b[0m";
1109        assert_eq!(strip_ansi(colored), "hello");
1110    }
1111
1112    #[test]
1113    fn test_strip_ansi_multiple_codes() {
1114        // Bold red: ESC[1;31m hello ESC[0m
1115        let colored = "\x1b[1;31mhello\x1b[0m world";
1116        assert_eq!(strip_ansi(colored), "hello world");
1117    }
1118
1119    #[test]
1120    fn test_strip_ansi_osc_sequence() {
1121        // OSC sequence: ESC ] 0 ; title BEL
1122        let with_osc = "before\x1b]0;window title\x07after";
1123        assert_eq!(strip_ansi(with_osc), "beforeafter");
1124    }
1125
1126    #[test]
1127    fn test_strip_ansi_preserves_unicode() {
1128        let text = "\x1b[32m日本語\x1b[0m";
1129        assert_eq!(strip_ansi(text), "日本語");
1130    }
1131
1132    // ===== Tab Width Tests =====
1133
1134    #[test]
1135    fn test_displayed_line_width_no_tabs() {
1136        assert_eq!(displayed_line_width("hello"), 5);
1137    }
1138
1139    #[test]
1140    fn test_displayed_line_width_single_tab_start() {
1141        // Tab at start expands to position 8
1142        assert_eq!(displayed_line_width("\thello"), 8 + 5);
1143    }
1144
1145    #[test]
1146    fn test_displayed_line_width_tab_after_text() {
1147        // "hi" (2 chars) + tab expands to position 8
1148        assert_eq!(displayed_line_width("hi\tworld"), 8 + 5);
1149    }
1150
1151    #[test]
1152    fn test_displayed_line_width_multiple_tabs() {
1153        // Tab to 8, tab to 16
1154        assert_eq!(displayed_line_width("\t\t"), 16);
1155    }
1156
1157    #[test]
1158    fn test_displayed_line_width_cjk() {
1159        // CJK characters are 2 columns wide
1160        assert_eq!(displayed_line_width("日本"), 4);
1161    }
1162
1163    // ===== count_displayed_rows with ANSI =====
1164
1165    #[test]
1166    fn test_count_displayed_rows_strips_ansi() {
1167        // Without stripping, ANSI codes would inflate the count
1168        let colored = "\x1b[31mhello\x1b[0m"; // "hello" with color codes
1169        // "hello" is 5 chars, fits in 80 columns = 1 row
1170        assert_eq!(count_displayed_rows(colored, Some(80)), 1);
1171    }
1172
1173    #[test]
1174    fn test_count_displayed_rows_with_tabs() {
1175        // "hi\tworld" = position 8 + 5 = 13 chars
1176        // In 80-column terminal, fits in 1 row
1177        assert_eq!(count_displayed_rows("hi\tworld", Some(80)), 1);
1178
1179        // In 10-column terminal: 13 chars needs ceiling(13/10) = 2 rows
1180        assert_eq!(count_displayed_rows("hi\tworld", Some(10)), 2);
1181    }
1182
1183    #[test]
1184    fn test_count_displayed_rows_ansi_and_tabs_combined() {
1185        // Colored text with tabs
1186        let content = "\x1b[32m\tindented\x1b[0m";
1187        // After stripping: "\tindented" = 8 + 8 = 16 chars
1188        assert_eq!(count_displayed_rows(content, Some(80)), 1);
1189        assert_eq!(count_displayed_rows(content, Some(10)), 2);
1190    }
1191
1192    // ===== Incremental Line Counting Tests =====
1193
1194    #[test]
1195    fn test_incremental_line_counting_single_write() {
1196        // Create a BufferedOutput that forces Buffering mode for testing
1197        let config = PagerConfig::default();
1198        let mut output = BufferedOutput::new_for_testing(config);
1199
1200        // Write 5 complete lines
1201        output.write("line1\nline2\nline3\nline4\nline5\n").unwrap();
1202
1203        assert_eq!(output.complete_lines, 5);
1204        assert_eq!(output.partial_line_len, 0);
1205    }
1206
1207    #[test]
1208    fn test_incremental_line_counting_chunked_writes() {
1209        // This is the case that was broken before the fix
1210        // Tests the pattern used by write_result: content followed by newline
1211        let config = PagerConfig::default();
1212        let mut output = BufferedOutput::new_for_testing(config);
1213
1214        // Simulate how write_result sends content and newlines separately
1215        output.write("line1").unwrap();
1216        assert_eq!(output.complete_lines, 0);
1217        assert_eq!(output.partial_line_len, 5);
1218
1219        output.write("\n").unwrap();
1220        assert_eq!(output.complete_lines, 1);
1221        assert_eq!(output.partial_line_len, 0);
1222
1223        output.write("line2").unwrap();
1224        assert_eq!(output.complete_lines, 1);
1225        assert_eq!(output.partial_line_len, 5);
1226
1227        output.write("\n").unwrap();
1228        assert_eq!(output.complete_lines, 2);
1229        assert_eq!(output.partial_line_len, 0);
1230
1231        // Continue for more lines
1232        for i in 3..=10 {
1233            output.write(&format!("line{i}")).unwrap();
1234            output.write("\n").unwrap();
1235        }
1236
1237        // Should have 10 complete lines
1238        assert_eq!(output.complete_lines, 10);
1239        assert_eq!(output.partial_line_len, 0);
1240    }
1241
1242    #[test]
1243    fn test_incremental_line_counting_mixed_writes() {
1244        let config = PagerConfig::default();
1245        let mut output = BufferedOutput::new_for_testing(config);
1246
1247        // Mix of complete lines and chunked writes
1248        output.write("line1\nline2\n").unwrap();
1249        assert_eq!(output.complete_lines, 2);
1250        assert_eq!(output.partial_line_len, 0);
1251
1252        output.write("partial").unwrap();
1253        assert_eq!(output.complete_lines, 2);
1254        assert_eq!(output.partial_line_len, 7);
1255
1256        output.write(" more").unwrap();
1257        assert_eq!(output.complete_lines, 2);
1258        assert_eq!(output.partial_line_len, 12);
1259
1260        output.write("\nline4\n").unwrap();
1261        assert_eq!(output.complete_lines, 4);
1262        assert_eq!(output.partial_line_len, 0);
1263    }
1264
1265    #[test]
1266    fn test_incremental_line_counting_multiple_newlines_in_one_write() {
1267        let config = PagerConfig::default();
1268        let mut output = BufferedOutput::new_for_testing(config);
1269
1270        // Write content with multiple embedded newlines
1271        output.write("a\nb\nc\nd\ne").unwrap();
1272        assert_eq!(output.complete_lines, 4); // 4 newlines = 4 complete lines
1273        assert_eq!(output.partial_line_len, 1); // "e" is partial
1274    }
1275}