codex_protocol/exec_output.rs
1//! Text encoding detection and conversion utilities for shell output.
2//!
3//! Windows users frequently run into code pages such as CP1251 or CP866 when invoking commands
4//! through VS Code. Those bytes show up as invalid UTF-8 and used to be replaced with the standard
5//! Unicode replacement character. We now lean on `chardetng` and `encoding_rs` so we can
6//! automatically detect and decode the vast majority of legacy encodings before falling back to
7//! lossy UTF-8 decoding.
8
9use chardetng::EncodingDetector;
10use encoding_rs::Encoding;
11use encoding_rs::IBM866;
12use encoding_rs::WINDOWS_1252;
13use std::time::Duration;
14
15#[derive(Debug, Clone)]
16pub struct StreamOutput<T: Clone> {
17 pub text: T,
18 pub truncated_after_lines: Option<u32>,
19}
20
21impl StreamOutput<String> {
22 pub fn new(text: String) -> Self {
23 Self {
24 text,
25 truncated_after_lines: None,
26 }
27 }
28}
29
30impl StreamOutput<Vec<u8>> {
31 pub fn from_utf8_lossy(&self) -> StreamOutput<String> {
32 StreamOutput {
33 text: bytes_to_string_smart(&self.text),
34 truncated_after_lines: self.truncated_after_lines,
35 }
36 }
37}
38
39#[derive(Clone, Debug)]
40pub struct ExecToolCallOutput {
41 pub exit_code: i32,
42 pub stdout: StreamOutput<String>,
43 pub stderr: StreamOutput<String>,
44 pub aggregated_output: StreamOutput<String>,
45 pub duration: Duration,
46 pub timed_out: bool,
47}
48
49impl Default for ExecToolCallOutput {
50 fn default() -> Self {
51 Self {
52 exit_code: 0,
53 stdout: StreamOutput::new(String::new()),
54 stderr: StreamOutput::new(String::new()),
55 aggregated_output: StreamOutput::new(String::new()),
56 duration: Duration::ZERO,
57 timed_out: false,
58 }
59 }
60}
61
62/// Attempts to convert arbitrary bytes to UTF-8 with best-effort encoding detection.
63pub fn bytes_to_string_smart(bytes: &[u8]) -> String {
64 if bytes.is_empty() {
65 return String::new();
66 }
67
68 if let Ok(utf8_str) = std::str::from_utf8(bytes) {
69 return utf8_str.to_owned();
70 }
71
72 let encoding = detect_encoding(bytes);
73 decode_bytes(bytes, encoding)
74}
75
76// Windows-1252 reassigns a handful of 0x80-0x9F slots to smart punctuation (curly quotes, dashes,
77// ™). CP866 uses those *same byte values* for uppercase Cyrillic letters. When chardetng sees shell
78// snippets that mix these bytes with ASCII it sometimes guesses IBM866, so “smart quotes” render as
79// Cyrillic garbage (“УФЦ”) in VS Code. However, CP866 uppercase tokens are perfectly valid output
80// (e.g., `ПРИ test`) so we cannot flip every 0x80-0x9F byte to Windows-1252 either. The compromise
81// is to only coerce IBM866 to Windows-1252 when (a) the high bytes are exclusively the punctuation
82// values listed below and (b) we spot adjacent ASCII. This targets the real failure case without
83// clobbering legitimate Cyrillic text. If another code page has a similar collision, introduce a
84// dedicated allowlist (like this one) plus unit tests that capture the actual shell output we want
85// to preserve. Windows-1252 byte values for smart punctuation.
86const WINDOWS_1252_PUNCT_BYTES: [u8; 8] = [
87 0x91, // ‘ (left single quotation mark)
88 0x92, // ’ (right single quotation mark)
89 0x93, // “ (left double quotation mark)
90 0x94, // ” (right double quotation mark)
91 0x95, // • (bullet)
92 0x96, // – (en dash)
93 0x97, // — (em dash)
94 0x99, // ™ (trade mark sign)
95];
96
97fn detect_encoding(bytes: &[u8]) -> &'static Encoding {
98 let mut detector = EncodingDetector::new();
99 detector.feed(bytes, true);
100 let (encoding, _is_confident) = detector.guess_assess(None, true);
101
102 // chardetng occasionally reports IBM866 for short strings that only contain Windows-1252 “smart
103 // punctuation” bytes (0x80-0x9F) because that range maps to Cyrillic letters in IBM866. When
104 // those bytes show up alongside an ASCII word (typical shell output: `"“`test), we know the
105 // intent was likely CP1252 quotes/dashes. Prefer WINDOWS_1252 in that specific situation so we
106 // render the characters users expect instead of Cyrillic junk. References:
107 // - Windows-1252 reserving 0x80-0x9F for curly quotes/dashes:
108 // https://en.wikipedia.org/wiki/Windows-1252
109 // - CP866 mapping 0x93/0x94/0x96 to Cyrillic letters, so the same bytes show up as “УФЦ” when
110 // mis-decoded: https://www.unicode.org/Public/MAPPINGS/VENDORS/MICSFT/PC/CP866.TXT
111 if encoding == IBM866 && looks_like_windows_1252_punctuation(bytes) {
112 return WINDOWS_1252;
113 }
114
115 encoding
116}
117
118fn decode_bytes(bytes: &[u8], encoding: &'static Encoding) -> String {
119 let (decoded, _, had_errors) = encoding.decode(bytes);
120
121 if had_errors {
122 return String::from_utf8_lossy(bytes).into_owned();
123 }
124
125 decoded.into_owned()
126}
127
128/// Detect whether the byte stream looks like Windows-1252 “smart punctuation” wrapped around
129/// otherwise-ASCII text.
130///
131/// Context: IBM866 and Windows-1252 share the 0x80-0x9F slot range. In IBM866 these bytes decode to
132/// Cyrillic letters, whereas Windows-1252 maps them to curly quotes and dashes. chardetng can guess
133/// IBM866 for short snippets that only contain those bytes, which turns shell output such as
134/// `“test”` into unreadable Cyrillic. To avoid that, we treat inputs comprising a handful of bytes
135/// from the problematic range plus ASCII letters as CP1252 punctuation. We deliberately do *not*
136/// cap how many of those punctuation bytes we accept: VS Code frequently prints several quoted
137/// phrases (e.g., `"foo" - "bar"`), and truncating the count would once again mis-decode those as
138/// Cyrillic. If we discover additional encodings with overlapping byte ranges, prefer adding
139/// encoding-specific byte allowlists like `WINDOWS_1252_PUNCT` and tests that exercise real-world
140/// shell snippets.
141fn looks_like_windows_1252_punctuation(bytes: &[u8]) -> bool {
142 let mut saw_extended_punctuation = false;
143 let mut saw_ascii_word = false;
144
145 for &byte in bytes {
146 if byte >= 0xA0 {
147 return false;
148 }
149 if (0x80..=0x9F).contains(&byte) {
150 if !is_windows_1252_punct(byte) {
151 return false;
152 }
153 saw_extended_punctuation = true;
154 }
155 if byte.is_ascii_alphabetic() {
156 saw_ascii_word = true;
157 }
158 }
159
160 saw_extended_punctuation && saw_ascii_word
161}
162
163fn is_windows_1252_punct(byte: u8) -> bool {
164 WINDOWS_1252_PUNCT_BYTES.contains(&byte)
165}
166
167#[cfg(test)]
168#[path = "exec_output_tests.rs"]
169mod tests;