Skip to main content

ctx_exec/
lib.rs

1//! `ctx-exec` — token-efficient command output compression.
2//!
3//! Compresses captured command output while keeping the signal: lines that
4//! match critical patterns (error, warning, failed, panic, …) or user-supplied
5//! `--keep` patterns survive verbatim, the first/last few lines form a summary
6//! head/tail, and everything in between folds into a single deterministic
7//! marker: `... [N lines omitted]`.
8//!
9//! Matching is rule-driven with the `regex` crate — the same engine ripgrep
10//! uses — so patterns follow rg's default regex syntax (case-insensitive by
11//! default, matching rg's default behavior).
12//!
13//! Byte-stable by design: output is a pure function of the input text and the
14//! options. No timestamps, no counters, no environment dependence.
15
16use regex::{Regex, RegexBuilder};
17use serde::Serialize;
18
19/// Default number of leading lines kept verbatim as the head summary.
20pub const DEFAULT_HEAD_LINES: usize = 5;
21
22/// Default number of trailing lines kept verbatim as the tail summary.
23pub const DEFAULT_TAIL_LINES: usize = 5;
24
25/// Default keep patterns (cli-contract.md §7). Lines matching any of these
26/// (case-insensitive) are always kept, wherever they appear in the output.
27pub const DEFAULT_KEEP_PATTERNS: &[&str] = &["error", "warning", "failed", "panic", "fatal"];
28
29/// Outputs at or below this many lines are passed through uncompressed.
30pub const DEFAULT_COLLAPSE_THRESHOLD: usize = 20;
31
32/// Errors produced by the compression engine.
33#[derive(Debug, thiserror::Error)]
34pub enum ExecError {
35    #[error("invalid regex pattern `{pattern}`: {message}")]
36    InvalidPattern { pattern: String, message: String },
37}
38
39/// Tuning knobs for [`compress`].
40#[derive(Debug, Clone)]
41pub struct CompressOptions {
42    /// Complete keep-pattern list (rg syntax); matching lines are kept
43    /// verbatim. Starts from [`DEFAULT_KEEP_PATTERNS`]; replace the whole list
44    /// to drop the defaults.
45    pub keep_patterns: Vec<String>,
46    /// Leading lines kept verbatim as the head summary.
47    pub head_lines: usize,
48    /// Trailing lines kept verbatim as the tail summary.
49    pub tail_lines: usize,
50    /// Outputs with at most this many lines pass through uncompressed.
51    pub collapse_threshold: usize,
52}
53
54impl Default for CompressOptions {
55    fn default() -> Self {
56        Self {
57            keep_patterns: DEFAULT_KEEP_PATTERNS
58                .iter()
59                .map(|s| s.to_string())
60                .collect(),
61            head_lines: DEFAULT_HEAD_LINES,
62            tail_lines: DEFAULT_TAIL_LINES,
63            collapse_threshold: DEFAULT_COLLAPSE_THRESHOLD,
64        }
65    }
66}
67
68/// Deterministic statistics about a compression pass.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
70pub struct CompressStats {
71    pub total_lines: usize,
72    pub kept_lines: usize,
73    pub omitted_lines: usize,
74    pub original_tokens: usize,
75    pub compressed_tokens: usize,
76    pub saved_percent: u32,
77}
78
79/// The result of a compression pass: the rendered text plus its statistics.
80#[derive(Debug, Clone)]
81pub struct CompressResult {
82    pub text: String,
83    pub stats: CompressStats,
84}
85
86/// Real token count via the cl100k_base BPE tokenizer (GPT-4-class; see
87/// cli-contract.md §8). A deterministic function of the text, so byte
88/// stability is unaffected. The bundled encoding is parsed once and cached.
89pub fn estimate_tokens(text: &str) -> usize {
90    static BPE: std::sync::OnceLock<tiktoken_rs::CoreBPE> = std::sync::OnceLock::new();
91    let bpe = BPE.get_or_init(|| {
92        tiktoken_rs::cl100k_base().expect("bundled cl100k_base encoding is corrupt")
93    });
94    bpe.encode_with_special_tokens(text).len()
95}
96
97/// Compress command output.
98///
99/// Outputs with at most `options.collapse_threshold` lines pass through
100/// unchanged. Otherwise kept lines are: the head summary, the tail summary,
101/// and every line matching a default keep pattern or a user `--keep` pattern.
102/// Omitted runs between kept lines fold into a single `... [N lines omitted]`
103/// marker.
104///
105/// This is the one-shot entry point; it feeds the whole text through a
106/// [`StreamCompressor`], so both paths render byte-identical output.
107pub fn compress(output: &str, options: &CompressOptions) -> Result<CompressResult, ExecError> {
108    let mut compressor = StreamCompressor::new(options)?;
109    compressor.push(output.as_bytes());
110    Ok(compressor.finish())
111}
112
113/// Streaming compressor: feed raw output bytes incrementally (as a command
114/// produces them) and render the same compressed view as [`compress`] on
115/// finish. Memory stays bounded by the head/tail windows and the lines that
116/// match keep patterns — the mass of uninteresting middle lines is only
117/// counted, never stored, so a command emitting gigabytes cannot exhaust
118/// memory.
119///
120/// Deterministic: for a given byte stream and options, [`StreamCompressor::finish`]
121/// always returns the same result, byte-stable with [`compress`].
122pub struct StreamCompressor {
123    matcher: KeepMatcher,
124    head_lines: usize,
125    tail_lines: usize,
126    collapse_threshold: usize,
127    /// Total lines seen so far (including a final unterminated line).
128    total: usize,
129    /// Completed head lines (the first `head_lines` lines).
130    head: Vec<String>,
131    /// The last `tail_lines` lines with their keep-pattern match flags.
132    ring: std::collections::VecDeque<(String, bool)>,
133    /// Keep-pattern matches that left the tail window: `(omitted_run_before, line)`.
134    kept_mid: Vec<(usize, String)>,
135    /// Consecutive non-kept middle lines since the last kept line.
136    run: usize,
137    /// Incremental cl100k token count of the raw bytes fed so far.
138    raw_tokens: usize,
139    /// Raw bytes of the first `collapse_threshold` lines, held verbatim for
140    /// the passthrough case.
141    prefix: Vec<u8>,
142    /// Number of complete lines buffered in `prefix`.
143    prefix_lines: usize,
144    /// Bytes of the current (unterminated) line.
145    pending: Vec<u8>,
146}
147
148impl StreamCompressor {
149    /// Create a streaming compressor for `options`.
150    pub fn new(options: &CompressOptions) -> Result<Self, ExecError> {
151        Ok(Self {
152            matcher: KeepMatcher::new(options)?,
153            head_lines: options.head_lines,
154            tail_lines: options.tail_lines,
155            collapse_threshold: options.collapse_threshold,
156            total: 0,
157            head: Vec::new(),
158            ring: std::collections::VecDeque::new(),
159            kept_mid: Vec::new(),
160            run: 0,
161            raw_tokens: 0,
162            prefix: Vec::new(),
163            prefix_lines: 0,
164            pending: Vec::new(),
165        })
166    }
167
168    /// Feed a chunk of raw output bytes. Chunks may split lines anywhere.
169    pub fn push(&mut self, bytes: &[u8]) {
170        self.pending.extend_from_slice(bytes);
171        while let Some(pos) = self.pending.iter().position(|b| *b == b'\n') {
172            let chunk: Vec<u8> = self.pending.drain(..=pos).collect();
173            let mut line = chunk.as_slice();
174            line = &line[..line.len() - 1]; // strip '\n'
175            if line.last() == Some(&b'\r') {
176                line = &line[..line.len() - 1];
177            }
178            self.consume(chunk.as_slice(), line);
179        }
180    }
181
182    /// Render the final compressed view and statistics.
183    pub fn finish(mut self) -> CompressResult {
184        if !self.pending.is_empty() {
185            // Final unterminated line: `str::lines` keeps a trailing `\r`
186            // here, so no stripping.
187            let chunk = std::mem::take(&mut self.pending);
188            self.consume(&chunk, &chunk);
189        }
190        if self.total <= self.collapse_threshold {
191            let text = String::from_utf8_lossy(&self.prefix).into_owned();
192            let tokens = estimate_tokens(&text);
193            return CompressResult {
194                text,
195                stats: CompressStats {
196                    total_lines: self.total,
197                    kept_lines: self.total,
198                    omitted_lines: 0,
199                    original_tokens: tokens,
200                    compressed_tokens: tokens,
201                    saved_percent: 0,
202                },
203            };
204        }
205        let kept_lines = self.head.len() + self.kept_mid.len() + self.ring.len();
206        let mut out = String::new();
207        let mut first = true;
208        for line in &self.head {
209            if !first {
210                out.push('\n');
211            }
212            out.push_str(line);
213            first = false;
214        }
215        for (omitted, line) in &self.kept_mid {
216            if *omitted > 0 {
217                if !first {
218                    out.push('\n');
219                }
220                out.push_str(&omit_marker(*omitted));
221                first = false;
222            }
223            if !first {
224                out.push('\n');
225            }
226            out.push_str(line);
227            first = false;
228        }
229        if self.run > 0 {
230            if !first {
231                out.push('\n');
232            }
233            out.push_str(&omit_marker(self.run));
234            first = false;
235        }
236        for (line, _) in &self.ring {
237            if !first {
238                out.push('\n');
239            }
240            out.push_str(line);
241            first = false;
242        }
243        let compressed_tokens = estimate_tokens(&out);
244        CompressResult {
245            text: out,
246            stats: CompressStats {
247                total_lines: self.total,
248                kept_lines,
249                omitted_lines: self.total - kept_lines,
250                original_tokens: self.raw_tokens,
251                compressed_tokens,
252                saved_percent: saved_pct(self.raw_tokens, compressed_tokens),
253            },
254        }
255    }
256
257    /// Process one complete line (`chunk` includes the `\n` terminator, or is
258    /// the final unterminated line).
259    fn consume(&mut self, chunk: &[u8], line: &[u8]) {
260        self.total += 1;
261        // Incremental token count. Chunks include their terminator, so counts
262        // equal whole-text cl100k counts except for rare runs of 2+ blank
263        // lines after punctuation (a regex chunk swallows several `\n`s at
264        // once); the drift is a token or two and `saved%` is approximate by
265        // contract.
266        self.raw_tokens += estimate_tokens(&String::from_utf8_lossy(chunk));
267        if self.total <= self.collapse_threshold {
268            self.prefix.extend_from_slice(chunk);
269            self.prefix_lines += 1;
270            return;
271        }
272        if !self.prefix.is_empty() {
273            let prefix = std::mem::take(&mut self.prefix);
274            let mut rest: &[u8] = &prefix;
275            let mut line_no = 0usize;
276            while let Some(pos) = rest.iter().position(|b| *b == b'\n') {
277                let mut l = &rest[..pos];
278                if l.last() == Some(&b'\r') {
279                    l = &l[..l.len() - 1];
280                }
281                line_no += 1;
282                self.feed(l, line_no);
283                rest = &rest[pos + 1..];
284            }
285            self.prefix_lines = 0;
286        }
287        self.feed(line, self.total);
288    }
289
290    /// Route one line through the keep/tail state machine. `line_no` is the
291    /// line's 1-based position in the stream.
292    fn feed(&mut self, line: &[u8], line_no: usize) {
293        let text = String::from_utf8_lossy(line);
294        let matched = self.matcher.is_match(&text);
295        if line_no <= self.head_lines {
296            self.head.push(text.into_owned());
297        } else {
298            self.ring.push_back((text.into_owned(), matched));
299            if self.ring.len() > self.tail_lines {
300                let (l, m) = self.ring.pop_front().expect("ring not empty");
301                if m {
302                    self.kept_mid.push((self.run, l));
303                    self.run = 0;
304                } else {
305                    self.run += 1;
306                }
307            }
308        }
309    }
310}
311
312fn omit_marker(n: usize) -> String {
313    format!("... [{n} lines omitted]")
314}
315
316fn saved_pct(original: usize, compressed: usize) -> u32 {
317    if original == 0 {
318        return 0;
319    }
320    ((original - compressed.min(original)) * 100 / original) as u32
321}
322
323/// Case-insensitive matcher over the keep patterns, compiled individually so
324/// per-pattern anchoring and capture semantics survive. An empty pattern list
325/// matches nothing — it must not degrade to a match-everything regex.
326struct KeepMatcher {
327    patterns: Vec<Regex>,
328}
329
330impl KeepMatcher {
331    fn new(options: &CompressOptions) -> Result<Self, ExecError> {
332        let mut patterns = Vec::with_capacity(options.keep_patterns.len());
333        for pattern in &options.keep_patterns {
334            patterns.push(
335                RegexBuilder::new(pattern)
336                    .case_insensitive(true)
337                    .build()
338                    .map_err(|e| ExecError::InvalidPattern {
339                        pattern: pattern.clone(),
340                        message: e.to_string(),
341                    })?,
342            );
343        }
344        Ok(Self { patterns })
345    }
346
347    fn is_match(&self, line: &str) -> bool {
348        self.patterns.iter().any(|re| re.is_match(line))
349    }
350}