Skip to main content

sshclip/
lib.rs

1use std::env;
2use std::fmt;
3use std::fmt::Write as _;
4use std::fs::{File, OpenOptions};
5use std::io::{self, IsTerminal, Read, Write};
6use std::path::{Path, PathBuf};
7use std::process::{Command, Output};
8
9pub const DEFAULT_MAX_BYTES: usize = 75_000;
10pub const DEFAULT_SEPARATOR: &str = "\n";
11
12pub 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";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Commands {
16    Doctor,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Selection {
21    Clipboard,
22    Primary,
23}
24
25impl Selection {
26    fn code(self) -> u8 {
27        match self {
28            Selection::Clipboard => b'c',
29            Selection::Primary => b'p',
30        }
31    }
32
33    fn parse(value: &str) -> Result<Self, AppError> {
34        match value {
35            "clipboard" => Ok(Selection::Clipboard),
36            "primary" => Ok(Selection::Primary),
37            _ => Err(AppError::Usage(format!(
38                "invalid value for --selection: '{value}' (expected clipboard|primary)"
39            ))),
40        }
41    }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum TmuxMode {
46    Auto,
47    Off,
48    Force,
49}
50
51impl TmuxMode {
52    fn parse(value: &str) -> Result<Self, AppError> {
53        match value {
54            "auto" => Ok(TmuxMode::Auto),
55            "off" => Ok(TmuxMode::Off),
56            "force" => Ok(TmuxMode::Force),
57            _ => Err(AppError::Usage(format!(
58                "invalid value for --tmux: '{value}' (expected auto|off|force)"
59            ))),
60        }
61    }
62}
63
64#[derive(Debug, Clone)]
65pub struct Cli {
66    pub command: Option<Commands>,
67    pub selection: Selection,
68    pub max_bytes: usize,
69    pub truncate: bool,
70    pub stdout: bool,
71    pub quiet: bool,
72    pub strict: bool,
73    pub strict_tmux: bool,
74    pub no_tmux_fix: bool,
75    pub tmux: TmuxMode,
76    pub concat: bool,
77    pub separator: String,
78    pub files: Vec<PathBuf>,
79}
80
81impl Default for Cli {
82    fn default() -> Self {
83        Self {
84            command: None,
85            selection: Selection::Clipboard,
86            max_bytes: DEFAULT_MAX_BYTES,
87            truncate: false,
88            stdout: false,
89            quiet: false,
90            strict: false,
91            strict_tmux: false,
92            no_tmux_fix: false,
93            tmux: TmuxMode::Auto,
94            concat: false,
95            separator: DEFAULT_SEPARATOR.to_string(),
96            files: Vec::new(),
97        }
98    }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum ParseResult {
103    Run,
104    Help,
105    Version,
106}
107
108#[derive(Debug, Clone)]
109pub enum AppError {
110    Usage(String),
111    Input(String),
112    TooLarge { max_bytes: usize },
113    Output(String),
114    Tmux(String),
115}
116
117impl fmt::Display for AppError {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        match self {
120            AppError::Usage(message) => f.write_str(message),
121            AppError::Input(message) => f.write_str(message),
122            AppError::TooLarge { max_bytes } => write!(
123                f,
124                "input exceeded --max-bytes limit ({max_bytes} bytes); increase --max-bytes or pass --truncate"
125            ),
126            AppError::Output(message) => f.write_str(message),
127            AppError::Tmux(message) => f.write_str(message),
128        }
129    }
130}
131
132impl std::error::Error for AppError {}
133
134impl AppError {
135    pub fn exit_code(&self) -> i32 {
136        match self {
137            AppError::Usage(_) => 2,
138            AppError::Input(_) => 3,
139            AppError::TooLarge { .. } => 4,
140            AppError::Output(_) => 5,
141            AppError::Tmux(_) => 6,
142        }
143    }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147enum WarningKind {
148    Tmux,
149}
150
151#[derive(Debug, Clone)]
152struct Warning {
153    kind: WarningKind,
154    message: String,
155}
156
157impl Warning {
158    fn tmux(message: impl Into<String>) -> Self {
159        Self {
160            kind: WarningKind::Tmux,
161            message: message.into(),
162        }
163    }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167enum Source<'a> {
168    Stdin,
169    File(&'a PathBuf),
170}
171
172pub fn parse_cli_from_env() -> Result<(ParseResult, Cli), AppError> {
173    let args: Vec<String> = env::args().skip(1).collect();
174    parse_cli_args(args)
175}
176
177fn parse_cli_args(args: Vec<String>) -> Result<(ParseResult, Cli), AppError> {
178    if let Some(first) = args.first()
179        && first == "doctor"
180    {
181        let mut cli = Cli {
182            command: Some(Commands::Doctor),
183            ..Cli::default()
184        };
185
186        if args.len() == 1 {
187            return Ok((ParseResult::Run, cli));
188        }
189
190        for arg in args.into_iter().skip(1) {
191            match arg.as_str() {
192                "-h" | "--help" => return Ok((ParseResult::Help, cli)),
193                "-V" | "--version" => return Ok((ParseResult::Version, cli)),
194                _ => {
195                    return Err(AppError::Usage(
196                        "`doctor` does not accept additional arguments".to_string(),
197                    ));
198                }
199            }
200        }
201
202        cli.command = Some(Commands::Doctor);
203        return Ok((ParseResult::Run, cli));
204    }
205
206    let mut cli = Cli::default();
207    let mut index = 0usize;
208    let mut positional_mode = false;
209
210    while index < args.len() {
211        let arg = &args[index];
212
213        if positional_mode {
214            cli.files.push(PathBuf::from(arg));
215            index += 1;
216            continue;
217        }
218
219        if arg == "--" {
220            positional_mode = true;
221            index += 1;
222            continue;
223        }
224
225        if arg == "-h" || arg == "--help" {
226            return Ok((ParseResult::Help, cli));
227        }
228
229        if arg == "-V" || arg == "--version" {
230            return Ok((ParseResult::Version, cli));
231        }
232
233        if arg == "--truncate" {
234            cli.truncate = true;
235            index += 1;
236            continue;
237        }
238
239        if arg == "--stdout" {
240            cli.stdout = true;
241            index += 1;
242            continue;
243        }
244
245        if arg == "--quiet" {
246            cli.quiet = true;
247            index += 1;
248            continue;
249        }
250
251        if arg == "--strict" {
252            cli.strict = true;
253            index += 1;
254            continue;
255        }
256
257        if arg == "--strict-tmux" {
258            cli.strict_tmux = true;
259            index += 1;
260            continue;
261        }
262
263        if arg == "--no-tmux-fix" {
264            cli.no_tmux_fix = true;
265            index += 1;
266            continue;
267        }
268
269        if arg == "--concat" {
270            cli.concat = true;
271            index += 1;
272            continue;
273        }
274
275        if let Some(value) = arg.strip_prefix("--selection=") {
276            cli.selection = Selection::parse(value)?;
277            index += 1;
278            continue;
279        }
280
281        if arg == "--selection" {
282            let value = args
283                .get(index + 1)
284                .ok_or_else(|| AppError::Usage("missing value for --selection".to_string()))?;
285            cli.selection = Selection::parse(value)?;
286            index += 2;
287            continue;
288        }
289
290        if let Some(value) = arg.strip_prefix("--max-bytes=") {
291            cli.max_bytes = parse_max_bytes(value)?;
292            index += 1;
293            continue;
294        }
295
296        if arg == "--max-bytes" {
297            let value = args
298                .get(index + 1)
299                .ok_or_else(|| AppError::Usage("missing value for --max-bytes".to_string()))?;
300            cli.max_bytes = parse_max_bytes(value)?;
301            index += 2;
302            continue;
303        }
304
305        if let Some(value) = arg.strip_prefix("--tmux=") {
306            cli.tmux = TmuxMode::parse(value)?;
307            index += 1;
308            continue;
309        }
310
311        if arg == "--tmux" {
312            let value = args
313                .get(index + 1)
314                .ok_or_else(|| AppError::Usage("missing value for --tmux".to_string()))?;
315            cli.tmux = TmuxMode::parse(value)?;
316            index += 2;
317            continue;
318        }
319
320        if let Some(value) = arg.strip_prefix("--separator=") {
321            cli.separator = value.to_string();
322            index += 1;
323            continue;
324        }
325
326        if arg == "--separator" {
327            let value = args
328                .get(index + 1)
329                .ok_or_else(|| AppError::Usage("missing value for --separator".to_string()))?;
330            cli.separator = value.clone();
331            index += 2;
332            continue;
333        }
334
335        if arg == "-s" {
336            let value = args
337                .get(index + 1)
338                .ok_or_else(|| AppError::Usage("missing value for -s".to_string()))?;
339            cli.selection = Selection::parse(value)?;
340            index += 2;
341            continue;
342        }
343
344        if let Some(value) = arg.strip_prefix("-s")
345            && !value.is_empty()
346        {
347            cli.selection = Selection::parse(value)?;
348            index += 1;
349            continue;
350        }
351
352        if arg.starts_with('-') && arg != "-" {
353            return Err(AppError::Usage(format!("unknown option: {arg}")));
354        }
355
356        cli.files.push(PathBuf::from(arg));
357        index += 1;
358    }
359
360    Ok((ParseResult::Run, cli))
361}
362
363fn parse_max_bytes(value: &str) -> Result<usize, AppError> {
364    let parsed = value
365        .parse::<usize>()
366        .map_err(|_| AppError::Usage(format!("invalid integer for --max-bytes: '{value}'")))?;
367
368    if parsed == 0 {
369        return Err(AppError::Usage(
370            "--max-bytes must be greater than zero".to_string(),
371        ));
372    }
373
374    Ok(parsed)
375}
376
377pub fn print_help() {
378    println!("{USAGE}");
379}
380
381pub fn print_version() {
382    println!("sshclip {}", env!("CARGO_PKG_VERSION"));
383}
384
385pub fn run(cli: Cli) -> Result<(), AppError> {
386    if let Some(Commands::Doctor) = cli.command {
387        return run_doctor();
388    }
389
390    let sources = resolve_sources(&cli.files, cli.concat)?;
391    let input = read_input(
392        &sources,
393        cli.max_bytes,
394        cli.truncate,
395        cli.separator.as_bytes(),
396    )?;
397
398    let mut payload = build_osc52(input.data.as_slice(), cli.selection);
399    let mut warnings = Vec::<Warning>::new();
400
401    let in_tmux_env = env::var_os("TMUX")
402        .and_then(|value| (!value.is_empty()).then_some(value))
403        .is_some();
404
405    let wrap_for_tmux = match cli.tmux {
406        TmuxMode::Off => false,
407        TmuxMode::Auto => in_tmux_env,
408        TmuxMode::Force => true,
409    };
410
411    let mut restore_passthrough: Option<bool> = None;
412
413    if wrap_for_tmux {
414        if !cli.no_tmux_fix {
415            if in_tmux_env {
416                match tmux_get_allow_passthrough() {
417                    Ok(false) => match tmux_set_allow_passthrough(true) {
418                        Ok(()) => {
419                            restore_passthrough = Some(false);
420                        }
421                        Err(err) => warnings.push(Warning::tmux(format!(
422                            "failed to enable tmux allow-passthrough automatically: {err}"
423                        ))),
424                    },
425                    Ok(true) => {}
426                    Err(err) => warnings.push(Warning::tmux(format!(
427                        "could not inspect tmux allow-passthrough: {err}"
428                    ))),
429                }
430            } else {
431                warnings.push(Warning::tmux(
432                    "--tmux=force used without $TMUX; skipping allow-passthrough auto-fix",
433                ));
434            }
435        }
436
437        payload = wrap_tmux_passthrough(payload.as_slice());
438    }
439
440    write_payload(payload.as_slice(), cli.stdout)?;
441
442    if let Some(previous) = restore_passthrough
443        && let Err(err) = tmux_set_allow_passthrough(previous)
444    {
445        warnings.push(Warning::tmux(format!(
446            "failed to restore tmux allow-passthrough to 'off': {err}"
447        )));
448    }
449
450    let tmux_warning_text = join_tmux_warning_text(&warnings);
451    if cli.strict_tmux && !tmux_warning_text.is_empty() {
452        return Err(AppError::Tmux(tmux_warning_text));
453    }
454
455    if cli.strict && !warnings.is_empty() {
456        if !tmux_warning_text.is_empty() {
457            return Err(AppError::Tmux(tmux_warning_text));
458        }
459    }
460
461    if input.truncated {
462        eprintln!(
463            "sshclip: warning: input truncated to {} bytes due to --max-bytes",
464            cli.max_bytes
465        );
466    }
467
468    for warning in warnings {
469        eprintln!("sshclip: warning: {}", warning.message);
470    }
471
472    if !cli.quiet {
473        eprintln!(
474            "sshclip: emitted OSC 52 sequence for {} byte(s) of input{}",
475            input.data.len(),
476            if wrap_for_tmux { " (tmux wrapped)" } else { "" }
477        );
478    }
479
480    Ok(())
481}
482
483fn join_tmux_warning_text(warnings: &[Warning]) -> String {
484    let mut message = String::new();
485    for warning in warnings {
486        if warning.kind != WarningKind::Tmux {
487            continue;
488        }
489
490        if !message.is_empty() {
491            message.push_str("; ");
492        }
493
494        message.push_str(warning.message.as_str());
495    }
496
497    message
498}
499
500fn run_doctor() -> Result<(), AppError> {
501    let stdin_tty = io::stdin().is_terminal();
502    let stdout_tty = io::stdout().is_terminal();
503    let stderr_tty = io::stderr().is_terminal();
504    let tty_open_result = OpenOptions::new().write(true).open("/dev/tty");
505    let tty_available = tty_open_result.is_ok();
506
507    println!("sshclip doctor");
508    println!();
509
510    println!("Environment");
511    println!("  stdin is tty:  {stdin_tty}");
512    println!("  stdout is tty: {stdout_tty}");
513    println!("  stderr is tty: {stderr_tty}");
514    println!("  /dev/tty writable: {tty_available}");
515
516    if let Err(err) = tty_open_result {
517        println!("  /dev/tty note: {err}");
518    }
519
520    for var in [
521        "SSH_CONNECTION",
522        "SSH_CLIENT",
523        "SSH_TTY",
524        "TMUX",
525        "STY",
526        "TERM",
527        "TERM_PROGRAM",
528    ] {
529        match env::var(var) {
530            Ok(value) => println!("  {var}: {value}"),
531            Err(_) => println!("  {var}: <unset>"),
532        }
533    }
534
535    println!();
536    println!("Checks");
537
538    let in_ssh = env::var("SSH_CONNECTION").is_ok() || env::var("SSH_CLIENT").is_ok();
539    if in_ssh {
540        println!("  OK: SSH environment detected.");
541    } else {
542        println!("  INFO: SSH environment variables are not set.");
543    }
544
545    let in_tmux = env::var_os("TMUX")
546        .and_then(|value| (!value.is_empty()).then_some(value))
547        .is_some();
548
549    if in_tmux {
550        match tmux_get_allow_passthrough() {
551            Ok(true) => println!("  OK: tmux allow-passthrough is ON for current pane."),
552            Ok(false) => println!("  WARN: tmux allow-passthrough is OFF for current pane."),
553            Err(err) => println!("  WARN: could not read tmux allow-passthrough: {err}"),
554        }
555    } else {
556        println!("  INFO: not currently inside tmux (or $TMUX unset).");
557    }
558
559    println!();
560    println!("Recommended actions");
561
562    if !tty_available {
563        println!(
564            "  - /dev/tty is unavailable; use --stdout only when pipeline output is intended."
565        );
566    }
567
568    if in_tmux {
569        println!(
570            "  - If copying fails in tmux, enable passthrough for this pane: tmux set -p allow-passthrough on"
571        );
572        println!("  - To set globally: add `set -g allow-passthrough on` to ~/.tmux.conf");
573    }
574
575    println!(
576        "  - Ensure your local terminal emulator supports OSC 52 clipboard writes and has them enabled."
577    );
578    println!(
579        "  - Security note: any process that can print to your terminal can attempt clipboard writes via OSC 52."
580    );
581
582    Ok(())
583}
584
585struct InputData {
586    data: Vec<u8>,
587    truncated: bool,
588}
589
590fn resolve_sources<'a>(files: &'a [PathBuf], concat: bool) -> Result<Vec<Source<'a>>, AppError> {
591    if files.len() > 1 && !concat {
592        return Err(AppError::Usage(
593            "multiple inputs provided; pass --concat to combine them".to_string(),
594        ));
595    }
596
597    if files.is_empty() {
598        return Ok(vec![Source::Stdin]);
599    }
600
601    Ok(files
602        .iter()
603        .map(|path| {
604            if path == Path::new("-") {
605                Source::Stdin
606            } else {
607                Source::File(path)
608            }
609        })
610        .collect())
611}
612
613fn read_input(
614    sources: &[Source<'_>],
615    max_bytes: usize,
616    truncate: bool,
617    separator: &[u8],
618) -> Result<InputData, AppError> {
619    let mut out = Vec::new();
620    let mut truncated = false;
621
622    for (index, source) in sources.iter().enumerate() {
623        if index > 0 {
624            match append_slice_limited(&mut out, separator, max_bytes, truncate)? {
625                AppendResult::Appended => {}
626                AppendResult::Truncated => {
627                    truncated = true;
628                    break;
629                }
630            }
631        }
632
633        if truncated {
634            break;
635        }
636
637        match source {
638            Source::Stdin => {
639                let stdin = io::stdin();
640                let mut lock = stdin.lock();
641                if read_from_reader_limited(&mut lock, &mut out, max_bytes, truncate)? {
642                    truncated = true;
643                    break;
644                }
645            }
646            Source::File(path) => {
647                let mut file = File::open(path).map_err(|err| {
648                    AppError::Input(format!("failed to open '{}': {err}", path.display()))
649                })?;
650                if read_from_reader_limited(&mut file, &mut out, max_bytes, truncate)? {
651                    truncated = true;
652                    break;
653                }
654            }
655        }
656    }
657
658    Ok(InputData {
659        data: out,
660        truncated,
661    })
662}
663
664enum AppendResult {
665    Appended,
666    Truncated,
667}
668
669fn append_slice_limited(
670    out: &mut Vec<u8>,
671    extra: &[u8],
672    max_bytes: usize,
673    truncate: bool,
674) -> Result<AppendResult, AppError> {
675    if extra.is_empty() {
676        return Ok(AppendResult::Appended);
677    }
678
679    let remaining = max_bytes.saturating_sub(out.len());
680
681    if extra.len() <= remaining {
682        out.extend_from_slice(extra);
683        return Ok(AppendResult::Appended);
684    }
685
686    if truncate {
687        out.extend_from_slice(&extra[..remaining]);
688        return Ok(AppendResult::Truncated);
689    }
690
691    Err(AppError::TooLarge { max_bytes })
692}
693
694fn read_from_reader_limited<R: Read>(
695    reader: &mut R,
696    out: &mut Vec<u8>,
697    max_bytes: usize,
698    truncate: bool,
699) -> Result<bool, AppError> {
700    let mut buf = [0u8; 8192];
701
702    loop {
703        if out.len() == max_bytes {
704            if truncate {
705                return Ok(true);
706            }
707
708            let extra = reader
709                .read(&mut buf[..1])
710                .map_err(|err| AppError::Input(format!("failed to read input: {err}")))?;
711
712            if extra == 0 {
713                return Ok(false);
714            }
715
716            return Err(AppError::TooLarge { max_bytes });
717        }
718
719        let n = reader
720            .read(&mut buf)
721            .map_err(|err| AppError::Input(format!("failed to read input: {err}")))?;
722
723        if n == 0 {
724            return Ok(false);
725        }
726
727        let remaining = max_bytes - out.len();
728        if n <= remaining {
729            out.extend_from_slice(&buf[..n]);
730            continue;
731        }
732
733        out.extend_from_slice(&buf[..remaining]);
734        if truncate {
735            return Ok(true);
736        }
737
738        return Err(AppError::TooLarge { max_bytes });
739    }
740}
741
742fn write_payload(payload: &[u8], use_stdout: bool) -> Result<(), AppError> {
743    if use_stdout {
744        let mut out = io::stdout().lock();
745        out.write_all(payload)
746            .and_then(|()| out.flush())
747            .map_err(|err| AppError::Output(format!("failed to write payload to stdout: {err}")))?;
748        return Ok(());
749    }
750
751    let mut tty = OpenOptions::new()
752        .write(true)
753        .open("/dev/tty")
754        .map_err(|err| {
755            AppError::Output(format!(
756                "no writable /dev/tty available ({err}); run with --stdout to emit on stdout"
757            ))
758        })?;
759
760    tty.write_all(payload)
761        .and_then(|()| tty.flush())
762        .map_err(|err| {
763            AppError::Output(format!("failed writing OSC 52 payload to /dev/tty: {err}"))
764        })
765}
766
767fn tmux_get_allow_passthrough() -> Result<bool, String> {
768    let output = run_tmux(["show-options", "-p", "-v", "allow-passthrough"])?;
769
770    match String::from_utf8_lossy(output.stdout.as_slice()).trim() {
771        "on" => Ok(true),
772        "off" => Ok(false),
773        other => Err(format!("unexpected value for allow-passthrough: '{other}'")),
774    }
775}
776
777fn tmux_set_allow_passthrough(enable: bool) -> Result<(), String> {
778    let value = if enable { "on" } else { "off" };
779    run_tmux(["set-option", "-p", "allow-passthrough", value]).map(|_| ())
780}
781
782fn run_tmux<const N: usize>(args: [&str; N]) -> Result<Output, String> {
783    let mut command_line = String::from("tmux");
784    for arg in args {
785        let _ = write!(command_line, " {arg}");
786    }
787
788    let output = Command::new("tmux")
789        .args(args)
790        .output()
791        .map_err(|err| format!("could not execute `{command_line}`: {err}"))?;
792
793    if output.status.success() {
794        return Ok(output);
795    }
796
797    let stderr = String::from_utf8_lossy(output.stderr.as_slice());
798    Err(format!(
799        "`{command_line}` exited with {}: {}",
800        output.status,
801        stderr.trim()
802    ))
803}
804
805pub fn base64_len(raw_len: usize) -> usize {
806    raw_len.div_ceil(3) * 4
807}
808
809pub fn osc52_sequence_len(raw_len: usize) -> usize {
810    // Prefix: ESC ] 52 ; <selection> ;  (7 bytes), suffix: ESC \\ (2 bytes)
811    9 + base64_len(raw_len)
812}
813
814pub fn build_osc52(data: &[u8], selection: Selection) -> Vec<u8> {
815    let encoded = encode_base64(data);
816    let mut out = Vec::with_capacity(9 + encoded.len());
817    out.extend_from_slice(b"\x1b]52;");
818    out.push(selection.code());
819    out.push(b';');
820    out.extend_from_slice(encoded.as_bytes());
821    out.extend_from_slice(b"\x1b\\");
822    out
823}
824
825pub fn wrap_tmux_passthrough(inner: &[u8]) -> Vec<u8> {
826    let mut out = Vec::with_capacity((inner.len() * 2) + 8);
827    out.extend_from_slice(b"\x1bPtmux;");
828    for byte in inner {
829        if *byte == 0x1b {
830            out.push(0x1b);
831        }
832        out.push(*byte);
833    }
834    out.extend_from_slice(b"\x1b\\");
835    out
836}
837
838pub fn encode_base64(data: &[u8]) -> String {
839    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
840
841    let mut out = String::with_capacity(base64_len(data.len()));
842    let mut i = 0usize;
843
844    while i + 3 <= data.len() {
845        let chunk = &data[i..i + 3];
846        let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | (chunk[2] as u32);
847        out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
848        out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
849        out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
850        out.push(TABLE[(n & 0x3f) as usize] as char);
851        i += 3;
852    }
853
854    match data.len() - i {
855        0 => {}
856        1 => {
857            let n = (data[i] as u32) << 16;
858            out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
859            out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
860            out.push('=');
861            out.push('=');
862        }
863        2 => {
864            let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8);
865            out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
866            out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
867            out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
868            out.push('=');
869        }
870        _ => unreachable!(),
871    }
872
873    out
874}
875
876#[cfg(test)]
877mod tests {
878    use std::io::Cursor;
879
880    use super::*;
881
882    #[test]
883    fn base64_math_is_correct() {
884        assert_eq!(base64_len(0), 0);
885        assert_eq!(base64_len(1), 4);
886        assert_eq!(base64_len(2), 4);
887        assert_eq!(base64_len(3), 4);
888        assert_eq!(base64_len(4), 8);
889    }
890
891    #[test]
892    fn encode_base64_is_correct() {
893        assert_eq!(encode_base64(b""), "");
894        assert_eq!(encode_base64(b"f"), "Zg==");
895        assert_eq!(encode_base64(b"fo"), "Zm8=");
896        assert_eq!(encode_base64(b"foo"), "Zm9v");
897        assert_eq!(encode_base64(b"hello"), "aGVsbG8=");
898    }
899
900    #[test]
901    fn osc52_length_math_is_correct() {
902        assert_eq!(osc52_sequence_len(0), 9);
903        assert_eq!(osc52_sequence_len(3), 13);
904    }
905
906    #[test]
907    fn builds_clipboard_osc52_sequence() {
908        let out = build_osc52(b"hello", Selection::Clipboard);
909        assert_eq!(out, b"\x1b]52;c;aGVsbG8=\x1b\\");
910    }
911
912    #[test]
913    fn builds_primary_osc52_sequence() {
914        let out = build_osc52(b"A", Selection::Primary);
915        assert_eq!(out, b"\x1b]52;p;QQ==\x1b\\");
916    }
917
918    #[test]
919    fn tmux_wrapper_frames_and_escapes() {
920        let inner = b"\x1b]52;c;QQ==\x1b\\";
921        let wrapped = wrap_tmux_passthrough(inner);
922        assert_eq!(wrapped, b"\x1bPtmux;\x1b\x1b]52;c;QQ==\x1b\x1b\\\x1b\\");
923    }
924
925    #[test]
926    fn resolve_sources_rejects_multiple_without_concat() {
927        let files = vec![PathBuf::from("a"), PathBuf::from("b")];
928        let err = resolve_sources(&files, false).expect_err("expected usage error");
929        assert!(matches!(err, AppError::Usage(_)));
930    }
931
932    #[test]
933    fn reader_exact_limit_succeeds() {
934        let mut cursor = Cursor::new(b"12345".to_vec());
935        let mut out = Vec::new();
936        let truncated = read_from_reader_limited(&mut cursor, &mut out, 5, false).unwrap();
937        assert!(!truncated);
938        assert_eq!(out, b"12345");
939    }
940
941    #[test]
942    fn reader_over_limit_fails_without_truncate() {
943        let mut cursor = Cursor::new(b"123456".to_vec());
944        let mut out = Vec::new();
945        let err = read_from_reader_limited(&mut cursor, &mut out, 5, false)
946            .expect_err("expected too large");
947        assert!(matches!(err, AppError::TooLarge { max_bytes: 5 }));
948    }
949
950    #[test]
951    fn reader_over_limit_truncates() {
952        let mut cursor = Cursor::new(b"123456".to_vec());
953        let mut out = Vec::new();
954        let truncated = read_from_reader_limited(&mut cursor, &mut out, 5, true).unwrap();
955        assert!(truncated);
956        assert_eq!(out, b"12345");
957    }
958
959    #[test]
960    fn separator_append_respects_limit() {
961        let mut out = b"abcd".to_vec();
962        let result = append_slice_limited(&mut out, b"XYZ", 5, true).unwrap();
963        assert!(matches!(result, AppendResult::Truncated));
964        assert_eq!(out, b"abcdX");
965    }
966
967    #[test]
968    fn parse_help_and_version() {
969        let parsed = parse_cli_args(vec!["--help".to_string()]).unwrap();
970        assert!(matches!(parsed.0, ParseResult::Help));
971
972        let parsed = parse_cli_args(vec!["-V".to_string()]).unwrap();
973        assert!(matches!(parsed.0, ParseResult::Version));
974    }
975
976    #[test]
977    fn parse_selection_short_flag() {
978        let parsed = parse_cli_args(vec!["-s".to_string(), "primary".to_string()]).unwrap();
979        assert!(matches!(parsed.0, ParseResult::Run));
980        assert!(matches!(parsed.1.selection, Selection::Primary));
981    }
982
983    #[test]
984    fn parse_doctor_subcommand() {
985        let parsed = parse_cli_args(vec!["doctor".to_string()]).unwrap();
986        assert!(matches!(parsed.0, ParseResult::Run));
987        assert!(matches!(parsed.1.command, Some(Commands::Doctor)));
988    }
989}