Skip to main content

vtcode_core/tools/
exec_read_target.rs

1//! Exec read-target extraction shared by loop guards and execution-history replay.
2//!
3//! A bounded shell slice read (`sed -n '<range>p' <file>`, `awk 'NR...' <file>`)
4//! is a positioned read of a workspace file, structured the same way as a
5//! `read_file` call with an offset/limit identity. The turn-local read guard and
6//! the cross-turn history replay both need the same (path, slice) identity, so
7//! the parsers live here instead of in one consumer and being re-derived
8//! (divergently) in the other.
9
10use serde_json::Value;
11
12/// A parsed bounded shell read: the target path plus the requested slice.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ExecReadTarget {
15    pub path: String,
16    pub start_line: usize,
17    /// Inclusive end line of the requested slice.
18    pub end_line: usize,
19    /// Whether the command output is the file's own lines. Only a bare
20    /// `sed -n '<range>p' <file>` (no pipe) is verbatim: piped stages and awk
21    /// programs transform the output, so it must never be served as positioned
22    /// file evidence for a verbatim query (and vice versa in replay mode).
23    /// Loop guards use path/slice regardless of this flag.
24    pub verbatim: bool,
25}
26
27/// Lexically normalize a shell-measurement target path so spelling variants
28/// (`./README.md`, `docs/../README.md`) share one identity. Reuses the shared
29/// code_search normalizer: leading `..` is preserved (fail-closed, never
30/// collides with in-workspace entries), so workspace escape via `../` cannot
31/// masquerade as a local read.
32fn normalized_shell_read_path(path: &str) -> String {
33    crate::tools::normalised_code_search_path(path)
34}
35
36/// Parse a bounded shell slice read from command args.
37///
38/// Measurement loops hide a simple read behind a pipe
39/// (`sed -n '65,71p' README.md | awk ...`, `awk 'NR...' README.md | cat`):
40/// only the leading pipeline stage determines what is read, so parse that and
41/// ignore trailing pipe stages. Only `|` is split: `;`/`&&`/`||` compounds
42/// keep their untracked behavior (the full word list fails the simple-shape
43/// parse below), which under-counts rather than over-blocks multi-read
44/// one-liners.
45pub fn parse_exec_read_target(args: &Value) -> Option<ExecReadTarget> {
46    let parts = crate::tools::command_args::command_words(args).ok()??;
47    let leading = leading_shell_segment(parts.as_slice());
48    let piped = leading.len() != parts.len();
49    parse_sed_read_target(leading)
50        .or_else(|| parse_awk_read_target(leading))
51        .map(|mut target| {
52            // A stripped pipe stage transforms the output downstream, so the
53            // lines that reach the model are not the file's own lines.
54            target.verbatim = target.verbatim && !piped;
55            target
56        })
57}
58
59/// Leading pipeline stage before the first `|` token. `command_words` splits
60/// via `shell_words`, so a `|` inside a quoted awk program stays embedded in
61/// the program word and never splits; only a standalone pipe operator cuts.
62fn leading_shell_segment(parts: &[String]) -> &[String] {
63    let end = parts.iter().position(|part| part.as_str() == "|").unwrap_or(parts.len());
64    &parts[..end]
65}
66
67/// Parse `awk '<program>' <single-file>` ranged measurement reads.
68///
69/// KISS scope: exactly `awk`, one program word, one file word — no options
70/// (`-F`, `-v` fail closed to `None`), no multi-file scans, and the program
71/// must reference an `NR` line range (see [`awk_nr_line_range`]). Whole-file
72/// scans stay untracked: distinct whole-file queries are diverse research, not
73/// pagination (mirrors the `code_search` per-path exclusion), so counting them
74/// would false-trip the path cap. Callers gate on read-only classification, so
75/// mutating programs (`>`, `|`, `system()`, `@`) never reach here; no need to
76/// re-scan the program for writes. The file word must not look like a flag.
77/// Awk output is always computed, never the file's own lines, so `verbatim` is
78/// always false.
79fn parse_awk_read_target(parts: &[String]) -> Option<ExecReadTarget> {
80    let path_word = parts.get(2)?.as_str();
81    let start = parts.first().map(String::as_str)?;
82    if parts.len() != 3 || start != "awk" {
83        return None;
84    }
85    let program = parts.get(1)?.as_str();
86    if program.trim().is_empty() {
87        return None;
88    }
89    if path_word.starts_with('-') || path_word.is_empty() {
90        return None;
91    }
92
93    let (start, end) = awk_nr_line_range(program)?;
94    Some(ExecReadTarget {
95        path: normalized_shell_read_path(path_word),
96        start_line: start,
97        end_line: end,
98        verbatim: false,
99    })
100}
101
102/// Extract the line range referenced by `NR` comparisons in an awk program.
103///
104/// Scans for `NR` then an optional comparison operator then a decimal number.
105/// Only numbers in that position count, so thresholds like `length > 120` (no
106/// `NR`) do not masquerade as line ranges. Requires at least two numbers: a
107/// single `NR` reference (`NR>1` header-skip, one `NR==67` probe, one
108/// `/NR==65/` regex hit) cannot delimit a range, and grouping all such
109/// open-ended scans by path alone would trip the family cap on diverse column
110/// queries over the same file. Returns `(min, max)` over all matches.
111fn awk_nr_line_range(program: &str) -> Option<(usize, usize)> {
112    let bytes = program.as_bytes();
113    let mut numbers: Vec<usize> = Vec::new();
114    let mut index = 0;
115    while index + 1 < bytes.len() {
116        if bytes[index] == b'N' && bytes[index + 1] == b'R' {
117            let mut cursor = index + 2;
118            while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
119                cursor += 1;
120            }
121            while cursor < bytes.len() && matches!(bytes[cursor], b'>' | b'<' | b'=' | b'!') {
122                cursor += 1;
123            }
124            while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
125                cursor += 1;
126            }
127            let start = cursor;
128            while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
129                cursor += 1;
130            }
131            if start < cursor
132                && let Ok(number) = program[start..cursor].parse::<usize>()
133            {
134                numbers.push(number);
135            }
136            index = cursor.max(index + 1);
137        } else {
138            index += 1;
139        }
140    }
141    let min = *numbers.iter().min()?;
142    let max = *numbers.iter().max()?;
143    // A lone `NR` reference cannot delimit a range (see doc comment); without
144    // this, every `NR>1` header-skip would collapse to `off=1::lim=1` and trip
145    // the family cap on diverse same-file queries.
146    if numbers.len() < 2 {
147        return None;
148    }
149    // Sanity: line numbers, not byte counts. Ranges spanning millions of
150    // lines are not real file slices; leave untracked.
151    if max == 0 || max.saturating_sub(min) > 1_000_000 {
152        return None;
153    }
154    Some((min, max))
155}
156
157/// Parse `sed -n '<range>p' <file>` (single file, no options). `-` (stdin) is
158/// not a file read.
159fn parse_sed_read_target(parts: &[String]) -> Option<ExecReadTarget> {
160    if parts.first().map(String::as_str) != Some("sed") {
161        return None;
162    }
163
164    let mut cursor = 1usize;
165    if parts.get(cursor).map(String::as_str) != Some("-n") {
166        return None;
167    }
168    cursor += 1;
169
170    let script = parts.get(cursor)?.as_str();
171    cursor += 1;
172
173    let path = parts.get(cursor)?.as_str();
174    if cursor + 1 != parts.len() {
175        return None;
176    }
177    // Fail closed on flag-like paths (`sed -n '1p' --help`). `-` (stdin) is
178    // not a file read either.
179    if path.starts_with('-') {
180        return None;
181    }
182
183    let (start, end) = parse_sed_print_range(script)?;
184    Some(ExecReadTarget {
185        path: normalized_shell_read_path(path),
186        start_line: start,
187        end_line: end,
188        // Bare `sed -n '<range>p' <file>` prints the file's own lines. The
189        // caller clears this when a pipe stage was stripped.
190        verbatim: true,
191    })
192}
193
194fn parse_sed_print_range(script: &str) -> Option<(usize, usize)> {
195    let range = script.strip_suffix('p')?;
196    let (start, end) = match range.split_once(',') {
197        Some((start, end)) => (start, end),
198        None => (range, range),
199    };
200
201    let start = start.parse::<usize>().ok()?;
202    let end = end.parse::<usize>().ok()?;
203    (start <= end).then_some((start, end))
204}
205
206#[cfg(test)]
207mod tests {
208    use serde_json::json;
209
210    use super::*;
211
212    fn target(command: &str) -> Option<ExecReadTarget> {
213        parse_exec_read_target(&json!({"cmd": command}))
214    }
215
216    #[test]
217    fn sed_slice_read_parses_its_path_and_range() {
218        let read = target("sed -n '270,300p' README.md").expect("simple sed read parses");
219        assert_eq!(read.path, "README.md");
220        assert_eq!(read.start_line, 270);
221        assert_eq!(read.end_line, 300);
222        assert!(read.verbatim);
223    }
224
225    #[test]
226    fn piped_sed_read_loses_verbatim_but_keeps_identity() {
227        let read = target("sed -n '65,71p' README.md | awk '{print length}'").expect("piped sed parses");
228        assert_eq!(read.path, "README.md");
229        assert!(!read.verbatim);
230    }
231
232    #[test]
233    fn awk_measurement_read_is_never_verbatim() {
234        let read = target("awk 'NR>=65 && NR<=71' README.md").expect("awk read parses");
235        assert_eq!(read.path, "README.md");
236        assert!(!read.verbatim);
237    }
238
239    #[test]
240    fn mutating_and_dynamic_shapes_fail_closed() {
241        // `sed -i` mutates (different flag) — never parsed as a read target.
242        assert!(target("sed -i 's/a/b/' README.md").is_none());
243        // Path normalization preserves workspace escape spelled as leading `..`.
244        let escape = target("sed -n '1,2p' ../README.md").expect("escape path parses");
245        assert!(escape.path.starts_with(".."), "{}", escape.path);
246        // Wrong arity (`sed` with extra operands) is not a simple read.
247        assert!(target("sed -n '1,2p' README.md LICENSE.md").is_none());
248    }
249}