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}