1use std::io::{self, Write};
35use std::process::{Child, Command, ExitStatus, Stdio};
36use unicode_width::UnicodeWidthChar;
37
38const BUFFER_CAP_BYTES: usize = 1024 * 1024;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum PagerMode {
44 #[default]
46 Auto,
47 Always,
49 Never,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum PagerExitStatus {
56 Success,
58 ExitCode(i32),
60 #[cfg_attr(not(unix), allow(dead_code))]
62 Signal(i32),
63}
64
65impl PagerExitStatus {
66 #[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 #[must_use]
83 #[allow(dead_code)]
84 pub fn is_success(self) -> bool {
85 matches!(self, Self::Success)
86 }
87}
88
89#[derive(Debug, Clone)]
91pub struct PagerConfig {
92 pub command: String,
94 pub enabled: PagerMode,
96 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 #[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 #[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
156pub struct PagerDecision {
158 config: PagerConfig,
159 is_tty: bool,
160 terminal_height: Option<usize>,
161}
162
163impl PagerDecision {
164 #[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 #[must_use]
181 pub fn is_tty(&self) -> bool {
182 self.is_tty
183 }
184
185 #[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; }
197
198 let threshold = self.config.threshold.or(self.terminal_height).unwrap_or(24);
199
200 displayed_rows > threshold
201 }
202 }
203 }
204
205 #[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 #[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#[cfg(test)]
222impl PagerDecision {
223 #[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
235pub struct PagerWriter {
240 child: Child,
241 stdin: std::process::ChildStdin,
242}
243
244impl PagerWriter {
245 pub fn spawn(command: &str) -> io::Result<Self> {
257 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 pub fn write(&mut self, content: &str) -> io::Result<()> {
296 self.stdin.write_all(content.as_bytes())
297 }
298
299 pub fn wait(mut self) -> io::Result<ExitStatus> {
305 drop(self.stdin); 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
320enum OutputMode {
322 Buffering,
324 Pager(PagerWriter),
326 Direct,
328}
329
330pub struct BufferedOutput {
344 buffer: String,
345 config: PagerConfig,
346 decision: PagerDecision,
347 mode: OutputMode,
348 #[allow(dead_code)]
350 terminal_width: Option<usize>,
351 complete_lines: usize,
353 partial_line_len: usize,
355 spawn_error: Option<io::Error>,
357}
358
359impl BufferedOutput {
360 #[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 let (mode, spawn_error) = match config.enabled {
369 PagerMode::Never => (OutputMode::Direct, None),
370 PagerMode::Always => {
371 match PagerWriter::spawn(&config.command) {
373 Ok(pager) => (OutputMode::Pager(pager), None),
374 Err(e) => {
375 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 (OutputMode::Direct, Some(e))
396 }
397 }
398 }
399 }
400 PagerMode::Auto => {
401 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 #[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, 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 pub fn write(&mut self, content: &str) -> io::Result<()> {
524 match &mut self.mode {
525 OutputMode::Direct => {
526 Self::write_direct(content)
528 }
529 OutputMode::Pager(pager) => {
530 Self::write_pager(pager, content)
532 }
533 OutputMode::Buffering => {
534 self.buffer.push_str(content);
536
537 self.update_line_counts(content);
540
541 let displayed_rows = self.displayed_row_estimate();
547
548 if self.should_transition_to_pager(displayed_rows) {
550 self.transition_to_pager()?;
554 }
555 Ok(())
557 }
558 }
559 }
560
561 pub fn finish(self) -> io::Result<PagerExitStatus> {
573 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 std::io::stdout().write_all(self.buffer.as_bytes())?;
588 Ok(PagerExitStatus::Success)
589 }
590 }
591 }
592}
593
594#[must_use]
596fn is_broken_pipe_exit(status: ExitStatus) -> bool {
597 #[cfg(unix)]
598 {
599 use std::os::unix::process::ExitStatusExt;
600 status.signal() == Some(13)
602 }
603 #[cfg(not(unix))]
604 {
605 matches!(status.code(), Some(0) | Some(1))
607 }
608}
609
610fn exit_status_to_pager_status(status: ExitStatus) -> PagerExitStatus {
616 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 if let Some(signal) = status.signal() {
626 return PagerExitStatus::Signal(signal);
627 }
628 }
629
630 if let Some(code) = status.code() {
632 PagerExitStatus::ExitCode(code)
633 } else {
634 PagerExitStatus::ExitCode(1)
637 }
638}
639
640#[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#[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 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#[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 width = (width / TAB_WIDTH + 1) * TAB_WIDTH;
710 } else {
711 width += UnicodeWidthChar::width(c).unwrap_or(0);
712 }
713 }
714 width
715}
716
717#[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 let clean_line = strip_ansi(line);
732 let line_width = displayed_line_width(&clean_line);
733 if line_width == 0 {
734 1 } else {
736 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 #[test]
751 fn test_pager_mode_default() {
752 assert_eq!(PagerMode::default(), PagerMode::Auto);
753 }
754
755 #[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 }
764
765 #[test]
766 #[serial]
767 fn test_pager_config_env_sqry_pager() {
768 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 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 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 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 #[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 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, ..Default::default()
917 };
918 let decision = PagerDecision::for_testing(config, true, Some(30));
919 assert!(!decision.should_page_rows(25)); assert!(decision.should_page_rows(35)); }
922
923 #[test]
924 fn test_pager_decision_auto_default_threshold() {
925 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)); assert!(decision.should_page_rows(30)); }
935
936 #[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 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 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 let cjk = "中文字符"; assert_eq!(count_displayed_rows(cjk, Some(80)), 1);
970
971 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 assert_eq!(count_displayed_rows(content, None), 1);
980 }
981
982 #[test]
985 fn test_pager_writer_spawn_invalid_syntax() {
986 let result = PagerWriter::spawn("less \"unclosed");
988 assert!(result.is_err());
989 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 #[test]
1027 fn test_buffered_output_never_mode_writes_directly() {
1028 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 let config = PagerConfig {
1043 enabled: PagerMode::Auto,
1044 ..Default::default()
1045 };
1046 let output = BufferedOutput::new(config);
1047 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 #[test]
1058 #[cfg(unix)]
1059 fn test_is_broken_pipe_exit_sigpipe() {
1060 use std::os::unix::process::ExitStatusExt;
1061 let status = ExitStatus::from_raw(13 << 8 | 0x7f); let _ = is_broken_pipe_exit(status);
1065 }
1066
1067 #[test]
1070 fn test_buffer_cap_constant() {
1071 assert_eq!(BUFFER_CAP_BYTES, 1024 * 1024);
1073 }
1074
1075 #[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 let status = PagerExitStatus::Signal(9);
1095 assert!(!status.is_success());
1096 assert_eq!(status.exit_code(), Some(137));
1097 }
1098
1099 #[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 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 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 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 #[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 assert_eq!(displayed_line_width("\thello"), 8 + 5);
1144 }
1145
1146 #[test]
1147 fn test_displayed_line_width_tab_after_text() {
1148 assert_eq!(displayed_line_width("hi\tworld"), 8 + 5);
1150 }
1151
1152 #[test]
1153 fn test_displayed_line_width_multiple_tabs() {
1154 assert_eq!(displayed_line_width("\t\t"), 16);
1156 }
1157
1158 #[test]
1159 fn test_displayed_line_width_cjk() {
1160 assert_eq!(displayed_line_width("日本"), 4);
1162 }
1163
1164 #[test]
1167 fn test_count_displayed_rows_strips_ansi() {
1168 let colored = "\x1b[31mhello\x1b[0m"; assert_eq!(count_displayed_rows(colored, Some(80)), 1);
1172 }
1173
1174 #[test]
1175 fn test_count_displayed_rows_with_tabs() {
1176 assert_eq!(count_displayed_rows("hi\tworld", Some(80)), 1);
1179
1180 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 let content = "\x1b[32m\tindented\x1b[0m";
1188 assert_eq!(count_displayed_rows(content, Some(80)), 1);
1190 assert_eq!(count_displayed_rows(content, Some(10)), 2);
1191 }
1192
1193 #[test]
1196 fn test_incremental_line_counting_single_write() {
1197 let config = PagerConfig::default();
1199 let mut output = BufferedOutput::new_for_testing(config);
1200
1201 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 let config = PagerConfig::default();
1213 let mut output = BufferedOutput::new_for_testing(config);
1214
1215 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 for i in 3..=10 {
1234 output.write(&format!("line{i}")).unwrap();
1235 output.write("\n").unwrap();
1236 }
1237
1238 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 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 output.write("a\nb\nc\nd\ne").unwrap();
1273 assert_eq!(output.complete_lines, 4); assert_eq!(output.partial_line_len, 1); }
1276}