1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
//! Auto-pager for `--help` and `--examples`.
//!
//! When stdout is a TTY and the user hasn't opted out, spawn `$PAGER`
//! (default `less -FRX`) and dup2 our stdout onto its stdin so subsequent
//! `println!` calls flow through it. After all output is written, the
//! caller MUST invoke `finish()` to flush stdout, close our end of the
//! pipe, and `wait()` on the child — otherwise the pager competes with
//! the shell for terminal control and exits early.
//!
//! Non-Unix targets compile to a no-op: the feature is off on Windows.
//! `colored::control::set_override(true)` is called whenever paging is
//! activated, because `colored` otherwise strips ANSI escapes on our
//! now-piped stdout and `less -R` has nothing to render.
#[cfg(unix)]
use std::io::IsTerminal;
#[cfg(unix)]
use std::os::unix::io::AsRawFd;
#[cfg(unix)]
use std::process::{Child, Command, Stdio};
/// Spawn a pager and redirect our stdout to its stdin, returning the
/// Child for lifecycle management. Returns None when paging is disabled,
/// stdout isn't a TTY, or the pager couldn't be spawned.
#[cfg(unix)]
pub fn activate(disabled: bool) -> Option<Child> {
if disabled || std::env::var("RECON_NO_PAGER").is_ok() {
return None;
}
if !std::io::stdout().is_terminal() {
return None;
}
let cmd = resolve_command();
let (prog, rest) = cmd.split_first()?;
let mut child = Command::new(prog)
.args(rest)
.stdin(Stdio::piped())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.ok()?;
// Replace our stdout with the pager's stdin. After dup2, both fds
// refer to the same pipe; we can drop the child.stdin handle below
// without closing the duped fd (it's a separate kernel descriptor).
// SAFETY: both fds are valid, dup2 is always safe when the arguments
// are valid open descriptors.
let child_stdin_fd = child.stdin.as_ref()?.as_raw_fd();
let rc = unsafe { libc::dup2(child_stdin_fd, libc::STDOUT_FILENO) };
if rc < 0 {
// dup2 failed — kill the pager we just spawned and fall through
// to unpaged output. Any println! from here on goes to the
// original stdout.
let _ = child.kill();
let _ = child.wait();
return None;
}
// Drop child.stdin so our dup'd fd is the only writable end. Without
// this, `less` never sees EOF when we exit (both ends of the pipe
// are still live via child.stdin) and hangs.
drop(child.stdin.take());
// Force colour output through the pipe; `less -R` renders it.
colored::control::set_override(true);
Some(child)
}
#[cfg(not(unix))]
pub fn activate(_disabled: bool) -> Option<()> {
// No-op on non-Unix. Windows callers get unpaged output, same as
// behaviour before this feature existed.
None
}
/// Block until the pager exits. Must be called after all output has been
/// written and before `main()` returns — otherwise the shell's foreground
/// process group reclaims the terminal and less gets SIGTTIN/SIGTTOU'd
/// (or the user's keystrokes get eaten by the shell) long before they've
/// finished scrolling.
///
/// Sequence:
/// 1. Flush stdlib's line-buffered stdout so any pending data reaches
/// the pager's read side.
/// 2. Close STDOUT_FILENO so the pipe has no writers; `less` reads
/// until EOF and either exits (`-F` fit-on-one-screen) or sits
/// waiting for user input.
/// 3. `wait()` on the child to block until the user quits or `-F` fires.
#[cfg(unix)]
pub fn finish(child: Option<Child>) {
if let Some(mut child) = child {
use std::io::Write;
let _ = std::io::stdout().flush();
// SAFETY: closing a fixed, known file descriptor.
unsafe {
libc::close(libc::STDOUT_FILENO);
}
let _ = child.wait();
}
}
#[cfg(not(unix))]
pub fn finish(_child: Option<()>) {}
/// Resolve the pager command to run. `$PAGER` wins when set and
/// non-empty, otherwise `less -F -R -X` is used. Shell-split by
/// whitespace only (no quote handling — $PAGER rarely needs it).
#[cfg(unix)]
pub fn resolve_command() -> Vec<String> {
match std::env::var("PAGER") {
Ok(s) if !s.trim().is_empty() => s
.split_whitespace()
.map(|p| p.to_string())
.collect(),
_ => vec![
"less".to_string(),
"-F".to_string(),
"-R".to_string(),
"-X".to_string(),
],
}
}
/// Check raw argv for `--no-pager`, used during the pre-clap `--help`
/// and `--examples` intercept blocks where `Args` isn't parsed yet.
pub fn no_pager_requested() -> bool {
std::env::args().any(|a| a == "--no-pager")
|| std::env::var("RECON_NO_PAGER").is_ok()
}
#[cfg(all(unix, test))]
mod tests {
use super::*;
#[test]
fn resolve_command_default_is_less_frx() {
// Ensure $PAGER is unset for this test. Using set_var is safe in
// single-threaded test harness; `cargo test` uses threads so we
// guard with a lock in case other tests touch $PAGER.
// Simpler: use a throwaway key and assert against the default.
let saved = std::env::var("PAGER").ok();
std::env::remove_var("PAGER");
let cmd = resolve_command();
if let Some(v) = saved {
std::env::set_var("PAGER", v);
}
assert_eq!(cmd, vec!["less", "-F", "-R", "-X"]);
}
#[test]
fn resolve_command_splits_pager_by_whitespace() {
// We can't safely mutate $PAGER in a multi-threaded test without
// a mutex, so simulate the parse directly.
fn parse(raw: &str) -> Vec<String> {
if raw.trim().is_empty() {
return vec!["less".into(), "-F".into(), "-R".into(), "-X".into()];
}
raw.split_whitespace().map(|p| p.to_string()).collect()
}
assert_eq!(parse("cat"), vec!["cat"]);
assert_eq!(parse("less -iF"), vec!["less", "-iF"]);
assert_eq!(
parse("more -d -r"),
vec!["more", "-d", "-r"]
);
assert_eq!(
parse(""),
vec!["less", "-F", "-R", "-X"]
);
}
}