use std::io::{self, Write};
use std::process::{Child, Command, ExitStatus, Stdio};
use unicode_width::UnicodeWidthChar;
const BUFFER_CAP_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PagerMode {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PagerExitStatus {
Success,
ExitCode(i32),
#[cfg_attr(not(unix), allow(dead_code))]
Signal(i32),
}
impl PagerExitStatus {
#[must_use]
#[allow(dead_code)]
pub fn exit_code(self) -> Option<i32> {
match self {
Self::Success => None,
Self::ExitCode(code) => Some(code),
Self::Signal(sig) => Some(128 + sig),
}
}
#[must_use]
#[allow(dead_code)]
pub fn is_success(self) -> bool {
matches!(self, Self::Success)
}
}
#[derive(Debug, Clone)]
pub struct PagerConfig {
pub command: String,
pub enabled: PagerMode,
pub threshold: Option<usize>,
}
impl Default for PagerConfig {
fn default() -> Self {
Self {
command: Self::default_pager_command(),
enabled: PagerMode::Auto,
threshold: None,
}
}
}
impl PagerConfig {
#[must_use]
pub fn default_pager_command() -> String {
std::env::var("SQRY_PAGER")
.or_else(|_| std::env::var("PAGER"))
.unwrap_or_else(|_| "less -FRX".to_string())
}
#[must_use]
pub fn from_cli_flags(pager_flag: bool, no_pager_flag: bool, pager_cmd: Option<&str>) -> Self {
let mode = if no_pager_flag {
PagerMode::Never
} else if pager_flag {
PagerMode::Always
} else {
PagerMode::Auto
};
let command = pager_cmd.map_or_else(Self::default_pager_command, String::from);
Self {
command,
enabled: mode,
threshold: None,
}
}
}
pub struct PagerDecision {
config: PagerConfig,
is_tty: bool,
terminal_height: Option<usize>,
}
impl PagerDecision {
#[must_use]
pub fn new(config: PagerConfig) -> Self {
use is_terminal::IsTerminal;
let is_tty = std::io::stdout().is_terminal();
let terminal_height = Self::detect_terminal_height();
Self {
config,
is_tty,
terminal_height,
}
}
#[must_use]
pub fn is_tty(&self) -> bool {
self.is_tty
}
#[must_use]
pub fn should_page_rows(&self, displayed_rows: usize) -> bool {
match self.config.enabled {
PagerMode::Never => false,
PagerMode::Always => true,
PagerMode::Auto => {
if !self.is_tty {
return false; }
let threshold = self.config.threshold.or(self.terminal_height).unwrap_or(24);
displayed_rows > threshold
}
}
}
#[must_use]
fn detect_terminal_height() -> Option<usize> {
use terminal_size::{Height, terminal_size};
terminal_size().map(|(_, Height(h))| h as usize)
}
#[must_use]
pub fn detect_terminal_width() -> Option<usize> {
use terminal_size::{Width, terminal_size};
terminal_size().map(|(Width(w), _)| w as usize)
}
}
#[cfg(test)]
impl PagerDecision {
#[must_use]
pub fn for_testing(config: PagerConfig, is_tty: bool, terminal_height: Option<usize>) -> Self {
Self {
config,
is_tty,
terminal_height,
}
}
}
pub struct PagerWriter {
child: Child,
stdin: std::process::ChildStdin,
}
impl PagerWriter {
pub fn spawn(command: &str) -> io::Result<Self> {
let parts = shlex::split(command).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("Invalid pager command syntax: {command}"),
)
})?;
if parts.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Empty pager command",
));
}
let (program, args) = parts.split_first().expect("Already checked non-empty");
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.spawn()?;
let stdin = child
.stdin
.take()
.ok_or_else(|| io::Error::other("Failed to open pager stdin"))?;
Ok(Self { child, stdin })
}
pub fn write(&mut self, content: &str) -> io::Result<()> {
self.stdin.write_all(content.as_bytes())
}
pub fn wait(mut self) -> io::Result<ExitStatus> {
drop(self.stdin); self.child.wait()
}
}
impl Write for PagerWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.stdin.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.stdin.flush()
}
}
enum OutputMode {
Buffering,
Pager(PagerWriter),
Direct,
}
pub struct BufferedOutput {
buffer: String,
config: PagerConfig,
decision: PagerDecision,
mode: OutputMode,
#[allow(dead_code)]
terminal_width: Option<usize>,
complete_lines: usize,
partial_line_len: usize,
spawn_error: Option<io::Error>,
}
impl BufferedOutput {
#[must_use]
pub fn new(config: PagerConfig) -> Self {
let decision = PagerDecision::new(config.clone());
let terminal_width = PagerDecision::detect_terminal_width();
let (mode, spawn_error) = match config.enabled {
PagerMode::Never => (OutputMode::Direct, None),
PagerMode::Always => {
match PagerWriter::spawn(&config.command) {
Ok(pager) => (OutputMode::Pager(pager), None),
Err(e) => {
let pager_name = config
.command
.split_whitespace()
.next()
.unwrap_or(&config.command);
if e.kind() == io::ErrorKind::NotFound {
eprintln!(
"Warning: pager '{pager_name}' not found. Output will not be paged. \
To enable paging, install '{pager_name}' or set the SQRY_PAGER environment variable."
);
(OutputMode::Direct, None)
} else {
eprintln!(
"Error: Failed to start pager '{pager_name}': {e}. \
Please check that the binary is correct and executable, \
or set a different pager using the SQRY_PAGER environment variable."
);
(OutputMode::Direct, Some(e))
}
}
}
}
PagerMode::Auto => {
if decision.is_tty() {
(OutputMode::Buffering, None)
} else {
(OutputMode::Direct, None)
}
}
};
Self {
buffer: String::new(),
config,
decision,
mode,
terminal_width,
complete_lines: 0,
partial_line_len: 0,
spawn_error,
}
}
#[cfg(test)]
#[must_use]
pub fn new_for_testing(config: PagerConfig) -> Self {
let decision = PagerDecision::new(config.clone());
let terminal_width = PagerDecision::detect_terminal_width();
Self {
buffer: String::new(),
config,
decision,
mode: OutputMode::Buffering, terminal_width,
complete_lines: 0,
partial_line_len: 0,
spawn_error: None,
}
}
fn write_direct(content: &str) -> io::Result<()> {
std::io::stdout().write_all(content.as_bytes())
}
fn write_pager(pager: &mut PagerWriter, content: &str) -> io::Result<()> {
match pager.write(content) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
Err(e) => Err(e),
}
}
fn update_line_counts(&mut self, content: &str) {
let newline_count = content.bytes().filter(|&b| b == b'\n').count();
self.complete_lines += newline_count;
self.update_partial_line_len(content);
}
fn update_partial_line_len(&mut self, content: &str) {
if let Some(last_nl_offset) = content.rfind('\n') {
self.partial_line_len = content.len().saturating_sub(last_nl_offset + 1);
} else {
self.partial_line_len += content.len();
}
}
fn displayed_row_estimate(&self) -> usize {
self.complete_lines + usize::from(self.partial_line_len > 0)
}
fn should_transition_to_pager(&self, displayed_rows: usize) -> bool {
self.decision.should_page_rows(displayed_rows) || self.buffer.len() > BUFFER_CAP_BYTES
}
fn transition_to_pager(&mut self) -> io::Result<()> {
match PagerWriter::spawn(&self.config.command) {
Ok(mut pager) => {
pager.write(&self.buffer)?;
self.buffer.clear();
self.mode = OutputMode::Pager(pager);
Ok(())
}
Err(e) => self.handle_pager_spawn_error(e),
}
}
fn handle_pager_spawn_error(&mut self, err: io::Error) -> io::Result<()> {
let pager_name = self
.config
.command
.split_whitespace()
.next()
.unwrap_or(&self.config.command);
if err.kind() == io::ErrorKind::NotFound {
eprintln!(
"Warning: pager '{pager_name}' not found. Output will not be paged. \
To enable paging, install '{pager_name}' or set the SQRY_PAGER environment variable."
);
} else {
eprintln!(
"Error: Failed to start pager '{pager_name}': {err}. \
Please check that the binary is correct and executable, \
or set a different pager using the SQRY_PAGER environment variable."
);
self.spawn_error = Some(err);
}
Self::write_direct(&self.buffer)?;
self.buffer.clear();
self.mode = OutputMode::Direct;
Ok(())
}
pub fn write(&mut self, content: &str) -> io::Result<()> {
match &mut self.mode {
OutputMode::Direct => {
Self::write_direct(content)
}
OutputMode::Pager(pager) => {
Self::write_pager(pager, content)
}
OutputMode::Buffering => {
self.buffer.push_str(content);
self.update_line_counts(content);
let displayed_rows = self.displayed_row_estimate();
if self.should_transition_to_pager(displayed_rows) {
self.transition_to_pager()?;
}
Ok(())
}
}
}
pub fn finish(self) -> io::Result<PagerExitStatus> {
if let Some(spawn_err) = self.spawn_error {
return Err(spawn_err);
}
match self.mode {
OutputMode::Direct => Ok(PagerExitStatus::Success),
OutputMode::Pager(pager) => {
let status = pager.wait()?;
Ok(exit_status_to_pager_status(status))
}
OutputMode::Buffering => {
std::io::stdout().write_all(self.buffer.as_bytes())?;
Ok(PagerExitStatus::Success)
}
}
}
}
#[must_use]
fn is_broken_pipe_exit(status: ExitStatus) -> bool {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
status.signal() == Some(13)
}
#[cfg(not(unix))]
{
matches!(status.code(), Some(0) | Some(1))
}
}
fn exit_status_to_pager_status(status: ExitStatus) -> PagerExitStatus {
if status.success() || is_broken_pipe_exit(status) {
return PagerExitStatus::Success;
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signal) = status.signal() {
return PagerExitStatus::Signal(signal);
}
}
if let Some(code) = status.code() {
PagerExitStatus::ExitCode(code)
} else {
PagerExitStatus::ExitCode(1)
}
}
#[allow(dead_code)]
const TAB_WIDTH: usize = 8;
fn skip_csi_sequence(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
while let Some(&next) = chars.peek() {
chars.next();
if (0x40..=0x7E).contains(&(next as u8)) {
break;
}
}
}
fn skip_osc_sequence(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
while let Some(&next) = chars.peek() {
if next == '\x07' {
chars.next();
break;
}
if next == '\x1b' {
chars.next();
if chars.peek() == Some(&'\\') {
chars.next();
}
break;
}
chars.next();
}
}
#[allow(dead_code)]
fn strip_ansi(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
match chars.peek().copied() {
Some('[') => {
chars.next();
skip_csi_sequence(&mut chars);
}
Some(']') => {
chars.next();
skip_osc_sequence(&mut chars);
}
_ => {}
}
} else {
result.push(c);
}
}
result
}
#[allow(dead_code)]
fn displayed_line_width(line: &str) -> usize {
let mut width = 0;
for c in line.chars() {
if c == '\t' {
width = (width / TAB_WIDTH + 1) * TAB_WIDTH;
} else {
width += UnicodeWidthChar::width(c).unwrap_or(0);
}
}
width
}
#[allow(dead_code)]
#[must_use]
pub fn count_displayed_rows(content: &str, terminal_width: Option<usize>) -> usize {
let width = terminal_width.unwrap_or(80);
content
.lines()
.map(|line| {
let clean_line = strip_ansi(line);
let line_width = displayed_line_width(&clean_line);
if line_width == 0 {
1 } else {
line_width.div_ceil(width)
}
})
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
fn test_pager_mode_default() {
assert_eq!(PagerMode::default(), PagerMode::Auto);
}
#[test]
fn test_pager_config_default() {
let config = PagerConfig::default();
assert_eq!(config.enabled, PagerMode::Auto);
assert!(config.threshold.is_none());
}
#[test]
#[serial]
fn test_pager_config_env_sqry_pager() {
unsafe {
std::env::set_var("SQRY_PAGER", "bat --style=plain");
std::env::remove_var("PAGER");
}
let cmd = PagerConfig::default_pager_command();
assert_eq!(cmd, "bat --style=plain");
unsafe {
std::env::remove_var("SQRY_PAGER");
}
}
#[test]
#[serial]
fn test_pager_config_env_pager_fallback() {
unsafe {
std::env::remove_var("SQRY_PAGER");
std::env::set_var("PAGER", "more");
}
let cmd = PagerConfig::default_pager_command();
assert_eq!(cmd, "more");
unsafe {
std::env::remove_var("PAGER");
}
}
#[test]
#[serial]
fn test_pager_config_env_sqry_pager_priority() {
unsafe {
std::env::set_var("SQRY_PAGER", "bat");
std::env::set_var("PAGER", "less");
}
let cmd = PagerConfig::default_pager_command();
assert_eq!(cmd, "bat");
unsafe {
std::env::remove_var("SQRY_PAGER");
std::env::remove_var("PAGER");
}
}
#[test]
#[serial]
fn test_pager_config_env_default_fallback() {
unsafe {
std::env::remove_var("SQRY_PAGER");
std::env::remove_var("PAGER");
}
let cmd = PagerConfig::default_pager_command();
assert_eq!(cmd, "less -FRX");
}
#[test]
fn test_pager_config_from_cli_flags_no_pager() {
let config = PagerConfig::from_cli_flags(false, true, None);
assert_eq!(config.enabled, PagerMode::Never);
}
#[test]
fn test_pager_config_from_cli_flags_pager() {
let config = PagerConfig::from_cli_flags(true, false, None);
assert_eq!(config.enabled, PagerMode::Always);
}
#[test]
fn test_pager_config_from_cli_flags_auto() {
let config = PagerConfig::from_cli_flags(false, false, None);
assert_eq!(config.enabled, PagerMode::Auto);
}
#[test]
fn test_pager_config_from_cli_flags_custom_cmd() {
let config = PagerConfig::from_cli_flags(true, false, Some("bat --color=always"));
assert_eq!(config.command, "bat --color=always");
}
#[test]
fn test_pager_decision_never_mode() {
let config = PagerConfig {
enabled: PagerMode::Never,
..Default::default()
};
let decision = PagerDecision::for_testing(config, true, Some(24));
assert!(!decision.should_page_rows(1000));
}
#[test]
fn test_pager_decision_always_mode() {
let config = PagerConfig {
enabled: PagerMode::Always,
..Default::default()
};
let decision = PagerDecision::for_testing(config, true, Some(24));
assert!(decision.should_page_rows(1));
}
#[test]
fn test_pager_decision_auto_below_threshold() {
let config = PagerConfig {
enabled: PagerMode::Auto,
threshold: Some(100),
..Default::default()
};
let decision = PagerDecision::for_testing(config, true, Some(24));
assert!(!decision.should_page_rows(50));
}
#[test]
fn test_pager_decision_auto_above_threshold() {
let config = PagerConfig {
enabled: PagerMode::Auto,
threshold: Some(100),
..Default::default()
};
let decision = PagerDecision::for_testing(config, true, Some(24));
assert!(decision.should_page_rows(150));
}
#[test]
fn test_pager_decision_auto_non_tty() {
let config = PagerConfig {
enabled: PagerMode::Auto,
..Default::default()
};
let decision = PagerDecision::for_testing(config, false, Some(24));
assert!(!decision.should_page_rows(1000));
}
#[test]
fn test_pager_decision_auto_uses_terminal_height() {
let config = PagerConfig {
enabled: PagerMode::Auto,
threshold: None, ..Default::default()
};
let decision = PagerDecision::for_testing(config, true, Some(30));
assert!(!decision.should_page_rows(25)); assert!(decision.should_page_rows(35)); }
#[test]
fn test_pager_decision_auto_default_threshold() {
let config = PagerConfig {
enabled: PagerMode::Auto,
threshold: None,
..Default::default()
};
let decision = PagerDecision::for_testing(config, true, None);
assert!(!decision.should_page_rows(20)); assert!(decision.should_page_rows(30)); }
#[test]
fn test_count_displayed_rows_simple() {
let content = "line1\nline2\nline3\n";
assert_eq!(count_displayed_rows(content, Some(80)), 3);
}
#[test]
fn test_count_displayed_rows_empty_lines() {
let content = "line1\n\nline3\n";
assert_eq!(count_displayed_rows(content, Some(80)), 3);
}
#[test]
fn test_count_displayed_rows_long_line_wraps() {
let long_line = "a".repeat(160);
assert_eq!(count_displayed_rows(&long_line, Some(80)), 2);
}
#[test]
fn test_count_displayed_rows_exactly_width() {
let exact_line = "a".repeat(80);
assert_eq!(count_displayed_rows(&exact_line, Some(80)), 1);
}
#[test]
fn test_count_displayed_rows_unicode() {
let cjk = "䏿–‡å—符"; assert_eq!(count_displayed_rows(cjk, Some(80)), 1);
assert_eq!(count_displayed_rows(cjk, Some(4)), 2);
}
#[test]
fn test_count_displayed_rows_default_width() {
let content = "test line\n";
assert_eq!(count_displayed_rows(content, None), 1);
}
#[test]
fn test_pager_writer_spawn_invalid_syntax() {
let result = PagerWriter::spawn("less \"unclosed");
assert!(result.is_err());
let err = result.err().expect("Should be an error");
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn test_pager_writer_spawn_empty_command() {
let result = PagerWriter::spawn("");
assert!(result.is_err());
let err = result.err().expect("Should be an error");
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn test_shlex_parsing_simple() {
let parts = shlex::split("less -R").unwrap();
assert_eq!(parts, vec!["less", "-R"]);
}
#[test]
fn test_shlex_parsing_quoted() {
let parts = shlex::split("\"bat\" --style=plain").unwrap();
assert_eq!(parts, vec!["bat", "--style=plain"]);
}
#[test]
fn test_shlex_parsing_windows_path() {
let parts = shlex::split("\"C:\\Program Files\\Git\\usr\\bin\\less.exe\" -R").unwrap();
assert_eq!(
parts,
vec!["C:\\Program Files\\Git\\usr\\bin\\less.exe", "-R"]
);
}
#[test]
fn test_buffered_output_never_mode_writes_directly() {
let config = PagerConfig {
enabled: PagerMode::Never,
..Default::default()
};
let output = BufferedOutput::new(config);
assert!(matches!(output.mode, OutputMode::Direct));
}
#[test]
fn test_buffered_output_auto_mode_non_tty_streams_directly() {
let config = PagerConfig {
enabled: PagerMode::Auto,
..Default::default()
};
let output = BufferedOutput::new(config);
assert!(
matches!(output.mode, OutputMode::Direct)
|| matches!(output.mode, OutputMode::Buffering),
"Expected Direct (non-TTY) or Buffering (TTY), got neither"
);
}
#[test]
#[cfg(unix)]
fn test_is_broken_pipe_exit_sigpipe() {
use std::os::unix::process::ExitStatusExt;
let status = ExitStatus::from_raw(13 << 8 | 0x7f); let _ = is_broken_pipe_exit(status);
}
#[test]
fn test_buffer_cap_constant() {
assert_eq!(BUFFER_CAP_BYTES, 1024 * 1024);
}
#[test]
fn test_pager_exit_status_success() {
let status = PagerExitStatus::Success;
assert!(status.is_success());
assert_eq!(status.exit_code(), None);
}
#[test]
fn test_pager_exit_status_exit_code() {
let status = PagerExitStatus::ExitCode(42);
assert!(!status.is_success());
assert_eq!(status.exit_code(), Some(42));
}
#[test]
fn test_pager_exit_status_signal() {
let status = PagerExitStatus::Signal(9);
assert!(!status.is_success());
assert_eq!(status.exit_code(), Some(137));
}
#[test]
fn test_strip_ansi_plain_text() {
assert_eq!(strip_ansi("hello world"), "hello world");
}
#[test]
fn test_strip_ansi_csi_color() {
let colored = "\x1b[31mhello\x1b[0m";
assert_eq!(strip_ansi(colored), "hello");
}
#[test]
fn test_strip_ansi_multiple_codes() {
let colored = "\x1b[1;31mhello\x1b[0m world";
assert_eq!(strip_ansi(colored), "hello world");
}
#[test]
fn test_strip_ansi_osc_sequence() {
let with_osc = "before\x1b]0;window title\x07after";
assert_eq!(strip_ansi(with_osc), "beforeafter");
}
#[test]
fn test_strip_ansi_preserves_unicode() {
let text = "\x1b[32m日本語\x1b[0m";
assert_eq!(strip_ansi(text), "日本語");
}
#[test]
fn test_displayed_line_width_no_tabs() {
assert_eq!(displayed_line_width("hello"), 5);
}
#[test]
fn test_displayed_line_width_single_tab_start() {
assert_eq!(displayed_line_width("\thello"), 8 + 5);
}
#[test]
fn test_displayed_line_width_tab_after_text() {
assert_eq!(displayed_line_width("hi\tworld"), 8 + 5);
}
#[test]
fn test_displayed_line_width_multiple_tabs() {
assert_eq!(displayed_line_width("\t\t"), 16);
}
#[test]
fn test_displayed_line_width_cjk() {
assert_eq!(displayed_line_width("日本"), 4);
}
#[test]
fn test_count_displayed_rows_strips_ansi() {
let colored = "\x1b[31mhello\x1b[0m"; assert_eq!(count_displayed_rows(colored, Some(80)), 1);
}
#[test]
fn test_count_displayed_rows_with_tabs() {
assert_eq!(count_displayed_rows("hi\tworld", Some(80)), 1);
assert_eq!(count_displayed_rows("hi\tworld", Some(10)), 2);
}
#[test]
fn test_count_displayed_rows_ansi_and_tabs_combined() {
let content = "\x1b[32m\tindented\x1b[0m";
assert_eq!(count_displayed_rows(content, Some(80)), 1);
assert_eq!(count_displayed_rows(content, Some(10)), 2);
}
#[test]
fn test_incremental_line_counting_single_write() {
let config = PagerConfig::default();
let mut output = BufferedOutput::new_for_testing(config);
output.write("line1\nline2\nline3\nline4\nline5\n").unwrap();
assert_eq!(output.complete_lines, 5);
assert_eq!(output.partial_line_len, 0);
}
#[test]
fn test_incremental_line_counting_chunked_writes() {
let config = PagerConfig::default();
let mut output = BufferedOutput::new_for_testing(config);
output.write("line1").unwrap();
assert_eq!(output.complete_lines, 0);
assert_eq!(output.partial_line_len, 5);
output.write("\n").unwrap();
assert_eq!(output.complete_lines, 1);
assert_eq!(output.partial_line_len, 0);
output.write("line2").unwrap();
assert_eq!(output.complete_lines, 1);
assert_eq!(output.partial_line_len, 5);
output.write("\n").unwrap();
assert_eq!(output.complete_lines, 2);
assert_eq!(output.partial_line_len, 0);
for i in 3..=10 {
output.write(&format!("line{i}")).unwrap();
output.write("\n").unwrap();
}
assert_eq!(output.complete_lines, 10);
assert_eq!(output.partial_line_len, 0);
}
#[test]
fn test_incremental_line_counting_mixed_writes() {
let config = PagerConfig::default();
let mut output = BufferedOutput::new_for_testing(config);
output.write("line1\nline2\n").unwrap();
assert_eq!(output.complete_lines, 2);
assert_eq!(output.partial_line_len, 0);
output.write("partial").unwrap();
assert_eq!(output.complete_lines, 2);
assert_eq!(output.partial_line_len, 7);
output.write(" more").unwrap();
assert_eq!(output.complete_lines, 2);
assert_eq!(output.partial_line_len, 12);
output.write("\nline4\n").unwrap();
assert_eq!(output.complete_lines, 4);
assert_eq!(output.partial_line_len, 0);
}
#[test]
fn test_incremental_line_counting_multiple_newlines_in_one_write() {
let config = PagerConfig::default();
let mut output = BufferedOutput::new_for_testing(config);
output.write("a\nb\nc\nd\ne").unwrap();
assert_eq!(output.complete_lines, 4); assert_eq!(output.partial_line_len, 1); }
}