use std::env;
use std::fmt;
use std::fmt::Write as _;
use std::fs::{File, OpenOptions};
use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
pub const DEFAULT_MAX_BYTES: usize = 75_000;
pub const DEFAULT_SEPARATOR: &str = "\n";
pub const USAGE: &str = "sshclip\n\nUsage:\n sshclip [OPTIONS] [FILE|-]\n sshclip doctor\n\nOptions:\n -s, --selection <clipboard|primary> Clipboard selection (default: clipboard)\n --max-bytes <N> Maximum input bytes before fail/truncate (default: 75000)\n --truncate Truncate input at --max-bytes instead of failing\n --stdout Emit escape sequence to stdout instead of /dev/tty\n --quiet Suppress success message\n --strict Treat best-effort warnings as failures\n --strict-tmux Fail with exit code 6 on tmux integration warnings\n --no-tmux-fix Do not mutate tmux allow-passthrough\n --tmux <auto|off|force> tmux wrapping behavior (default: auto)\n --concat Concatenate multiple inputs in argument order\n --separator <TEXT> Separator for --concat inputs (default: newline)\n -h, --help Print help\n -V, --version Print version\n";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Commands {
Doctor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Selection {
Clipboard,
Primary,
}
impl Selection {
fn code(self) -> u8 {
match self {
Selection::Clipboard => b'c',
Selection::Primary => b'p',
}
}
fn parse(value: &str) -> Result<Self, AppError> {
match value {
"clipboard" => Ok(Selection::Clipboard),
"primary" => Ok(Selection::Primary),
_ => Err(AppError::Usage(format!(
"invalid value for --selection: '{value}' (expected clipboard|primary)"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TmuxMode {
Auto,
Off,
Force,
}
impl TmuxMode {
fn parse(value: &str) -> Result<Self, AppError> {
match value {
"auto" => Ok(TmuxMode::Auto),
"off" => Ok(TmuxMode::Off),
"force" => Ok(TmuxMode::Force),
_ => Err(AppError::Usage(format!(
"invalid value for --tmux: '{value}' (expected auto|off|force)"
))),
}
}
}
#[derive(Debug, Clone)]
pub struct Cli {
pub command: Option<Commands>,
pub selection: Selection,
pub max_bytes: usize,
pub truncate: bool,
pub stdout: bool,
pub quiet: bool,
pub strict: bool,
pub strict_tmux: bool,
pub no_tmux_fix: bool,
pub tmux: TmuxMode,
pub concat: bool,
pub separator: String,
pub files: Vec<PathBuf>,
}
impl Default for Cli {
fn default() -> Self {
Self {
command: None,
selection: Selection::Clipboard,
max_bytes: DEFAULT_MAX_BYTES,
truncate: false,
stdout: false,
quiet: false,
strict: false,
strict_tmux: false,
no_tmux_fix: false,
tmux: TmuxMode::Auto,
concat: false,
separator: DEFAULT_SEPARATOR.to_string(),
files: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseResult {
Run,
Help,
Version,
}
#[derive(Debug, Clone)]
pub enum AppError {
Usage(String),
Input(String),
TooLarge { max_bytes: usize },
Output(String),
Tmux(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Usage(message) => f.write_str(message),
AppError::Input(message) => f.write_str(message),
AppError::TooLarge { max_bytes } => write!(
f,
"input exceeded --max-bytes limit ({max_bytes} bytes); increase --max-bytes or pass --truncate"
),
AppError::Output(message) => f.write_str(message),
AppError::Tmux(message) => f.write_str(message),
}
}
}
impl std::error::Error for AppError {}
impl AppError {
pub fn exit_code(&self) -> i32 {
match self {
AppError::Usage(_) => 2,
AppError::Input(_) => 3,
AppError::TooLarge { .. } => 4,
AppError::Output(_) => 5,
AppError::Tmux(_) => 6,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WarningKind {
Tmux,
}
#[derive(Debug, Clone)]
struct Warning {
kind: WarningKind,
message: String,
}
impl Warning {
fn tmux(message: impl Into<String>) -> Self {
Self {
kind: WarningKind::Tmux,
message: message.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Source<'a> {
Stdin,
File(&'a PathBuf),
}
pub fn parse_cli_from_env() -> Result<(ParseResult, Cli), AppError> {
let args: Vec<String> = env::args().skip(1).collect();
parse_cli_args(args)
}
fn parse_cli_args(args: Vec<String>) -> Result<(ParseResult, Cli), AppError> {
if let Some(first) = args.first()
&& first == "doctor"
{
let mut cli = Cli {
command: Some(Commands::Doctor),
..Cli::default()
};
if args.len() == 1 {
return Ok((ParseResult::Run, cli));
}
for arg in args.into_iter().skip(1) {
match arg.as_str() {
"-h" | "--help" => return Ok((ParseResult::Help, cli)),
"-V" | "--version" => return Ok((ParseResult::Version, cli)),
_ => {
return Err(AppError::Usage(
"`doctor` does not accept additional arguments".to_string(),
));
}
}
}
cli.command = Some(Commands::Doctor);
return Ok((ParseResult::Run, cli));
}
let mut cli = Cli::default();
let mut index = 0usize;
let mut positional_mode = false;
while index < args.len() {
let arg = &args[index];
if positional_mode {
cli.files.push(PathBuf::from(arg));
index += 1;
continue;
}
if arg == "--" {
positional_mode = true;
index += 1;
continue;
}
if arg == "-h" || arg == "--help" {
return Ok((ParseResult::Help, cli));
}
if arg == "-V" || arg == "--version" {
return Ok((ParseResult::Version, cli));
}
if arg == "--truncate" {
cli.truncate = true;
index += 1;
continue;
}
if arg == "--stdout" {
cli.stdout = true;
index += 1;
continue;
}
if arg == "--quiet" {
cli.quiet = true;
index += 1;
continue;
}
if arg == "--strict" {
cli.strict = true;
index += 1;
continue;
}
if arg == "--strict-tmux" {
cli.strict_tmux = true;
index += 1;
continue;
}
if arg == "--no-tmux-fix" {
cli.no_tmux_fix = true;
index += 1;
continue;
}
if arg == "--concat" {
cli.concat = true;
index += 1;
continue;
}
if let Some(value) = arg.strip_prefix("--selection=") {
cli.selection = Selection::parse(value)?;
index += 1;
continue;
}
if arg == "--selection" {
let value = args
.get(index + 1)
.ok_or_else(|| AppError::Usage("missing value for --selection".to_string()))?;
cli.selection = Selection::parse(value)?;
index += 2;
continue;
}
if let Some(value) = arg.strip_prefix("--max-bytes=") {
cli.max_bytes = parse_max_bytes(value)?;
index += 1;
continue;
}
if arg == "--max-bytes" {
let value = args
.get(index + 1)
.ok_or_else(|| AppError::Usage("missing value for --max-bytes".to_string()))?;
cli.max_bytes = parse_max_bytes(value)?;
index += 2;
continue;
}
if let Some(value) = arg.strip_prefix("--tmux=") {
cli.tmux = TmuxMode::parse(value)?;
index += 1;
continue;
}
if arg == "--tmux" {
let value = args
.get(index + 1)
.ok_or_else(|| AppError::Usage("missing value for --tmux".to_string()))?;
cli.tmux = TmuxMode::parse(value)?;
index += 2;
continue;
}
if let Some(value) = arg.strip_prefix("--separator=") {
cli.separator = value.to_string();
index += 1;
continue;
}
if arg == "--separator" {
let value = args
.get(index + 1)
.ok_or_else(|| AppError::Usage("missing value for --separator".to_string()))?;
cli.separator = value.clone();
index += 2;
continue;
}
if arg == "-s" {
let value = args
.get(index + 1)
.ok_or_else(|| AppError::Usage("missing value for -s".to_string()))?;
cli.selection = Selection::parse(value)?;
index += 2;
continue;
}
if let Some(value) = arg.strip_prefix("-s")
&& !value.is_empty()
{
cli.selection = Selection::parse(value)?;
index += 1;
continue;
}
if arg.starts_with('-') && arg != "-" {
return Err(AppError::Usage(format!("unknown option: {arg}")));
}
cli.files.push(PathBuf::from(arg));
index += 1;
}
Ok((ParseResult::Run, cli))
}
fn parse_max_bytes(value: &str) -> Result<usize, AppError> {
let parsed = value
.parse::<usize>()
.map_err(|_| AppError::Usage(format!("invalid integer for --max-bytes: '{value}'")))?;
if parsed == 0 {
return Err(AppError::Usage(
"--max-bytes must be greater than zero".to_string(),
));
}
Ok(parsed)
}
pub fn print_help() {
println!("{USAGE}");
}
pub fn print_version() {
println!("sshclip {}", env!("CARGO_PKG_VERSION"));
}
pub fn run(cli: Cli) -> Result<(), AppError> {
if let Some(Commands::Doctor) = cli.command {
return run_doctor();
}
let sources = resolve_sources(&cli.files, cli.concat)?;
let input = read_input(
&sources,
cli.max_bytes,
cli.truncate,
cli.separator.as_bytes(),
)?;
let mut payload = build_osc52(input.data.as_slice(), cli.selection);
let mut warnings = Vec::<Warning>::new();
let in_tmux_env = env::var_os("TMUX")
.and_then(|value| (!value.is_empty()).then_some(value))
.is_some();
let wrap_for_tmux = match cli.tmux {
TmuxMode::Off => false,
TmuxMode::Auto => in_tmux_env,
TmuxMode::Force => true,
};
let mut restore_passthrough: Option<bool> = None;
if wrap_for_tmux {
if !cli.no_tmux_fix {
if in_tmux_env {
match tmux_get_allow_passthrough() {
Ok(false) => match tmux_set_allow_passthrough(true) {
Ok(()) => {
restore_passthrough = Some(false);
}
Err(err) => warnings.push(Warning::tmux(format!(
"failed to enable tmux allow-passthrough automatically: {err}"
))),
},
Ok(true) => {}
Err(err) => warnings.push(Warning::tmux(format!(
"could not inspect tmux allow-passthrough: {err}"
))),
}
} else {
warnings.push(Warning::tmux(
"--tmux=force used without $TMUX; skipping allow-passthrough auto-fix",
));
}
}
payload = wrap_tmux_passthrough(payload.as_slice());
}
write_payload(payload.as_slice(), cli.stdout)?;
if let Some(previous) = restore_passthrough
&& let Err(err) = tmux_set_allow_passthrough(previous)
{
warnings.push(Warning::tmux(format!(
"failed to restore tmux allow-passthrough to 'off': {err}"
)));
}
let tmux_warning_text = join_tmux_warning_text(&warnings);
if cli.strict_tmux && !tmux_warning_text.is_empty() {
return Err(AppError::Tmux(tmux_warning_text));
}
if cli.strict && !warnings.is_empty() {
if !tmux_warning_text.is_empty() {
return Err(AppError::Tmux(tmux_warning_text));
}
}
if input.truncated {
eprintln!(
"sshclip: warning: input truncated to {} bytes due to --max-bytes",
cli.max_bytes
);
}
for warning in warnings {
eprintln!("sshclip: warning: {}", warning.message);
}
if !cli.quiet {
eprintln!(
"sshclip: emitted OSC 52 sequence for {} byte(s) of input{}",
input.data.len(),
if wrap_for_tmux { " (tmux wrapped)" } else { "" }
);
}
Ok(())
}
fn join_tmux_warning_text(warnings: &[Warning]) -> String {
let mut message = String::new();
for warning in warnings {
if warning.kind != WarningKind::Tmux {
continue;
}
if !message.is_empty() {
message.push_str("; ");
}
message.push_str(warning.message.as_str());
}
message
}
fn run_doctor() -> Result<(), AppError> {
let stdin_tty = io::stdin().is_terminal();
let stdout_tty = io::stdout().is_terminal();
let stderr_tty = io::stderr().is_terminal();
let tty_open_result = OpenOptions::new().write(true).open("/dev/tty");
let tty_available = tty_open_result.is_ok();
println!("sshclip doctor");
println!();
println!("Environment");
println!(" stdin is tty: {stdin_tty}");
println!(" stdout is tty: {stdout_tty}");
println!(" stderr is tty: {stderr_tty}");
println!(" /dev/tty writable: {tty_available}");
if let Err(err) = tty_open_result {
println!(" /dev/tty note: {err}");
}
for var in [
"SSH_CONNECTION",
"SSH_CLIENT",
"SSH_TTY",
"TMUX",
"STY",
"TERM",
"TERM_PROGRAM",
] {
match env::var(var) {
Ok(value) => println!(" {var}: {value}"),
Err(_) => println!(" {var}: <unset>"),
}
}
println!();
println!("Checks");
let in_ssh = env::var("SSH_CONNECTION").is_ok() || env::var("SSH_CLIENT").is_ok();
if in_ssh {
println!(" OK: SSH environment detected.");
} else {
println!(" INFO: SSH environment variables are not set.");
}
let in_tmux = env::var_os("TMUX")
.and_then(|value| (!value.is_empty()).then_some(value))
.is_some();
if in_tmux {
match tmux_get_allow_passthrough() {
Ok(true) => println!(" OK: tmux allow-passthrough is ON for current pane."),
Ok(false) => println!(" WARN: tmux allow-passthrough is OFF for current pane."),
Err(err) => println!(" WARN: could not read tmux allow-passthrough: {err}"),
}
} else {
println!(" INFO: not currently inside tmux (or $TMUX unset).");
}
println!();
println!("Recommended actions");
if !tty_available {
println!(
" - /dev/tty is unavailable; use --stdout only when pipeline output is intended."
);
}
if in_tmux {
println!(
" - If copying fails in tmux, enable passthrough for this pane: tmux set -p allow-passthrough on"
);
println!(" - To set globally: add `set -g allow-passthrough on` to ~/.tmux.conf");
}
println!(
" - Ensure your local terminal emulator supports OSC 52 clipboard writes and has them enabled."
);
println!(
" - Security note: any process that can print to your terminal can attempt clipboard writes via OSC 52."
);
Ok(())
}
struct InputData {
data: Vec<u8>,
truncated: bool,
}
fn resolve_sources<'a>(files: &'a [PathBuf], concat: bool) -> Result<Vec<Source<'a>>, AppError> {
if files.len() > 1 && !concat {
return Err(AppError::Usage(
"multiple inputs provided; pass --concat to combine them".to_string(),
));
}
if files.is_empty() {
return Ok(vec![Source::Stdin]);
}
Ok(files
.iter()
.map(|path| {
if path == Path::new("-") {
Source::Stdin
} else {
Source::File(path)
}
})
.collect())
}
fn read_input(
sources: &[Source<'_>],
max_bytes: usize,
truncate: bool,
separator: &[u8],
) -> Result<InputData, AppError> {
let mut out = Vec::new();
let mut truncated = false;
for (index, source) in sources.iter().enumerate() {
if index > 0 {
match append_slice_limited(&mut out, separator, max_bytes, truncate)? {
AppendResult::Appended => {}
AppendResult::Truncated => {
truncated = true;
break;
}
}
}
if truncated {
break;
}
match source {
Source::Stdin => {
let stdin = io::stdin();
let mut lock = stdin.lock();
if read_from_reader_limited(&mut lock, &mut out, max_bytes, truncate)? {
truncated = true;
break;
}
}
Source::File(path) => {
let mut file = File::open(path).map_err(|err| {
AppError::Input(format!("failed to open '{}': {err}", path.display()))
})?;
if read_from_reader_limited(&mut file, &mut out, max_bytes, truncate)? {
truncated = true;
break;
}
}
}
}
Ok(InputData {
data: out,
truncated,
})
}
enum AppendResult {
Appended,
Truncated,
}
fn append_slice_limited(
out: &mut Vec<u8>,
extra: &[u8],
max_bytes: usize,
truncate: bool,
) -> Result<AppendResult, AppError> {
if extra.is_empty() {
return Ok(AppendResult::Appended);
}
let remaining = max_bytes.saturating_sub(out.len());
if extra.len() <= remaining {
out.extend_from_slice(extra);
return Ok(AppendResult::Appended);
}
if truncate {
out.extend_from_slice(&extra[..remaining]);
return Ok(AppendResult::Truncated);
}
Err(AppError::TooLarge { max_bytes })
}
fn read_from_reader_limited<R: Read>(
reader: &mut R,
out: &mut Vec<u8>,
max_bytes: usize,
truncate: bool,
) -> Result<bool, AppError> {
let mut buf = [0u8; 8192];
loop {
if out.len() == max_bytes {
if truncate {
return Ok(true);
}
let extra = reader
.read(&mut buf[..1])
.map_err(|err| AppError::Input(format!("failed to read input: {err}")))?;
if extra == 0 {
return Ok(false);
}
return Err(AppError::TooLarge { max_bytes });
}
let n = reader
.read(&mut buf)
.map_err(|err| AppError::Input(format!("failed to read input: {err}")))?;
if n == 0 {
return Ok(false);
}
let remaining = max_bytes - out.len();
if n <= remaining {
out.extend_from_slice(&buf[..n]);
continue;
}
out.extend_from_slice(&buf[..remaining]);
if truncate {
return Ok(true);
}
return Err(AppError::TooLarge { max_bytes });
}
}
fn write_payload(payload: &[u8], use_stdout: bool) -> Result<(), AppError> {
if use_stdout {
let mut out = io::stdout().lock();
out.write_all(payload)
.and_then(|()| out.flush())
.map_err(|err| AppError::Output(format!("failed to write payload to stdout: {err}")))?;
return Ok(());
}
let mut tty = OpenOptions::new()
.write(true)
.open("/dev/tty")
.map_err(|err| {
AppError::Output(format!(
"no writable /dev/tty available ({err}); run with --stdout to emit on stdout"
))
})?;
tty.write_all(payload)
.and_then(|()| tty.flush())
.map_err(|err| {
AppError::Output(format!("failed writing OSC 52 payload to /dev/tty: {err}"))
})
}
fn tmux_get_allow_passthrough() -> Result<bool, String> {
let output = run_tmux(["show-options", "-p", "-v", "allow-passthrough"])?;
match String::from_utf8_lossy(output.stdout.as_slice()).trim() {
"on" => Ok(true),
"off" => Ok(false),
other => Err(format!("unexpected value for allow-passthrough: '{other}'")),
}
}
fn tmux_set_allow_passthrough(enable: bool) -> Result<(), String> {
let value = if enable { "on" } else { "off" };
run_tmux(["set-option", "-p", "allow-passthrough", value]).map(|_| ())
}
fn run_tmux<const N: usize>(args: [&str; N]) -> Result<Output, String> {
let mut command_line = String::from("tmux");
for arg in args {
let _ = write!(command_line, " {arg}");
}
let output = Command::new("tmux")
.args(args)
.output()
.map_err(|err| format!("could not execute `{command_line}`: {err}"))?;
if output.status.success() {
return Ok(output);
}
let stderr = String::from_utf8_lossy(output.stderr.as_slice());
Err(format!(
"`{command_line}` exited with {}: {}",
output.status,
stderr.trim()
))
}
pub fn base64_len(raw_len: usize) -> usize {
raw_len.div_ceil(3) * 4
}
pub fn osc52_sequence_len(raw_len: usize) -> usize {
// Prefix: ESC ] 52 ; <selection> ; (7 bytes), suffix: ESC \\ (2 bytes)
9 + base64_len(raw_len)
}
pub fn build_osc52(data: &[u8], selection: Selection) -> Vec<u8> {
let encoded = encode_base64(data);
let mut out = Vec::with_capacity(9 + encoded.len());
out.extend_from_slice(b"\x1b]52;");
out.push(selection.code());
out.push(b';');
out.extend_from_slice(encoded.as_bytes());
out.extend_from_slice(b"\x1b\\");
out
}
pub fn wrap_tmux_passthrough(inner: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity((inner.len() * 2) + 8);
out.extend_from_slice(b"\x1bPtmux;");
for byte in inner {
if *byte == 0x1b {
out.push(0x1b);
}
out.push(*byte);
}
out.extend_from_slice(b"\x1b\\");
out
}
pub fn encode_base64(data: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(base64_len(data.len()));
let mut i = 0usize;
while i + 3 <= data.len() {
let chunk = &data[i..i + 3];
let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | (chunk[2] as u32);
out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
out.push(TABLE[(n & 0x3f) as usize] as char);
i += 3;
}
match data.len() - i {
0 => {}
1 => {
let n = (data[i] as u32) << 16;
out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
out.push('=');
out.push('=');
}
2 => {
let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8);
out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
out.push('=');
}
_ => unreachable!(),
}
out
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use super::*;
#[test]
fn base64_math_is_correct() {
assert_eq!(base64_len(0), 0);
assert_eq!(base64_len(1), 4);
assert_eq!(base64_len(2), 4);
assert_eq!(base64_len(3), 4);
assert_eq!(base64_len(4), 8);
}
#[test]
fn encode_base64_is_correct() {
assert_eq!(encode_base64(b""), "");
assert_eq!(encode_base64(b"f"), "Zg==");
assert_eq!(encode_base64(b"fo"), "Zm8=");
assert_eq!(encode_base64(b"foo"), "Zm9v");
assert_eq!(encode_base64(b"hello"), "aGVsbG8=");
}
#[test]
fn osc52_length_math_is_correct() {
assert_eq!(osc52_sequence_len(0), 9);
assert_eq!(osc52_sequence_len(3), 13);
}
#[test]
fn builds_clipboard_osc52_sequence() {
let out = build_osc52(b"hello", Selection::Clipboard);
assert_eq!(out, b"\x1b]52;c;aGVsbG8=\x1b\\");
}
#[test]
fn builds_primary_osc52_sequence() {
let out = build_osc52(b"A", Selection::Primary);
assert_eq!(out, b"\x1b]52;p;QQ==\x1b\\");
}
#[test]
fn tmux_wrapper_frames_and_escapes() {
let inner = b"\x1b]52;c;QQ==\x1b\\";
let wrapped = wrap_tmux_passthrough(inner);
assert_eq!(wrapped, b"\x1bPtmux;\x1b\x1b]52;c;QQ==\x1b\x1b\\\x1b\\");
}
#[test]
fn resolve_sources_rejects_multiple_without_concat() {
let files = vec![PathBuf::from("a"), PathBuf::from("b")];
let err = resolve_sources(&files, false).expect_err("expected usage error");
assert!(matches!(err, AppError::Usage(_)));
}
#[test]
fn reader_exact_limit_succeeds() {
let mut cursor = Cursor::new(b"12345".to_vec());
let mut out = Vec::new();
let truncated = read_from_reader_limited(&mut cursor, &mut out, 5, false).unwrap();
assert!(!truncated);
assert_eq!(out, b"12345");
}
#[test]
fn reader_over_limit_fails_without_truncate() {
let mut cursor = Cursor::new(b"123456".to_vec());
let mut out = Vec::new();
let err = read_from_reader_limited(&mut cursor, &mut out, 5, false)
.expect_err("expected too large");
assert!(matches!(err, AppError::TooLarge { max_bytes: 5 }));
}
#[test]
fn reader_over_limit_truncates() {
let mut cursor = Cursor::new(b"123456".to_vec());
let mut out = Vec::new();
let truncated = read_from_reader_limited(&mut cursor, &mut out, 5, true).unwrap();
assert!(truncated);
assert_eq!(out, b"12345");
}
#[test]
fn separator_append_respects_limit() {
let mut out = b"abcd".to_vec();
let result = append_slice_limited(&mut out, b"XYZ", 5, true).unwrap();
assert!(matches!(result, AppendResult::Truncated));
assert_eq!(out, b"abcdX");
}
#[test]
fn parse_help_and_version() {
let parsed = parse_cli_args(vec!["--help".to_string()]).unwrap();
assert!(matches!(parsed.0, ParseResult::Help));
let parsed = parse_cli_args(vec!["-V".to_string()]).unwrap();
assert!(matches!(parsed.0, ParseResult::Version));
}
#[test]
fn parse_selection_short_flag() {
let parsed = parse_cli_args(vec!["-s".to_string(), "primary".to_string()]).unwrap();
assert!(matches!(parsed.0, ParseResult::Run));
assert!(matches!(parsed.1.selection, Selection::Primary));
}
#[test]
fn parse_doctor_subcommand() {
let parsed = parse_cli_args(vec!["doctor".to_string()]).unwrap();
assert!(matches!(parsed.0, ParseResult::Run));
assert!(matches!(parsed.1.command, Some(Commands::Doctor)));
}
}