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 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}