Skip to main content

leviath_sys/
tty.rs

1//! Terminal clipboard support via the OSC52 escape sequence.
2//!
3//! OSC52 is a last-resort clipboard mechanism: it asks the *terminal emulator*
4//! to set the system clipboard, so it works over SSH and without any native
5//! clipboard tool. The bytes must reach the real terminal - the controlling
6//! `/dev/tty` or stdout.
7//!
8//! This module holds the pure, fully-tested pieces: `osc52_sequence` (the
9//! base64/escape encoding) and [`osc52_write_via`] (the tty-then-stdout branch
10//! logic, parameterized over the tty opener and the fallback sink so every
11//! branch is exercised via injected fakes). The genuinely-untestable real-I/O
12//! leaves (opening `/dev/tty`, writing the real `stdout()`) are composed in the
13//! CLI binary - see `real_yank` in `crates/leviath-cli/src/main.rs`.
14
15use std::fs::File;
16use std::io::{self, Write};
17
18/// Base64-encode `text` and wrap it in the OSC52 "set clipboard" escape sequence.
19fn osc52_sequence(text: &str) -> String {
20    use std::fmt::Write as FmtWrite;
21    let bytes = text.as_bytes();
22    let mut encoded = String::with_capacity((bytes.len() * 4 / 3) + 8);
23    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
24    let mut i = 0;
25    while i + 3 <= bytes.len() {
26        let b0 = bytes[i] as usize;
27        let b1 = bytes[i + 1] as usize;
28        let b2 = bytes[i + 2] as usize;
29        let _ = FmtWrite::write_char(&mut encoded, TABLE[b0 >> 2] as char);
30        let _ = FmtWrite::write_char(&mut encoded, TABLE[((b0 & 3) << 4) | (b1 >> 4)] as char);
31        let _ = FmtWrite::write_char(&mut encoded, TABLE[((b1 & 0xf) << 2) | (b2 >> 6)] as char);
32        let _ = FmtWrite::write_char(&mut encoded, TABLE[b2 & 0x3f] as char);
33        i += 3;
34    }
35    let rem = bytes.len() - i;
36    if rem == 1 {
37        let b0 = bytes[i] as usize;
38        let _ = FmtWrite::write_char(&mut encoded, TABLE[b0 >> 2] as char);
39        let _ = FmtWrite::write_char(&mut encoded, TABLE[(b0 & 3) << 4] as char);
40        encoded.push_str("==");
41    } else if rem == 2 {
42        let b0 = bytes[i] as usize;
43        let b1 = bytes[i + 1] as usize;
44        let _ = FmtWrite::write_char(&mut encoded, TABLE[b0 >> 2] as char);
45        let _ = FmtWrite::write_char(&mut encoded, TABLE[((b0 & 3) << 4) | (b1 >> 4)] as char);
46        let _ = FmtWrite::write_char(&mut encoded, TABLE[(b1 & 0xf) << 2] as char);
47        encoded.push('=');
48    }
49    format!("\x1b]52;c;{}\x07", encoded)
50}
51
52/// Core OSC52 write logic, parameterized over how to open the TTY and where the
53/// stdout fallback writes, so every branch is exercisable without touching a
54/// real terminal. `pub` so the CLI binary can compose it with the real
55/// `/dev/tty` opener + real `stdout()` (the un-unit-testable leaves) - see
56/// `real_yank` in `crates/leviath-cli/src/main.rs`.
57///
58/// The fallback is a `&mut dyn Write` (not a generic `T: Write`) deliberately:
59/// a single vtable dispatch on this cold, human-driven path costs nothing, and
60/// a non-generic function has exactly one coverage-mapping instance instead of
61/// one per caller type - no phantom-uncovered monomorphization for llvm-cov.
62pub fn osc52_write_via(
63    text: &str,
64    open_tty: fn() -> io::Result<File>,
65    stdout_fallback: &mut dyn Write,
66) -> bool {
67    let osc = osc52_sequence(text);
68    if let Ok(mut tty) = open_tty()
69        && tty.write_all(osc.as_bytes()).is_ok()
70        && tty.flush().is_ok()
71    {
72        return true;
73    }
74    // Fallback: write to stdout. Report the real outcome so callers can show an
75    // error when even this fails.
76    stdout_fallback.write_all(osc.as_bytes()).is_ok() && stdout_fallback.flush().is_ok()
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn osc52_sequence_encodes_all_remainder_lengths() {
85        assert_eq!(osc52_sequence("foo"), "\x1b]52;c;Zm9v\x07"); // rem == 0
86        assert_eq!(osc52_sequence("f"), "\x1b]52;c;Zg==\x07"); // rem == 1
87        assert_eq!(osc52_sequence("fo"), "\x1b]52;c;Zm8=\x07"); // rem == 2
88        assert!(osc52_sequence("").starts_with("\x1b]52;c;"));
89    }
90
91    fn open_fails() -> io::Result<File> {
92        Err(io::Error::other("no tty"))
93    }
94
95    // Cross-platform stand-ins for a real TTY: a writable temp file (accepts
96    // writes) and a read-only temp file (writes to it fail). Using a temp file
97    // rather than `/dev/null` keeps these tests - and thus `osc52_write_via`'s
98    // tty-success and tty-write-fails branches - covered on every OS, not just
99    // Unix.
100    fn open_temp_writable() -> io::Result<File> {
101        std::fs::OpenOptions::new()
102            .create(true)
103            .write(true)
104            .truncate(true)
105            .open(std::env::temp_dir().join("lev_sys_osc52_fake_tty_w"))
106    }
107
108    fn open_temp_readonly() -> io::Result<File> {
109        let path = std::env::temp_dir().join("lev_sys_osc52_fake_tty_ro");
110        let _ = std::fs::write(&path, b"");
111        std::fs::OpenOptions::new().read(true).open(&path)
112    }
113
114    /// Fails on `write` (so the `&&` short-circuits before `flush`).
115    struct WriteFailWriter;
116    impl Write for WriteFailWriter {
117        fn write(&mut self, _: &[u8]) -> io::Result<usize> {
118            Err(io::Error::other("write fail"))
119        }
120        fn flush(&mut self) -> io::Result<()> {
121            Ok(())
122        }
123    }
124
125    /// Accepts `write` but fails on `flush` (exercises the flush half of the `&&`).
126    struct FlushFailWriter {
127        buf: Vec<u8>,
128    }
129    impl Write for FlushFailWriter {
130        fn write(&mut self, data: &[u8]) -> io::Result<usize> {
131            self.buf.extend_from_slice(data);
132            Ok(data.len())
133        }
134        fn flush(&mut self) -> io::Result<()> {
135            Err(io::Error::other("flush fail"))
136        }
137    }
138
139    #[test]
140    fn write_via_returns_true_when_tty_write_succeeds() {
141        // The writable temp file accepts the write: exercises the "tty write
142        // succeeded, return early" branch with no real terminal involved.
143        let mut sink = Vec::new();
144        assert!(osc52_write_via("hi", open_temp_writable, &mut sink));
145        assert!(
146            sink.is_empty(),
147            "fallback must not run when the tty write succeeds"
148        );
149    }
150
151    #[test]
152    fn write_via_falls_back_when_tty_opens_but_write_fails() {
153        // The temp file opened read-only makes write_all fail, so control
154        // falls through to the stdout fallback.
155        let mut sink = Vec::new();
156        assert!(osc52_write_via("hi", open_temp_readonly, &mut sink));
157        assert!(
158            !sink.is_empty(),
159            "fallback should have written the sequence"
160        );
161    }
162
163    #[test]
164    fn write_via_falls_back_when_tty_open_fails() {
165        let mut sink = Vec::new();
166        assert!(osc52_write_via("hi", open_fails, &mut sink));
167        assert!(!sink.is_empty());
168    }
169
170    #[test]
171    fn write_via_returns_false_when_fallback_write_fails() {
172        let mut sink = WriteFailWriter;
173        assert!(!osc52_write_via("hi", open_fails, &mut sink));
174        // `flush` is unreachable through `osc52_write_via` here (the failing
175        // `write_all` short-circuits the `&&`), so call it directly to cover
176        // its trivial body rather than leave a dead region.
177        assert!(sink.flush().is_ok());
178    }
179
180    #[test]
181    fn write_via_returns_false_when_fallback_flush_fails() {
182        // write succeeds but flush fails - exercises the flush half of the
183        // final `write_all(..).is_ok() && flush(..).is_ok()`.
184        let mut sink = FlushFailWriter { buf: Vec::new() };
185        assert!(!osc52_write_via("hi", open_fails, &mut sink));
186        assert!(
187            !sink.buf.is_empty(),
188            "write should have run before flush failed"
189        );
190    }
191}