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/// Token approximation of a text's cost: 4 bytes ~ 1 token, matching the
87/// estimate used by `ctx-symbol`.
88pub fn estimate_tokens(text: &str) -> usize {
89    text.len() / 4
90}
91
92/// Compress command output.
93///
94/// Outputs with at most `options.collapse_threshold` lines pass through
95/// unchanged. Otherwise kept lines are: the head summary, the tail summary,
96/// and every line matching a default keep pattern or a user `--keep` pattern.
97/// Omitted runs between kept lines fold into a single `... [N lines omitted]`
98/// marker.
99pub fn compress(output: &str, options: &CompressOptions) -> Result<CompressResult, ExecError> {
100    let lines: Vec<&str> = output.lines().collect();
101    let total = lines.len();
102    let original_tokens = estimate_tokens(output);
103    let matcher = KeepMatcher::new(options)?;
104
105    if total <= options.collapse_threshold {
106        return Ok(CompressResult {
107            text: output.to_string(),
108            stats: CompressStats {
109                total_lines: total,
110                kept_lines: total,
111                omitted_lines: 0,
112                original_tokens,
113                compressed_tokens: original_tokens,
114                saved_percent: 0,
115            },
116        });
117    }
118
119    let mut kept = Vec::with_capacity(total);
120    for (i, line) in lines.iter().enumerate() {
121        let in_summary = i < options.head_lines || i >= total.saturating_sub(options.tail_lines);
122        kept.push(in_summary || matcher.is_match(line));
123    }
124    let kept_lines = kept.iter().filter(|k| **k).count();
125
126    let text = render(&lines, &kept);
127    let compressed_tokens = estimate_tokens(&text);
128    let saved_percent = saved_pct(original_tokens, compressed_tokens);
129
130    Ok(CompressResult {
131        text,
132        stats: CompressStats {
133            total_lines: total,
134            kept_lines,
135            omitted_lines: total - kept_lines,
136            original_tokens,
137            compressed_tokens,
138            saved_percent,
139        },
140    })
141}
142
143/// Render the kept-line mask into the final text with omission markers.
144fn render(lines: &[&str], kept: &[bool]) -> String {
145    let mut out = String::new();
146    let mut omitted = 0usize;
147    let mut first = true;
148    for (i, line) in lines.iter().enumerate() {
149        if kept[i] {
150            if omitted > 0 {
151                if !first {
152                    out.push('\n');
153                }
154                out.push_str(&omit_marker(omitted));
155                omitted = 0;
156                first = false;
157            }
158            if !first {
159                out.push('\n');
160            }
161            out.push_str(line);
162            first = false;
163        } else {
164            omitted += 1;
165        }
166    }
167    if omitted > 0 {
168        if !first {
169            out.push('\n');
170        }
171        out.push_str(&omit_marker(omitted));
172    }
173    out
174}
175
176fn omit_marker(n: usize) -> String {
177    format!("... [{n} lines omitted]")
178}
179
180fn saved_pct(original: usize, compressed: usize) -> u32 {
181    if original == 0 {
182        return 0;
183    }
184    ((original - compressed.min(original)) * 100 / original) as u32
185}
186
187/// Case-insensitive matcher over the keep patterns, compiled individually so
188/// per-pattern anchoring and capture semantics survive. An empty pattern list
189/// matches nothing — it must not degrade to a match-everything regex.
190struct KeepMatcher {
191    patterns: Vec<Regex>,
192}
193
194impl KeepMatcher {
195    fn new(options: &CompressOptions) -> Result<Self, ExecError> {
196        let mut patterns = Vec::with_capacity(options.keep_patterns.len());
197        for pattern in &options.keep_patterns {
198            patterns.push(
199                RegexBuilder::new(pattern)
200                    .case_insensitive(true)
201                    .build()
202                    .map_err(|e| ExecError::InvalidPattern {
203                        pattern: pattern.clone(),
204                        message: e.to_string(),
205                    })?,
206            );
207        }
208        Ok(Self { patterns })
209    }
210
211    fn is_match(&self, line: &str) -> bool {
212        self.patterns.iter().any(|re| re.is_match(line))
213    }
214}